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 patch_types_an_integer_primary_key_from_its_canonical_row_id() {
577        let schema = json!({
578            "version": 1,
579            "tables": [{
580                "name": "counters",
581                "primaryKey": "id",
582                "columns": [
583                    { "name": "id", "type": "integer", "nullable": false },
584                    { "name": "title", "type": "string", "nullable": false }
585                ],
586                "scopes": []
587            }]
588        });
589        let mut client = SyncClient::new("int-patch".into(), &schema, ClientLimits::default())
590            .expect("test client");
591        client
592            .write_base_row(
593                "counters",
594                &vec![
595                    Some(ColumnValue::Integer(42)),
596                    Some(ColumnValue::String("before".into())),
597                ],
598                1,
599            )
600            .unwrap();
601        client.rebuild_overlay().expect("rebuild overlay");
602        client
603            .patch(
604                "counters",
605                "42",
606                Map::from_iter([
607                    ("id".to_owned(), json!(42)),
608                    ("title".to_owned(), json!("after")),
609                ]),
610                None,
611            )
612            .expect("an integer key that renders as the rowId is not a change");
613        let rows = client.query("SELECT id, title FROM counters", &[]).unwrap();
614        assert_eq!(Value::from(rows), json!([{"id": 42, "title": "after"}]));
615        for row_id in ["042", "+42", "42.0"] {
616            let error = client
617                .patch(
618                    "counters",
619                    row_id,
620                    Map::from_iter([("title".to_owned(), json!("x"))]),
621                    None,
622                )
623                .expect_err("a non-canonical integer rowId names no row");
624            assert_eq!(error.code, "sync.row_missing", "{row_id}");
625        }
626    }
627
628    #[test]
629    fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
630        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
631            {"name":"id","type":"string","nullable":false},
632            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
633        for state in ["absent", "cached", "pinned"] {
634            let path = std::env::temp_dir().join(format!(
635                "syncular-blob-stage-{}.sqlite",
636                uuid::Uuid::new_v4()
637            ));
638            let mut client = SyncClient::open_path(
639                "stage-test".into(),
640                &schema,
641                Default::default(),
642                path.to_str().unwrap(),
643            )
644            .unwrap();
645            let bytes = b"atomic stage";
646            if state != "absent" {
647                client
648                    .upload_blob(bytes, Some("text/plain".into()), None)
649                    .unwrap();
650                if state == "cached" {
651                    client
652                        .conn
653                        .execute("DELETE FROM _syncular_blob_uploads", [])
654                        .unwrap();
655                }
656            }
657            let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
658            client
659                .conn
660                .execute_batch(
661                    "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
662                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
663                 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
664                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
665                )
666                .unwrap();
667            let error = client
668                .upload_blob(bytes, Some("application/octet-stream".into()), None)
669                .unwrap_err();
670            assert!(error.contains("injected stage failure"), "{state}: {error}");
671            assert!(client.conn.is_autocommit());
672            let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
673            drop(client);
674            let mut reopened = SyncClient::open_path(
675                "stage-test".into(),
676                &schema,
677                Default::default(),
678                path.to_str().unwrap(),
679            )
680            .unwrap();
681            let durable = reopened
682                .query("SELECT * FROM _syncular_blobs", &[])
683                .unwrap();
684            reopened
685                .conn
686                .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
687                .unwrap();
688            let retried = reopened.upload_blob(bytes, None, None).unwrap();
689            assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
690            assert_eq!(
691                reopened
692                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
693                    .unwrap(),
694                vec![Map::from_iter([(
695                    "blob_id".into(),
696                    Value::from(blob_id_for(bytes)),
697                )])]
698            );
699            drop(reopened);
700            std::fs::remove_file(&path).unwrap();
701            assert_eq!(durable, bodies, "{state}: reopened body state");
702            assert_eq!(immediate, bodies, "{state}: immediate body state");
703        }
704    }
705
706    #[test]
707    fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
708        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
709            {"name":"id","type":"string","nullable":false},
710            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
711        for fault in [
712            "missing",
713            "body-type",
714            "length",
715            "hash",
716            "pin-metadata",
717            "read",
718            "pin-delete",
719        ] {
720            let path = std::env::temp_dir().join(format!(
721                "syncular-blob-upload-{}.sqlite",
722                uuid::Uuid::new_v4()
723            ));
724            let mut client = SyncClient::open_path(
725                "upload-test".into(),
726                &schema,
727                Default::default(),
728                path.to_str().unwrap(),
729            )
730            .unwrap();
731            let bytes = b"durable upload";
732            let ref_value = client
733                .upload_blob(bytes, Some("text/plain".into()), None)
734                .unwrap();
735            let commit = client
736                .mutate(vec![Mutation::Upsert {
737                    table: "attachments".into(),
738                    values: Map::from_iter([
739                        ("id".into(), Value::from("pending")),
740                        (
741                            "file".into(),
742                            Value::from(format!(
743                                "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
744                                blob_id_for(bytes),
745                                bytes.len()
746                            )),
747                        ),
748                    ]),
749                    base_version: None,
750                }])
751                .unwrap();
752            client
753                .conn
754                .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
755                .unwrap();
756            client.conn.execute_batch(match fault {
757                "missing" => "DELETE FROM _syncular_blobs",
758                "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
759                "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
760                "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
761                "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
762                "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
763                _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
764            }).unwrap();
765            let state = if fault == "read" {
766                Vec::new()
767            } else {
768                client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
769            };
770            let mut transport = CountingRealtimeTransport::default();
771            let error = client.flush_blob_uploads(&mut transport).unwrap_err();
772            assert_eq!(
773                error.code,
774                if matches!(fault, "read" | "pin-delete") {
775                    "client.failed"
776                } else {
777                    "sync.local_corrupt"
778                },
779                "{fault}"
780            );
781            assert_eq!(
782                transport.blob_uploads.len(),
783                usize::from(fault == "pin-delete"),
784                "{fault}: corrupt bytes must never be uploaded"
785            );
786            assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
787            if fault != "read" {
788                assert_eq!(
789                    client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
790                    state
791                );
792            } else {
793                client
794                    .conn
795                    .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
796                    .unwrap();
797            }
798            drop(client);
799            let mut reopened = SyncClient::open_path(
800                "upload-test".into(),
801                &schema,
802                Default::default(),
803                path.to_str().unwrap(),
804            )
805            .unwrap();
806            assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
807            if fault != "read" {
808                assert_eq!(
809                    reopened
810                        .query("SELECT * FROM _syncular_blobs", &[])
811                        .unwrap(),
812                    state
813                );
814            }
815            if fault == "pin-delete" {
816                reopened
817                    .conn
818                    .execute_batch("DROP TRIGGER fail_pin_delete")
819                    .unwrap();
820            }
821            if fault == "pin-metadata" {
822                reopened
823                    .conn
824                    .execute(
825                        "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
826                        [],
827                    )
828                    .unwrap();
829            }
830            reopened
831                .conn
832                .execute_batch(
833                    "DELETE FROM _syncular_blobs;
834                     INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
835                )
836                .unwrap();
837            transport.blob_uploads.clear();
838            reopened.flush_blob_uploads(&mut transport).unwrap();
839            assert_eq!(
840                transport.blob_uploads,
841                [(blob_id_for(bytes), bytes.to_vec())]
842            );
843            assert_eq!(
844                reopened
845                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
846                    .unwrap(),
847                Vec::<Map<String, Value>>::new()
848            );
849            assert_eq!(reopened.pending_commit_ids(), [commit]);
850            assert_eq!(
851                reopened
852                    .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
853                    .unwrap()
854                    .unwrap()
855                    .bytes,
856                bytes
857            );
858            drop(reopened);
859            std::fs::remove_file(path).unwrap();
860        }
861    }
862
863    #[test]
864    fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
865        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
866            {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
867            "scopes":[{"pattern":"project:{project_id}"}]}]});
868        for realtime in [false, true] {
869            for fault in [
870                "none",
871                "row",
872                "revision",
873                "commit",
874                "cursor",
875                "error",
876                "missing-end",
877            ] {
878                if realtime && fault == "missing-end" {
879                    continue;
880                }
881                let path = std::env::temp_dir()
882                    .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
883                let mut client = SyncClient::open_path(
884                    "frame-test".into(),
885                    &schema,
886                    Default::default(),
887                    path.to_str().unwrap(),
888                )
889                .unwrap();
890                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
891                client
892                    .subscribe(
893                        "tasks".into(),
894                        "tasks".into(),
895                        vec![("project_id".into(), vec!["p1".into()])],
896                        None,
897                    )
898                    .unwrap();
899                client.subs[0].cursor = 0;
900                client.subs[0].synced_once = true;
901                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
902                client.persist_sub(&client.subs[0]).unwrap();
903                client.realtime_state = RealtimeState::Connected;
904                client.realtime_reason_code = None;
905                client.drain_change_batches();
906                let mut transport = CountingRealtimeTransport::default();
907                let (_, meta) = client.build_request(false).unwrap();
908                let mut response = Message {
909                    wire_version: WIRE_VERSION,
910                    msg_kind: MsgKind::Response,
911                    frames: vec![
912                        Frame::RespHeader {
913                            required_schema_version: None,
914                            latest_schema_version: None,
915                            log_epoch: Some("epoch-1".into()),
916                            reset_required: Some(false),
917                        },
918                        Frame::SubStart {
919                            id: "tasks".into(),
920                            status: SubStatus::Active,
921                            reason_code: "".into(),
922                            effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
923                            bootstrap: false,
924                        },
925                    ],
926                };
927                for (index, id) in ["first", "second"].iter().enumerate() {
928                    let values = Map::from_iter([
929                        ("id".into(), Value::from(*id)),
930                        ("project_id".into(), Value::from("p1")),
931                    ]);
932                    response.frames.push(Frame::Commit {
933                        commit_seq: index as i64 + 1,
934                        actor_id: "writer".into(),
935                        created_at_ms: 1,
936                        tables: vec!["tasks".into()],
937                        changes: vec![ssp2::model::Change {
938                            table_index: 0,
939                            row_id: (*id).into(),
940                            op: Op::Upsert,
941                            row_version: Some(index as i64 + 1),
942                            scopes: vec![("project_id".into(), "p1".into())],
943                            row: Some(
944                                encode_row_json(
945                                    client.schema.table("tasks").unwrap(),
946                                    id,
947                                    &values,
948                                    &client.encryption,
949                                )
950                                .unwrap(),
951                            ),
952                        }],
953                    });
954                }
955                response.frames.push(Frame::SubEnd {
956                    next_cursor: 2,
957                    bootstrap_state: None,
958                });
959                let valid = response.clone();
960                match fault {
961                    "row" => {
962                        let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
963                        let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
964                    },
965                    "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(),
966                    "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(),
967                    "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
968                    "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});},
969                    "missing-end" => {response.frames.truncate(3);},
970                    _ => {}
971                }
972                if realtime {
973                    client.on_realtime_binary(&mut transport, &encode_message(&response));
974                } else {
975                    assert_eq!(
976                        matches!(
977                            client.process_response(&mut transport, response, &meta),
978                            SyncOutcome::Ok(_)
979                        ),
980                        fault == "none",
981                        "{fault}"
982                    );
983                }
984                assert!(
985                    client.conn.is_autocommit(),
986                    "no open transaction after {realtime}/{fault}"
987                );
988                let count = if matches!(fault, "none" | "cursor") {
989                    2
990                } else {
991                    1
992                };
993                let expected_revision = count + u64::from(realtime && fault != "none");
994                assert_eq!(
995                    client.local_revision(),
996                    expected_revision,
997                    "{realtime}/{fault}"
998                );
999                let batches = client.drain_change_batches();
1000                assert_eq!(
1001                    batches.len(),
1002                    expected_revision as usize,
1003                    "{realtime}/{fault}"
1004                );
1005                let row_batches: Vec<_> = batches
1006                    .iter()
1007                    .filter(|batch| !batch.tables.is_empty())
1008                    .collect();
1009                assert_eq!(row_batches.len(), count as usize);
1010                assert_eq!(
1011                    row_batches
1012                        .iter()
1013                        .map(|b| b.revision.clone())
1014                        .collect::<Vec<_>>(),
1015                    (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
1016                );
1017                for table in ["tasks", "_syncular_base_tasks"] {
1018                    assert_eq!(
1019                        client
1020                            .conn
1021                            .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
1022                                .get::<_, u64>(0))
1023                            .unwrap(),
1024                        count,
1025                        "{realtime}/{fault}/{table}"
1026                    );
1027                }
1028                assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1029                assert_eq!(
1030                    transport
1031                        .messages
1032                        .iter()
1033                        .filter(|m| m.contains("\"ack\""))
1034                        .count(),
1035                    usize::from(fault == "none")
1036                );
1037                if matches!(fault, "revision" | "commit" | "cursor") {
1038                    client
1039                        .conn
1040                        .execute_batch("DROP TRIGGER fail_frame")
1041                        .unwrap();
1042                }
1043                drop(client);
1044                let mut reopened = SyncClient::open_path(
1045                    "frame-test".into(),
1046                    &schema,
1047                    Default::default(),
1048                    path.to_str().unwrap(),
1049                )
1050                .unwrap();
1051                assert_eq!(reopened.local_revision(), expected_revision);
1052                assert_eq!(
1053                    reopened
1054                        .query("SELECT id FROM tasks ORDER BY id", &[])
1055                        .unwrap()
1056                        .len(),
1057                    count as usize
1058                );
1059                assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1060                let (_, meta) = reopened.build_request(false).unwrap();
1061                assert!(matches!(
1062                    reopened.process_response(&mut transport, valid, &meta),
1063                    SyncOutcome::Ok(_)
1064                ));
1065                assert_eq!(
1066                    reopened
1067                        .query("SELECT id FROM tasks ORDER BY id", &[])
1068                        .unwrap()
1069                        .len(),
1070                    2
1071                );
1072                assert_eq!(reopened.subs[0].cursor, 2);
1073                drop(reopened);
1074                std::fs::remove_file(path).unwrap();
1075            }
1076        }
1077    }
1078
1079    #[test]
1080    fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
1081        for status in [SubStatus::Reset, SubStatus::Revoked] {
1082            for fault in ["subscription", "revision"] {
1083                let mut client = client();
1084                client
1085                    .subscribe(
1086                        "tasks".into(),
1087                        "tasks".into(),
1088                        vec![("project_id".into(), vec!["p1".into()])],
1089                        None,
1090                    )
1091                    .unwrap();
1092                client.subs[0].cursor = 41;
1093                client.subs[0].synced_once = true;
1094                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1095                client.persist_sub(&client.subs[0]).unwrap();
1096                client
1097                    .mutate(vec![Mutation::Upsert {
1098                        table: "tasks".into(),
1099                        values: Map::from_iter([
1100                            ("id".into(), Value::from("pending")),
1101                            ("project_id".into(), Value::from("p1")),
1102                        ]),
1103                        base_version: None,
1104                    }])
1105                    .unwrap();
1106                let ids = client.pending_commit_ids();
1107                let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
1108                let revision = client.local_revision();
1109                client.drain_change_batches();
1110                let (_, meta) = client.build_request(false).unwrap();
1111                client.conn.execute_batch(if fault == "subscription" {
1112                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
1113                } else {
1114                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
1115                }).unwrap();
1116                let mut transport = CountingRealtimeTransport::default();
1117                let mut report = SyncReport::default();
1118                // Reset changes window completeness and therefore needs a revision.
1119                if status == SubStatus::Reset {
1120                    client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
1121                }
1122                let result = client.process_section(
1123                    &mut transport,
1124                    "tasks",
1125                    status,
1126                    "sync.scope_revoked",
1127                    vec![],
1128                    vec![],
1129                    Some((42, None)),
1130                    &meta,
1131                    &mut report,
1132                );
1133                assert!(result.is_err(), "{status:?}/{fault}");
1134                assert!(client.conn.is_autocommit());
1135                assert_eq!(client.subs[0].cursor, 41);
1136                assert_eq!(client.subs[0].state, SubState::Active);
1137                assert_eq!(client.pending_commit_ids(), ids);
1138                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
1139                assert!(client.rejections.is_empty());
1140                assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
1141                assert_eq!(client.local_revision(), revision);
1142                assert!(client.drain_change_batches().is_empty());
1143                assert!(report.revoked.is_empty() && report.resets.is_empty());
1144            }
1145        }
1146    }
1147
1148    #[test]
1149    fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
1150        let mut client = client();
1151        client
1152            .subscribe(
1153                "tasks".into(),
1154                "tasks".into(),
1155                vec![("project_id".into(), vec!["p1".into()])],
1156                None,
1157            )
1158            .unwrap();
1159        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1160        client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
1161            CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
1162        let row = |id: &str| ssp2::segment::SegmentRow {
1163            server_version: 1,
1164            values: vec![
1165                Some(ColumnValue::String(id.into())),
1166                Some(ColumnValue::String("p1".into())),
1167            ],
1168        };
1169        let segment = RowsSegment {
1170            table: "tasks".into(),
1171            schema_version: 1,
1172            columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
1173            blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
1174        };
1175        assert!(client.apply_segment(0, &segment, true).is_err());
1176        assert!(client.conn.is_autocommit());
1177        assert_eq!(
1178            client
1179                .query("SELECT id FROM tasks ORDER BY id", &[])
1180                .unwrap(),
1181            vec![Map::from_iter([("id".into(), Value::from("first"))])]
1182        );
1183        assert_eq!(client.local_revision(), 1);
1184        assert_eq!(client.drain_change_batches().len(), 1);
1185        assert_eq!(client.subs[0].cursor, -1);
1186        client
1187            .conn
1188            .execute_batch("DROP TRIGGER fail_block")
1189            .unwrap();
1190        assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
1191        assert_eq!(client.local_revision(), 3);
1192        assert_eq!(client.drain_change_batches().len(), 2);
1193    }
1194
1195    #[test]
1196    fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
1197        assert_eq!(
1198            client().diagnostics_storage().blob_cache_bytes_approx,
1199            Some(0)
1200        );
1201        let client = SyncClient::new(
1202            "diagnostic-storage".to_owned(),
1203            &json!({
1204                "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
1205                    "columns": [{"name": "id", "type": "string", "nullable": false},
1206                        {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
1207            }),
1208            ClientLimits {
1209                blob_cache_max_bytes: Some(5),
1210                ..Default::default()
1211            },
1212        )
1213        .unwrap();
1214        let empty = client.diagnostics_storage();
1215        assert_eq!(empty.status, "healthy");
1216        assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
1217        assert_eq!(empty.retained_outcome_entries, Some(0));
1218        assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1219        assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1220        client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1221            INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1222                VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1223            INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1224                VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1225        let written = client.diagnostics_storage();
1226        assert_eq!(written.status, "pressure");
1227        assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1228        assert_eq!(written.retained_outcome_entries, Some(2));
1229        assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1230        assert_eq!(written.blob_cache_bytes_approx, Some(8));
1231        assert_eq!(
1232            written.pressure_reason_code.as_deref(),
1233            Some("client.blob_cache_over_limit")
1234        );
1235        client
1236            .conn
1237            .execute_batch(
1238                "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1239            DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1240            )
1241            .unwrap();
1242        let staged = client.diagnostics_storage();
1243        assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1244        assert_eq!(staged.retained_outcome_entries, Some(0));
1245        assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1246        assert_eq!(staged.status, "healthy");
1247        client.conn.execute_batch("ROLLBACK").unwrap();
1248        assert_eq!(client.diagnostics_storage(), written);
1249        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1250        let unreadable = client.diagnostics_storage();
1251        assert_eq!(unreadable.status, "unreadable");
1252        assert!(unreadable.database_bytes_approx.is_none());
1253        assert!(unreadable.pending_outbox_bytes_approx.is_none());
1254        assert!(unreadable.retained_outcome_entries.is_none());
1255        assert!(unreadable.retained_outcome_bytes_approx.is_none());
1256        assert!(unreadable.blob_cache_bytes_approx.is_none());
1257        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1258        let restored = client.diagnostics_storage();
1259        assert_eq!(restored.status, written.status);
1260        assert_eq!(
1261            restored.retained_outcome_bytes_approx,
1262            written.retained_outcome_bytes_approx
1263        );
1264        client.conn.set_prepared_statement_cache_capacity(0);
1265        assert_eq!(client.diagnostics_storage(), restored);
1266    }
1267
1268    /// Answers every round with one server `ERROR` frame (§1.6).
1269    struct ServerErrorTransport {
1270        code: &'static str,
1271        retryable: bool,
1272    }
1273
1274    impl Transport for ServerErrorTransport {
1275        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1276            Ok(encode_message(&Message {
1277                wire_version: WIRE_VERSION,
1278                msg_kind: MsgKind::Response,
1279                frames: vec![
1280                    Frame::RespHeader {
1281                        required_schema_version: None,
1282                        latest_schema_version: None,
1283                        log_epoch: Some("epoch-1".to_owned()),
1284                        reset_required: Some(false),
1285                    },
1286                    Frame::Error {
1287                        code: self.code.to_owned(),
1288                        message: "server error".to_owned(),
1289                        category: "internal".to_owned(),
1290                        retryable: self.retryable,
1291                        recommended_action: "retryLater".to_owned(),
1292                        details: None,
1293                    },
1294                ],
1295            }))
1296        }
1297
1298        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1299            self.sync(request)
1300        }
1301
1302        fn download_segment(
1303            &mut self,
1304            _request: &SegmentRequest,
1305            _on_progress: &mut dyn FnMut(u64),
1306        ) -> Result<Vec<u8>, TransportError> {
1307            Err(TransportError::new("sync.transport_failed", "offline"))
1308        }
1309
1310        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1311            Ok(())
1312        }
1313
1314        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1315            Ok(())
1316        }
1317
1318        fn realtime_close(&mut self) -> Result<(), TransportError> {
1319            Ok(())
1320        }
1321    }
1322
1323    #[test]
1324    fn retryable_server_error_schedules_a_background_retry() {
1325        let mut client = client();
1326        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1327        let mut transport = ServerErrorTransport {
1328            code: "sync.internal_error",
1329            retryable: true,
1330        };
1331        assert!(matches!(
1332            client.sync(&mut transport),
1333            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1334        ));
1335        assert!(matches!(
1336            client.drain_sync_intents().as_slice(),
1337            [SyncIntent::Background { delay_ms: 250 }]
1338        ));
1339        assert_eq!(
1340            client.progress().snapshot().unwrap().retry_delay_ms,
1341            Some(250)
1342        );
1343        let mut transport = ServerErrorTransport {
1344            code: "sync.invalid_request",
1345            retryable: false,
1346        };
1347        assert!(matches!(
1348            client.sync(&mut transport),
1349            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1350        ));
1351        assert!(client.drain_sync_intents().is_empty());
1352        let progress = client.progress().snapshot().unwrap();
1353        assert_eq!(progress.state, ProgressState::Failed);
1354        assert_eq!(progress.retry_delay_ms, None);
1355    }
1356
1357    #[test]
1358    fn sync_until_idle_reports_budget_exhaustion_or_failure() {
1359        // §7.7: the exhausted outcome is a partial success, not a failure,
1360        // and serializes with an explicit flag.
1361        let exhausted = SyncOutcome::BudgetExhausted(SyncReport::default()).to_json();
1362        assert_eq!(exhausted["ok"], json!(true));
1363        assert_eq!(exhausted["budgetExhausted"], json!(true));
1364        assert!(exhausted["report"].is_object());
1365
1366        let mut client = client();
1367        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1368        // Zero explicit budget is a caller error, not an exhaustion.
1369        let mut idle_transport = HostTransport::new_from_config(&json!({})).unwrap();
1370        let zero = client.sync_until_idle(&mut idle_transport, Some(0));
1371        assert!(
1372            matches!(zero, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"),
1373            "{zero:?}"
1374        );
1375        // A real server failure stays a failure across the budget loop.
1376        let mut erroring = ServerErrorTransport {
1377            code: "sync.internal_error",
1378            retryable: false,
1379        };
1380        let failed = client.sync_until_idle(&mut erroring, Some(3));
1381        assert!(
1382            matches!(failed, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"),
1383            "{failed:?}"
1384        );
1385    }
1386
1387    /// Returns one pre-built response per round so a test can pin a successful
1388    /// work-bearing round followed by a failure or a schema floor.
1389    struct ScriptedSyncTransport {
1390        responses: std::collections::VecDeque<Message>,
1391        calls: usize,
1392    }
1393
1394    impl Transport for ScriptedSyncTransport {
1395        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1396            self.calls += 1;
1397            let response = self.responses.pop_front().expect("scripted response");
1398            Ok(encode_message(&response))
1399        }
1400
1401        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1402            self.sync(request)
1403        }
1404
1405        fn download_segment(
1406            &mut self,
1407            _request: &SegmentRequest,
1408            _on_progress: &mut dyn FnMut(u64),
1409        ) -> Result<Vec<u8>, TransportError> {
1410            Err(TransportError::new("sync.transport_failed", "offline"))
1411        }
1412
1413        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1414            Ok(())
1415        }
1416
1417        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1418            Ok(())
1419        }
1420
1421        fn realtime_close(&mut self) -> Result<(), TransportError> {
1422            Ok(())
1423        }
1424    }
1425
1426    #[test]
1427    fn sync_until_idle_success_then_failure_keeps_earlier_work() {
1428        let path = std::env::temp_dir().join(format!(
1429            "syncular-budget-script-{}.sqlite",
1430            uuid::Uuid::new_v4()
1431        ));
1432        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1433            {"name":"id","type":"string","nullable":false},
1434            {"name":"project_id","type":"string","nullable":false}],
1435            "scopes":[{"pattern":"project:{project_id}"}]}]});
1436        let mut client = SyncClient::open_path(
1437            "budget-script".into(),
1438            &schema,
1439            ClientLimits::default(),
1440            path.to_str().unwrap(),
1441        )
1442        .expect("open client");
1443        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1444        client
1445            .subscribe(
1446                "tasks".into(),
1447                "tasks".into(),
1448                vec![("project_id".into(), vec!["p1".into()])],
1449                None,
1450            )
1451            .unwrap();
1452        client.subs[0].cursor = 0;
1453        client.subs[0].synced_once = true;
1454        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1455        client.persist_sub(&client.subs[0]).unwrap();
1456        client.drain_change_batches();
1457
1458        let row = encode_row_json(
1459            client.schema.table("tasks").unwrap(),
1460            "kept",
1461            &Map::from_iter([
1462                ("id".into(), json!("kept")),
1463                ("project_id".into(), json!("p1")),
1464            ]),
1465            &client.encryption,
1466        )
1467        .unwrap();
1468        let success = Message {
1469            wire_version: WIRE_VERSION,
1470            msg_kind: MsgKind::Response,
1471            frames: vec![
1472                Frame::RespHeader {
1473                    required_schema_version: None,
1474                    latest_schema_version: None,
1475                    log_epoch: Some("epoch-1".into()),
1476                    reset_required: Some(false),
1477                },
1478                Frame::SubStart {
1479                    id: "tasks".into(),
1480                    status: SubStatus::Active,
1481                    reason_code: String::new(),
1482                    effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1483                    bootstrap: false,
1484                },
1485                Frame::Commit {
1486                    commit_seq: 1,
1487                    created_at_ms: 1,
1488                    actor_id: "writer".into(),
1489                    tables: vec!["tasks".into()],
1490                    changes: vec![ssp2::model::Change {
1491                        table_index: 0,
1492                        row_id: "kept".into(),
1493                        op: Op::Upsert,
1494                        row_version: Some(1),
1495                        scopes: vec![("project_id".into(), "p1".into())],
1496                        row: Some(row),
1497                    }],
1498                },
1499                Frame::SubEnd {
1500                    next_cursor: 1,
1501                    bootstrap_state: None,
1502                },
1503            ],
1504        };
1505        let failure = Message {
1506            wire_version: WIRE_VERSION,
1507            msg_kind: MsgKind::Response,
1508            frames: vec![
1509                Frame::RespHeader {
1510                    required_schema_version: None,
1511                    latest_schema_version: None,
1512                    log_epoch: Some("epoch-1".into()),
1513                    reset_required: Some(false),
1514                },
1515                Frame::Error {
1516                    code: "sync.internal_error".into(),
1517                    message: "second round failed".into(),
1518                    category: "internal".into(),
1519                    retryable: false,
1520                    recommended_action: "retryLater".into(),
1521                    details: None,
1522                },
1523            ],
1524        };
1525        let mut transport = ScriptedSyncTransport {
1526            responses: std::collections::VecDeque::from([success, failure]),
1527            calls: 0,
1528        };
1529        let outcome = client.sync_until_idle(&mut transport, Some(2));
1530        assert!(
1531            matches!(outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"),
1532            "{outcome:?}"
1533        );
1534        assert_eq!(
1535            transport.calls, 2,
1536            "one successful round then the failing round"
1537        );
1538        assert_eq!(
1539            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
1540            json!("kept"),
1541            "the earlier round's durable work survives the later failure"
1542        );
1543        drop(client);
1544        let _ = std::fs::remove_file(&path);
1545    }
1546
1547    #[test]
1548    fn sync_until_idle_stops_on_a_schema_floor() {
1549        let mut client = client();
1550        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1551        let floor = Message {
1552            wire_version: WIRE_VERSION,
1553            msg_kind: MsgKind::Response,
1554            frames: vec![Frame::RespHeader {
1555                required_schema_version: Some(2),
1556                latest_schema_version: Some(2),
1557                log_epoch: Some("epoch-1".into()),
1558                reset_required: Some(false),
1559            }],
1560        };
1561        let mut transport = ScriptedSyncTransport {
1562            responses: std::collections::VecDeque::from([floor.clone(), floor]),
1563            calls: 0,
1564        };
1565        let outcome = client.sync_until_idle(&mut transport, Some(3));
1566        match outcome {
1567            SyncOutcome::Ok(report) => assert_eq!(
1568                report
1569                    .schema_floor
1570                    .as_ref()
1571                    .and_then(|floor| floor.required_schema_version),
1572                Some(2),
1573                "the stop retains the latest schema floor"
1574            ),
1575            other => panic!("expected a schema-floor stop, got {other:?}"),
1576        }
1577        assert_eq!(
1578            transport.calls, 1,
1579            "a schema floor stops the budget loop after one round"
1580        );
1581    }
1582
1583    #[test]
1584    fn sync_report_merge_schema_floor_is_latest_state() {
1585        let mut aggregate = SyncReport {
1586            schema_floor: Some(SchemaFloor {
1587                required_schema_version: Some(2),
1588                latest_schema_version: Some(2),
1589            }),
1590            ..SyncReport::default()
1591        };
1592        aggregate.merge(&SyncReport::default());
1593        assert!(
1594            aggregate.schema_floor.is_none(),
1595            "a later round without a floor clears it"
1596        );
1597    }
1598
1599    #[test]
1600    fn push_capacity_limits_are_hard_and_keep_intent() {
1601        // The configured operation cap is hard for the first commit.
1602        let mut c = client();
1603        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1604        c.limits.max_push_operations_per_request = Some(1);
1605        let id = c
1606            .mutate(vec![
1607                Mutation::Upsert {
1608                    table: "tasks".into(),
1609                    values: Map::from_iter([
1610                        ("id".into(), json!("a")),
1611                        ("project_id".into(), json!("p1")),
1612                    ]),
1613                    base_version: None,
1614                },
1615                Mutation::Upsert {
1616                    table: "tasks".into(),
1617                    values: Map::from_iter([
1618                        ("id".into(), json!("b")),
1619                        ("project_id".into(), json!("p1")),
1620                    ]),
1621                    base_version: None,
1622                },
1623            ])
1624            .unwrap();
1625        let error = c.build_request(false).unwrap_err();
1626        let SyncOutcome::Failed {
1627            error_code,
1628            details,
1629            ..
1630        } = *error
1631        else {
1632            panic!("expected a capacity failure");
1633        };
1634        assert_eq!(error_code, "client.push_request_too_large");
1635        let details = details.expect("capacity details");
1636        assert_eq!(details["kind"], json!("operations"));
1637        assert_eq!(details["limit"], json!(1));
1638        assert_eq!(details["size"], json!(2));
1639        assert_eq!(details["clientCommitId"], json!(id));
1640        assert_eq!(c.pending_commit_ids(), vec![id]);
1641
1642        // The commit-count cap defers whole later commits.
1643        let mut c = client();
1644        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1645        c.limits.max_push_commits_per_request = Some(1);
1646        let first = c
1647            .mutate(vec![Mutation::Upsert {
1648                table: "tasks".into(),
1649                values: Map::from_iter([
1650                    ("id".into(), json!("one")),
1651                    ("project_id".into(), json!("p1")),
1652                ]),
1653                base_version: None,
1654            }])
1655            .unwrap();
1656        let second = c
1657            .mutate(vec![Mutation::Upsert {
1658                table: "tasks".into(),
1659                values: Map::from_iter([
1660                    ("id".into(), json!("two")),
1661                    ("project_id".into(), json!("p1")),
1662                ]),
1663                base_version: None,
1664            }])
1665            .unwrap();
1666        let (_, meta) = c.build_request(false).unwrap();
1667        assert_eq!(meta.pushed_ids, vec![first.clone()]);
1668        assert_eq!(meta.deferred_commits, 1);
1669        assert_eq!(c.pending_commit_ids(), vec![first, second]);
1670    }
1671
1672    #[test]
1673    fn push_request_byte_cap_defers_the_prefix_boundary_and_errors_the_head() {
1674        // Two small commits: a cap just below the full request defers the
1675        // last commit and sends the fitting prefix (header + first commit).
1676        let mut c = client();
1677        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1678        let first = c
1679            .mutate(vec![Mutation::Upsert {
1680                table: "tasks".into(),
1681                values: Map::from_iter([
1682                    ("id".into(), json!("one")),
1683                    ("project_id".into(), json!("p1")),
1684                ]),
1685                base_version: None,
1686            }])
1687            .unwrap();
1688        let second = c
1689            .mutate(vec![Mutation::Upsert {
1690                table: "tasks".into(),
1691                values: Map::from_iter([
1692                    ("id".into(), json!("two")),
1693                    ("project_id".into(), json!("p1")),
1694                ]),
1695                base_version: None,
1696            }])
1697            .unwrap();
1698        let (message, meta) = c.build_request(false).unwrap();
1699        assert_eq!(meta.pushed_ids.len(), 2);
1700        let full = encode_message(&message).len();
1701        c.limits.max_push_request_bytes = Some(full - 1);
1702        let (_, meta) = c.build_request(false).unwrap();
1703        assert_eq!(meta.pushed_ids, vec![first.clone()]);
1704        assert_eq!(meta.deferred_commits, 1);
1705        assert_eq!(c.pending_commit_ids(), vec![first, second]);
1706
1707        // A single commit larger than the budget is a typed capacity error and
1708        // stays queued, reporting the full projected request size.
1709        let mut c = client();
1710        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1711        let huge = c
1712            .mutate(vec![Mutation::Upsert {
1713                table: "tasks".into(),
1714                values: Map::from_iter([
1715                    ("id".into(), json!("x".repeat(20_000))),
1716                    ("project_id".into(), json!("p1")),
1717                ]),
1718                base_version: None,
1719            }])
1720            .unwrap();
1721        let (message, meta) = c.build_request(false).unwrap();
1722        assert_eq!(meta.pushed_ids, vec![huge.clone()]);
1723        let with_huge = encode_message(&message).len();
1724        c.limits.max_push_request_bytes = Some(with_huge - 1);
1725        let error = c.build_request(false).unwrap_err();
1726        let SyncOutcome::Failed {
1727            error_code,
1728            details,
1729            ..
1730        } = *error
1731        else {
1732            panic!("expected a capacity failure");
1733        };
1734        assert_eq!(error_code, "client.push_request_too_large");
1735        let details = details.expect("capacity details");
1736        assert_eq!(details["kind"], json!("bytes"));
1737        assert_eq!(details["limit"], json!((with_huge - 1) as u64));
1738        assert_eq!(details["size"], json!(with_huge as u64));
1739        assert_eq!(details["clientCommitId"], json!(huge));
1740        assert_eq!(c.pending_commit_ids(), vec![huge]);
1741
1742        // A cap smaller than the fixed frames reports no blocked commit.
1743        let mut c = client();
1744        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1745        c.limits.max_push_request_bytes = Some(1);
1746        let error = c.build_request(false).unwrap_err();
1747        let SyncOutcome::Failed { details, .. } = *error else {
1748            panic!("expected a capacity failure");
1749        };
1750        let details = details.expect("capacity details");
1751        assert_eq!(details["kind"], json!("bytes"));
1752        assert!(details.get("clientCommitId").is_none(), "{details}");
1753    }
1754
1755    #[test]
1756    fn push_limits_validate_at_construction() {
1757        for limits in [
1758            ClientLimits {
1759                max_push_commits_per_request: Some(0),
1760                ..Default::default()
1761            },
1762            ClientLimits {
1763                max_push_operations_per_request: Some(0),
1764                ..Default::default()
1765            },
1766            ClientLimits {
1767                max_push_request_bytes: Some(0),
1768                ..Default::default()
1769            },
1770            ClientLimits {
1771                max_push_request_bytes: Some(u32::MAX as usize + 1),
1772                ..Default::default()
1773            },
1774        ] {
1775            let schema = json!({"version":1,"tables":[]});
1776            let error = SyncClient::with_connection(
1777                "bad-limits".into(),
1778                &schema,
1779                limits,
1780                Connection::open_in_memory().unwrap(),
1781            )
1782            .err();
1783            assert_eq!(
1784                error.as_deref(),
1785                Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
1786            );
1787        }
1788    }
1789
1790    #[test]
1791    fn open_path_rejects_invalid_push_limits_before_storage() {
1792        for identity in [false, true] {
1793            let path = std::env::temp_dir().join(format!(
1794                "syncular-bad-limits-{}.sqlite",
1795                uuid::Uuid::new_v4()
1796            ));
1797            let schema = json!({"version":1,"tables":[]});
1798            let limits = ClientLimits {
1799                max_push_request_bytes: Some(0),
1800                ..Default::default()
1801            };
1802            let error = if identity {
1803                SyncClient::open_path_with_identity(None, &schema, limits, path.to_str().unwrap())
1804            } else {
1805                SyncClient::open_path("bad-limits".into(), &schema, limits, path.to_str().unwrap())
1806            }
1807            .err();
1808            assert_eq!(
1809                error.as_deref(),
1810                Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
1811            );
1812            assert!(
1813                !path.exists(),
1814                "an invalid limit is rejected before the database file is created"
1815            );
1816        }
1817    }
1818
1819    #[test]
1820    fn failed_progress_retry_delay_doubles_to_the_cap() {
1821        let mut client = client();
1822        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1823        let mut transport = ServerErrorTransport {
1824            code: "sync.internal_error",
1825            retryable: true,
1826        };
1827        let delays: Vec<Option<u64>> = (0..9)
1828            .map(|_| {
1829                client.sync(&mut transport);
1830                client.progress().snapshot().unwrap().retry_delay_ms
1831            })
1832            .collect();
1833        assert_eq!(
1834            delays,
1835            [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
1836        );
1837    }
1838
1839    #[test]
1840    fn background_retry_deadlines_back_off_and_reset() {
1841        let mut client = client();
1842        client.schedule_background_retry();
1843        assert!(matches!(
1844            client.drain_sync_intents().as_slice(),
1845            [SyncIntent::Background { delay_ms: 250 }]
1846        ));
1847        client.schedule_background_retry();
1848        assert!(matches!(
1849            client.drain_sync_intents().as_slice(),
1850            [SyncIntent::Background { delay_ms: 500 }]
1851        ));
1852        client.reset_background_retry();
1853        client.schedule_background_retry();
1854        assert!(matches!(
1855            client.drain_sync_intents().as_slice(),
1856            [SyncIntent::Background { delay_ms: 250 }]
1857        ));
1858    }
1859
1860    #[test]
1861    fn log_epoch_reset_requests_an_immediate_follow_up_round() {
1862        let mut client = client();
1863        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1864        client
1865            .subscribe(
1866                "epoch-tasks".to_owned(),
1867                "tasks".to_owned(),
1868                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1869                None,
1870            )
1871            .expect("subscribe");
1872        client.drain_sync_intents();
1873
1874        client
1875            .run_log_epoch_reset("epoch-2")
1876            .expect("reset partition log epoch");
1877
1878        assert!(matches!(
1879            client.drain_sync_intents().as_slice(),
1880            [SyncIntent::Interactive]
1881        ));
1882    }
1883
1884    #[derive(Default)]
1885    struct CountingRealtimeTransport {
1886        connects: usize,
1887        closes: usize,
1888        messages: Vec<String>,
1889        blob_uploads: Vec<(String, Vec<u8>)>,
1890        blob_download: Option<Vec<u8>>,
1891        sync_calls: usize,
1892        realtime_sync_calls: usize,
1893        refuse_connect: bool,
1894    }
1895
1896    impl Transport for CountingRealtimeTransport {
1897        fn blob_upload(
1898            &mut self,
1899            blob_id: &str,
1900            bytes: &[u8],
1901            _media_type: Option<&str>,
1902        ) -> Result<(), TransportError> {
1903            self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1904            Ok(())
1905        }
1906
1907        fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1908            self.blob_download
1909                .clone()
1910                .map(BlobDownload::Bytes)
1911                .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1912        }
1913
1914        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1915            self.sync_calls += 1;
1916            Err(TransportError::new("sync.transport_failed", "offline"))
1917        }
1918
1919        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1920            self.realtime_sync_calls += 1;
1921            Err(TransportError::new("sync.transport_failed", "offline"))
1922        }
1923
1924        fn download_segment(
1925            &mut self,
1926            _request: &SegmentRequest,
1927            _on_progress: &mut dyn FnMut(u64),
1928        ) -> Result<Vec<u8>, TransportError> {
1929            Err(TransportError::new("sync.transport_failed", "offline"))
1930        }
1931
1932        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1933            self.connects += 1;
1934            if self.refuse_connect {
1935                return Err(TransportError::new("transport.failed", "refused"));
1936            }
1937            Ok(())
1938        }
1939
1940        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1941            self.messages.push(text.to_owned());
1942            Ok(())
1943        }
1944
1945        fn realtime_close(&mut self) -> Result<(), TransportError> {
1946            self.closes += 1;
1947            Ok(())
1948        }
1949    }
1950
1951    #[test]
1952    fn blob_results_own_downloaded_bytes() {
1953        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1954            {"name":"id","type":"string","nullable":false},
1955            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1956        let bytes = b"owned typed result".to_vec();
1957        let blob_id = blob_id_for(&bytes);
1958        let mut transport = CountingRealtimeTransport {
1959            blob_download: Some(bytes.clone()),
1960            ..Default::default()
1961        };
1962        let result = {
1963            let mut client =
1964                SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1965                    .unwrap();
1966            let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1967            assert_eq!(result.blob_id, blob_id);
1968            assert_eq!(result.byte_length, bytes.len() as i64);
1969            assert_eq!(result.bytes, bytes);
1970            assert_eq!(result.media_type, None);
1971            assert_eq!(
1972                client
1973                    .conn
1974                    .query_row(
1975                        "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1976                        rusqlite::params![blob_id],
1977                        |row| row.get::<_, i64>(0),
1978                    )
1979                    .unwrap(),
1980                0
1981            );
1982            result
1983        };
1984        assert_eq!(result.bytes, bytes);
1985
1986        let mut client =
1987            SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1988        assert_eq!(
1989            client
1990                .fetch_blob_bytes(&mut transport, "not json")
1991                .unwrap_err(),
1992            (
1993                "client.failed".to_owned(),
1994                "blob ref is not JSON".to_owned()
1995            )
1996        );
1997    }
1998
1999    #[test]
2000    fn visible_blob_references_survive_cache_trimming() {
2001        let schema = json!({"version":1,"tables":[
2002            {"name":"attachments","primaryKey":"id","columns":[
2003                {"name":"id","type":"string","nullable":false},
2004                {"name":"body","type":"blob_ref","nullable":true},
2005                {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
2006            {"name":"avatars","primaryKey":"id","columns":[
2007                {"name":"id","type":"string","nullable":false},
2008                {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
2009        ]});
2010        let bytes = b"referenced above cap".to_vec();
2011        let blob_id = blob_id_for(&bytes);
2012        let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
2013        let mut client = SyncClient::new(
2014            "visible-blob-refs".to_owned(),
2015            &schema,
2016            ClientLimits {
2017                blob_cache_max_bytes: Some(1),
2018                ..Default::default()
2019            },
2020        )
2021        .unwrap();
2022        client.conn.execute(
2023            "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
2024            rusqlite::params![reference, reference, reference, reference],
2025        ).unwrap();
2026        client
2027            .conn
2028            .execute(
2029                "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
2030                rusqlite::params![reference],
2031            )
2032            .unwrap();
2033        client.conn.execute(
2034            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
2035            [],
2036        ).unwrap();
2037        let mut transport = CountingRealtimeTransport {
2038            blob_download: Some(bytes.clone()),
2039            ..Default::default()
2040        };
2041
2042        assert_eq!(
2043            client
2044                .fetch_blob_bytes(&mut transport, &blob_id)
2045                .unwrap()
2046                .bytes,
2047            bytes
2048        );
2049        let rows = client
2050            .conn
2051            .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
2052            .unwrap()
2053            .query_map([], |row| row.get::<_, String>(0))
2054            .unwrap()
2055            .collect::<Result<Vec<_>, _>>()
2056            .unwrap();
2057        assert_eq!(rows, vec![blob_id]);
2058    }
2059
2060    #[test]
2061    fn closed_transport_never_invokes_network_and_reopen_is_host_owned() {
2062        let mut transport = CountingRealtimeTransport::default();
2063        let path = std::env::temp_dir().join(format!(
2064            "syncular-transport-gate-{}.db",
2065            uuid::Uuid::new_v4()
2066        ));
2067        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
2068            {"name":"id","type":"string","nullable":false},
2069            {"name":"project_id","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
2070        let mut client = SyncClient::open_path(
2071            "gate".into(),
2072            &schema,
2073            ClientLimits::default(),
2074            path.to_str().unwrap(),
2075        )
2076        .unwrap();
2077        client.connect_realtime(&mut transport).unwrap();
2078        client.set_transport_enabled(&mut transport, false);
2079        assert_eq!(transport.closes, 1);
2080        client.begin_security_preflight();
2081        client.activate_security(Default::default()).unwrap();
2082        client
2083            .subscribe(
2084                "tasks".into(),
2085                "tasks".into(),
2086                vec![("project_id".into(), vec!["p1".into()])],
2087                None,
2088            )
2089            .unwrap();
2090        for id in ["one", "two"] {
2091            client
2092                .mutate(vec![Mutation::Upsert {
2093                    table: "tasks".into(),
2094                    values: Map::from_iter([
2095                        ("id".into(), json!(id)),
2096                        ("project_id".into(), json!("p1")),
2097                    ]),
2098                    base_version: None,
2099                }])
2100                .unwrap();
2101        }
2102        assert_eq!(
2103            client
2104                .query("SELECT id FROM tasks ORDER BY id", &[])
2105                .unwrap()
2106                .len(),
2107            2
2108        );
2109        assert!(
2110            matches!(client.sync(&mut transport), SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
2111        );
2112        assert!(client
2113            .connect_realtime(&mut transport)
2114            .unwrap_err()
2115            .starts_with("sync.offline:"));
2116        assert!(client
2117            .set_presence(&mut transport, "project:p1", None)
2118            .unwrap_err()
2119            .starts_with("sync.offline:"));
2120        assert_eq!(transport.sync_calls, 0);
2121        assert_eq!(transport.realtime_sync_calls, 0);
2122        assert_eq!(transport.connects, 1);
2123        assert!(transport.messages.is_empty());
2124        assert!(matches!(
2125            client.drain_sync_intents().as_slice(),
2126            [SyncIntent::None]
2127        ));
2128        let pending = client.pending_commit_ids();
2129        drop(client);
2130        let mut reopened = SyncClient::open_path(
2131            "gate".into(),
2132            &schema,
2133            ClientLimits::default(),
2134            path.to_str().unwrap(),
2135        )
2136        .unwrap();
2137        assert!(reopened.transport_enabled());
2138        reopened.set_transport_enabled(&mut transport, false);
2139        assert_eq!(reopened.pending_commit_ids(), pending);
2140        assert_eq!(
2141            reopened.query("SELECT id FROM tasks", &[]).unwrap().len(),
2142            2
2143        );
2144        reopened.set_transport_enabled(&mut transport, true);
2145        assert!(matches!(
2146            reopened.drain_sync_intents().as_slice(),
2147            [SyncIntent::Interactive]
2148        ));
2149        reopened.set_transport_enabled(&mut transport, true);
2150        assert!(reopened.drain_sync_intents().is_empty());
2151        drop(reopened);
2152        std::fs::remove_file(path).unwrap();
2153    }
2154
2155    #[test]
2156    fn realtime_connection_ownership_is_idempotent() {
2157        let mut client = client();
2158        let mut transport = CountingRealtimeTransport {
2159            connects: 0,
2160            closes: 0,
2161            ..Default::default()
2162        };
2163        client
2164            .connect_realtime(&mut transport)
2165            .expect("first connect");
2166        client
2167            .connect_realtime(&mut transport)
2168            .expect("idempotent connect");
2169        assert_eq!(transport.connects, 1);
2170        client.disconnect_realtime(&mut transport);
2171        client.disconnect_realtime(&mut transport);
2172        assert_eq!(transport.closes, 1);
2173        client
2174            .connect_realtime(&mut transport)
2175            .expect("deliberate reconnect");
2176        assert_eq!(transport.connects, 2);
2177    }
2178
2179    #[test]
2180    fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
2181        let mut client = client();
2182        client
2183            .set_realtime_policy(RealtimePolicy::Required)
2184            .expect("set policy");
2185        let mut transport = CountingRealtimeTransport::default();
2186        match client.sync(&mut transport) {
2187            SyncOutcome::RealtimeUnavailable {
2188                state,
2189                reason_code,
2190                retry_delay_ms,
2191            } => {
2192                assert_eq!(state, RealtimeState::Disconnected);
2193                assert_eq!(reason_code, None);
2194                assert_eq!(retry_delay_ms, 250);
2195            }
2196            other => panic!("expected RealtimeUnavailable, got {other:?}"),
2197        }
2198        assert_eq!(transport.sync_calls, 0);
2199        assert!(matches!(
2200            client.drain_sync_intents().as_slice(),
2201            [SyncIntent::Background { delay_ms: 250 }]
2202        ));
2203        let snapshot = client
2204            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2205            .expect("diagnostics");
2206        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
2207        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
2208        assert_eq!(snapshot.host.realtime_reason_code, None);
2209        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
2210        assert_eq!(
2211            snapshot
2212                .last_round
2213                .as_ref()
2214                .and_then(|round| round.error_code.clone()),
2215            Some(REALTIME_UNAVAILABLE_CODE.to_owned())
2216        );
2217    }
2218
2219    #[test]
2220    fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
2221        let mut client = client();
2222        client
2223            .set_realtime_policy(RealtimePolicy::Required)
2224            .expect("set policy");
2225        let mut transport = CountingRealtimeTransport {
2226            refuse_connect: true,
2227            ..Default::default()
2228        };
2229        assert!(client.connect_realtime(&mut transport).is_err());
2230        assert_eq!(client.realtime_state(), RealtimeState::Refused);
2231        let snapshot = client
2232            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2233            .expect("diagnostics");
2234        assert_eq!(
2235            snapshot.host.realtime_reason_code.as_deref(),
2236            Some("transport.failed")
2237        );
2238        match client.sync(&mut transport) {
2239            SyncOutcome::RealtimeUnavailable {
2240                state, reason_code, ..
2241            } => {
2242                assert_eq!(state, RealtimeState::Refused);
2243                assert_eq!(reason_code.as_deref(), Some("transport.failed"));
2244            }
2245            other => panic!("expected RealtimeUnavailable, got {other:?}"),
2246        }
2247        assert_eq!(transport.sync_calls, 0);
2248        assert_eq!(transport.realtime_sync_calls, 0);
2249    }
2250
2251    #[test]
2252    fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
2253        let mut client = client();
2254        client
2255            .set_realtime_policy(RealtimePolicy::Required)
2256            .expect("set policy");
2257        let mut transport = CountingRealtimeTransport::default();
2258        client.connect_realtime(&mut transport).expect("connect");
2259        assert_eq!(client.realtime_state(), RealtimeState::Connected);
2260        assert!(
2261            client.set_realtime_policy(RealtimePolicy::Off).is_err(),
2262            "a policy change cannot silently re-route a connected socket"
2263        );
2264        assert!(matches!(
2265            client.sync(&mut transport),
2266            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
2267        ));
2268        assert_eq!(client.realtime_state(), RealtimeState::Lost);
2269        assert_eq!(transport.realtime_sync_calls, 1);
2270        assert_eq!(transport.sync_calls, 0);
2271        let snapshot = client
2272            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2273            .expect("diagnostics");
2274        assert_eq!(
2275            snapshot.host.realtime_reason_code.as_deref(),
2276            Some("sync.transport_failed")
2277        );
2278        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
2279        match client.sync(&mut transport) {
2280            SyncOutcome::RealtimeUnavailable {
2281                state, reason_code, ..
2282            } => {
2283                assert_eq!(state, RealtimeState::Lost);
2284                assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
2285            }
2286            other => panic!("expected RealtimeUnavailable, got {other:?}"),
2287        }
2288        assert_eq!(transport.sync_calls, 0);
2289        client.disconnect_realtime(&mut transport);
2290        assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
2291        assert_eq!(transport.closes, 1);
2292        client.connect_realtime(&mut transport).expect("reconnect");
2293        assert_eq!(client.realtime_state(), RealtimeState::Connected);
2294        assert_eq!(transport.connects, 2);
2295    }
2296
2297    #[test]
2298    fn off_policy_uses_http_and_refuses_an_explicit_connect() {
2299        let mut client = client();
2300        client
2301            .set_realtime_policy(RealtimePolicy::Off)
2302            .expect("set policy");
2303        let mut transport = CountingRealtimeTransport::default();
2304        assert!(client.connect_realtime(&mut transport).is_err());
2305        assert_eq!(client.realtime_state(), RealtimeState::Disabled);
2306        assert!(matches!(
2307            client.sync(&mut transport),
2308            SyncOutcome::Failed { .. }
2309        ));
2310        assert_eq!(transport.sync_calls, 1);
2311        assert_eq!(transport.realtime_sync_calls, 0);
2312        let snapshot = client
2313            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2314            .expect("diagnostics");
2315        assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
2316        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
2317    }
2318
2319    #[test]
2320    fn optional_policy_keeps_the_http_round() {
2321        let mut client = client();
2322        let mut transport = CountingRealtimeTransport::default();
2323        assert!(matches!(
2324            client.sync(&mut transport),
2325            SyncOutcome::Failed { .. }
2326        ));
2327        assert_eq!(transport.sync_calls, 1);
2328        assert_eq!(transport.realtime_sync_calls, 0);
2329        let snapshot = client
2330            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2331            .expect("diagnostics");
2332        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
2333        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
2334    }
2335
2336    #[test]
2337    fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
2338        let mut client = client();
2339        client
2340            .subscribe(
2341                "repair-tasks".to_owned(),
2342                "tasks".to_owned(),
2343                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2344                None,
2345            )
2346            .expect("subscribe");
2347        {
2348            let sub = client
2349                .subs
2350                .iter_mut()
2351                .find(|sub| sub.id == "repair-tasks")
2352                .expect("subscription");
2353            sub.cursor = 42;
2354            sub.synced_once = true;
2355            let persisted = sub.clone();
2356            client.persist_sub(&persisted).unwrap();
2357        }
2358        for table in ["_syncular_base_tasks", "tasks"] {
2359            client
2360                .conn
2361                .execute(
2362                    &format!(
2363                        "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
2364                    ),
2365                    rusqlite::params!["server-row", "p1"],
2366                )
2367                .expect("seed server row");
2368        }
2369        let pending = client
2370            .mutate(vec![Mutation::Upsert {
2371                table: "tasks".to_owned(),
2372                values: Map::from_iter([
2373                    ("id".to_owned(), Value::from("offline-row")),
2374                    ("project_id".to_owned(), Value::from("p1")),
2375                ]),
2376                base_version: None,
2377            }])
2378            .expect("queue offline work");
2379        client.drain_change_batches();
2380        client.drain_sync_intents();
2381
2382        assert_eq!(
2383            client
2384                .rebootstrap_local_data(&LocalDataRebootstrapInput {
2385                    rebootstrap_id: "support-case-001".to_owned(),
2386                })
2387                .expect("rebootstrap"),
2388            LocalDataRebootstrapResult {
2389                already_applied: false,
2390                retained_commits: 1,
2391                reset_subscriptions: 1,
2392            }
2393        );
2394        let visible_ids = client
2395            .conn
2396            .prepare("SELECT id FROM tasks ORDER BY id")
2397            .expect("prepare visible ids")
2398            .query_map([], |row| row.get::<_, String>(0))
2399            .expect("query visible ids")
2400            .collect::<Result<Vec<_>, _>>()
2401            .expect("collect visible ids");
2402        assert_eq!(visible_ids, vec!["offline-row"]);
2403        assert_eq!(client.pending_commit_ids(), vec![pending]);
2404        assert_eq!(
2405            client
2406                .subscription_state("repair-tasks")
2407                .expect("subscription")
2408                .cursor,
2409            -1
2410        );
2411        assert!(client.upgrading());
2412        assert!(client.sync_needed());
2413        assert!(matches!(
2414            client.drain_sync_intents().as_slice(),
2415            [SyncIntent::Interactive]
2416        ));
2417        assert_eq!(client.drain_change_batches().len(), 1);
2418
2419        assert_eq!(
2420            client
2421                .rebootstrap_local_data(&LocalDataRebootstrapInput {
2422                    rebootstrap_id: "support-case-001".to_owned(),
2423                })
2424                .expect("idempotent retry"),
2425            LocalDataRebootstrapResult {
2426                already_applied: true,
2427                retained_commits: 1,
2428                reset_subscriptions: 1,
2429            }
2430        );
2431        assert!(client.drain_change_batches().is_empty());
2432    }
2433
2434    #[test]
2435    fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
2436        let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
2437        assert_eq!(
2438            decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
2439            (3, 4)
2440        );
2441        assert_eq!(
2442            decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
2443            (0, 0)
2444        );
2445        for malformed in [
2446            "",
2447            "{}",
2448            r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
2449            r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
2450            r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
2451        ] {
2452            assert_eq!(
2453                decode_local_rebootstrap_receipt(malformed)
2454                    .expect_err("malformed receipt must fail"),
2455                "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
2456            );
2457        }
2458    }
2459
2460    #[test]
2461    fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
2462        let path = std::env::temp_dir().join(format!(
2463            "syncular-rebootstrap-receipt-{}.db",
2464            uuid::Uuid::new_v4()
2465        ));
2466        let schema = json!({
2467            "version": 1,
2468            "tables": [{
2469                "name": "tasks",
2470                "primaryKey": "id",
2471                "columns": [
2472                    { "name": "id", "type": "string", "nullable": false },
2473                    { "name": "project_id", "type": "string", "nullable": false }
2474                ],
2475                "scopes": [{ "pattern": "project:{project_id}" }]
2476            }]
2477        });
2478        let path_string = path.to_str().expect("UTF-8 temp path");
2479
2480        {
2481            let mut first = SyncClient::open_path(
2482                "repair-restart-client".to_owned(),
2483                &schema,
2484                ClientLimits::default(),
2485                path_string,
2486            )
2487            .expect("first open");
2488            first
2489                .subscribe(
2490                    "repair-tasks".to_owned(),
2491                    "tasks".to_owned(),
2492                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2493                    None,
2494                )
2495                .expect("subscribe");
2496            first
2497                .mutate(vec![Mutation::Upsert {
2498                    table: "tasks".to_owned(),
2499                    values: Map::from_iter([
2500                        ("id".to_owned(), Value::from("offline-row")),
2501                        ("project_id".to_owned(), Value::from("p1")),
2502                    ]),
2503                    base_version: None,
2504                }])
2505                .expect("queue offline work");
2506            assert_eq!(
2507                first
2508                    .rebootstrap_local_data(&LocalDataRebootstrapInput {
2509                        rebootstrap_id: "restart-receipt".to_owned(),
2510                    })
2511                    .expect("first rebootstrap"),
2512                LocalDataRebootstrapResult {
2513                    already_applied: false,
2514                    retained_commits: 1,
2515                    reset_subscriptions: 1,
2516                }
2517            );
2518        }
2519
2520        let mut reopened = SyncClient::open_path(
2521            "repair-restart-client".to_owned(),
2522            &schema,
2523            ClientLimits::default(),
2524            path_string,
2525        )
2526        .expect("reopen");
2527        reopened.drain_change_batches();
2528        reopened.drain_sync_intents();
2529        assert_eq!(
2530            reopened
2531                .rebootstrap_local_data(&LocalDataRebootstrapInput {
2532                    rebootstrap_id: "restart-receipt".to_owned(),
2533                })
2534                .expect("receipt replay"),
2535            LocalDataRebootstrapResult {
2536                already_applied: true,
2537                retained_commits: 1,
2538                reset_subscriptions: 1,
2539            }
2540        );
2541        assert!(reopened.drain_change_batches().is_empty());
2542        assert!(reopened.drain_sync_intents().is_empty());
2543        drop(reopened);
2544        std::fs::remove_file(path).expect("remove temp database");
2545    }
2546
2547    #[test]
2548    fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
2549        let mut malformed = client();
2550        malformed
2551            .subscribe(
2552                "repair-tasks".to_owned(),
2553                "tasks".to_owned(),
2554                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2555                None,
2556            )
2557            .expect("subscribe");
2558        malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
2559        malformed.drain_change_batches();
2560        malformed.drain_sync_intents();
2561        let malformed_error = malformed
2562            .rebootstrap_local_data(&LocalDataRebootstrapInput {
2563                rebootstrap_id: "malformed".to_owned(),
2564            })
2565            .expect_err("malformed receipt must fail closed");
2566        assert_eq!(
2567            malformed_error,
2568            "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
2569        );
2570        assert!(!malformed.upgrading());
2571        assert_eq!(
2572            malformed
2573                .subscription_state("repair-tasks")
2574                .expect("unchanged subscription")
2575                .cursor,
2576            -1
2577        );
2578        assert!(malformed.drain_change_batches().is_empty());
2579        assert!(malformed.drain_sync_intents().is_empty());
2580
2581        let mut unreadable = client();
2582        unreadable
2583            .conn
2584            .execute(
2585                "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
2586                rusqlite::params!["server-row", "p1"],
2587            )
2588            .expect("seed visible row");
2589        unreadable
2590            .conn
2591            .execute("DROP TABLE _syncular_meta", [])
2592            .expect("break marker storage");
2593        let unreadable_error = unreadable
2594            .rebootstrap_local_data(&LocalDataRebootstrapInput {
2595                rebootstrap_id: "unreadable".to_owned(),
2596            })
2597            .expect_err("unreadable marker storage must fail closed");
2598        assert_eq!(
2599            unreadable_error,
2600            "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
2601        );
2602        let visible_rows = unreadable
2603            .conn
2604            .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
2605            .expect("visible projection remains");
2606        assert_eq!(visible_rows, 1);
2607        assert!(!unreadable.upgrading());
2608        assert!(unreadable.drain_change_batches().is_empty());
2609        assert!(unreadable.drain_sync_intents().is_empty());
2610    }
2611
2612    #[test]
2613    fn local_rebootstrap_cannot_bypass_schema_floor() {
2614        let mut client = client();
2615        client.set_schema_floor(Some(SchemaFloor {
2616            required_schema_version: Some(2),
2617            latest_schema_version: Some(2),
2618        }));
2619        let error = client
2620            .rebootstrap_local_data(&LocalDataRebootstrapInput {
2621                rebootstrap_id: "blocked-floor".to_owned(),
2622            })
2623            .expect_err("schema floor must block repair");
2624        assert!(error.contains("cannot bypass an active schema-floor stop"));
2625    }
2626
2627    #[test]
2628    fn diego_applied_sparse_moves_stay_visible_until_pull_image_arrives() {
2629        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2630            "columns":[{"name":"id","type":"string","nullable":false},
2631                {"name":"project_id","type":"string","nullable":false},
2632                {"name":"starts_at_ms","type":"integer","nullable":false},
2633                {"name":"version","type":"integer","nullable":false}],
2634            "scopes":[{"pattern":"project:{project_id}"}]}]});
2635        let mut client =
2636            SyncClient::new("diego-ack-gap".into(), &schema, ClientLimits::default()).unwrap();
2637        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2638        client
2639            .write_base_row(
2640                "tasks",
2641                &vec![
2642                    Some(ColumnValue::String("t1".into())),
2643                    Some(ColumnValue::String("p1".into())),
2644                    Some(ColumnValue::Integer(100)),
2645                    Some(ColumnValue::Integer(1)),
2646                ],
2647                1,
2648            )
2649            .unwrap();
2650        client.rebuild_overlay().expect("rebuild overlay");
2651        let first = client
2652            .patch(
2653                "tasks",
2654                "t1",
2655                Map::from_iter([
2656                    ("starts_at_ms".into(), json!(200)),
2657                    ("version".into(), json!(2)),
2658                ]),
2659                None,
2660            )
2661            .unwrap();
2662        let (_, first_request) = client.build_request(false).unwrap();
2663        // This second edit happens while the first request is in flight.
2664        let second = client
2665            .patch(
2666                "tasks",
2667                "t1",
2668                Map::from_iter([
2669                    ("starts_at_ms".into(), json!(300)),
2670                    ("version".into(), json!(3)),
2671                ]),
2672                None,
2673            )
2674            .unwrap();
2675        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2676        for (id, seq, meta) in [
2677            (first, 2, first_request),
2678            (second, 3, client.build_request(false).unwrap().1),
2679        ] {
2680            let response = Message {
2681                wire_version: WIRE_VERSION,
2682                msg_kind: MsgKind::Response,
2683                frames: vec![
2684                    Frame::RespHeader {
2685                        required_schema_version: None,
2686                        latest_schema_version: None,
2687                        log_epoch: Some("epoch-1".into()),
2688                        reset_required: Some(false),
2689                    },
2690                    Frame::PushResult {
2691                        client_commit_id: id,
2692                        status: PushStatus::Applied,
2693                        commit_seq: Some(seq),
2694                        results: vec![OpResult::Applied { op_index: 0 }],
2695                    },
2696                ],
2697            };
2698            assert!(matches!(
2699                client.process_response(&mut transport, response, &meta),
2700                SyncOutcome::Ok(_)
2701            ));
2702            let row = &client
2703                .query("SELECT starts_at_ms, version FROM tasks WHERE id='t1'", &[])
2704                .unwrap()[0];
2705            assert_eq!(
2706                row["starts_at_ms"],
2707                json!(300),
2708                "an applied ACK without a pull image must not expose the old base"
2709            );
2710            assert_eq!(
2711                row["version"],
2712                json!(3),
2713                "the queued domain version must remain monotonic"
2714            );
2715        }
2716    }
2717
2718    #[test]
2719    fn first_epoch_acquisition_preserves_tables_subscriptions_and_local_intent() {
2720        for stored in [None, Some("epoch-old")] {
2721            let mut client = client();
2722            client
2723                .subscribe(
2724                    "tasks".into(),
2725                    "tasks".into(),
2726                    vec![("project_id".into(), vec!["p1".into()])],
2727                    None,
2728                )
2729                .unwrap();
2730            client
2731                .conn
2732                .execute_batch(
2733                    "CREATE TRIGGER epoch_table_identity AFTER INSERT ON tasks BEGIN SELECT 1; END",
2734                )
2735                .unwrap();
2736            let id = client
2737                .mutate(vec![Mutation::Upsert {
2738                    table: "tasks".into(),
2739                    values: Map::from_iter([
2740                        ("id".into(), json!("offline")),
2741                        ("project_id".into(), json!("p1")),
2742                    ]),
2743                    base_version: None,
2744                }])
2745                .unwrap();
2746            if let Some(epoch) = stored {
2747                client.set_meta(LOG_EPOCH_KEY, epoch);
2748            }
2749            let before = serde_json::to_value(client.subscription_state("tasks")).unwrap();
2750            let (_, meta) = client.build_request(false).unwrap();
2751            client.drain_change_batches();
2752            client.drain_sync_intents();
2753            let response = Message {
2754                wire_version: WIRE_VERSION,
2755                msg_kind: MsgKind::Response,
2756                frames: vec![Frame::RespHeader {
2757                    required_schema_version: None,
2758                    latest_schema_version: None,
2759                    log_epoch: Some("epoch-new".into()),
2760                    reset_required: Some(true),
2761                }],
2762            };
2763            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2764            let outcome = client.process_response(&mut transport, response, &meta);
2765            let SyncOutcome::Ok(report) = outcome else {
2766                panic!("epoch round failed: {outcome:?}");
2767            };
2768            assert_eq!(
2769                report.resets,
2770                if stored.is_none() {
2771                    Vec::<String>::new()
2772                } else {
2773                    vec!["tasks".into()]
2774                }
2775            );
2776            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
2777            assert_eq!(client.pending_commit_ids(), vec![id]);
2778            assert_eq!(
2779                client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
2780                json!("offline")
2781            );
2782            let trigger: i64 = client
2783                .conn
2784                .query_row(
2785                    "SELECT count(*) FROM sqlite_master WHERE name='epoch_table_identity'",
2786                    [],
2787                    |row| row.get(0),
2788                )
2789                .unwrap();
2790            if stored.is_none() {
2791                assert!(!client.upgrading);
2792                assert!(client
2793                    .drain_change_batches()
2794                    .iter()
2795                    .all(|batch| batch.status.as_ref().is_none_or(|status| !status.upgrading)));
2796                assert_eq!(
2797                    serde_json::to_value(client.subscription_state("tasks")).unwrap(),
2798                    before
2799                );
2800                assert_eq!(trigger, 1, "acquisition must not drop the synced table");
2801            } else {
2802                assert!(client.upgrading);
2803                assert_eq!(trigger, 0, "an epoch change must drop the old table");
2804            }
2805            assert!(matches!(
2806                client.drain_sync_intents().as_slice(),
2807                [SyncIntent::Interactive]
2808            ));
2809        }
2810    }
2811
2812    #[test]
2813    fn epoch_acquisition_storage_failures_preserve_ready_state_and_intent() {
2814        for key in ["logEpoch", "localRevision"] {
2815            let mut client = client();
2816            let id = client
2817                .mutate(vec![Mutation::Upsert {
2818                    table: "tasks".into(),
2819                    values: Map::from_iter([
2820                        ("id".into(), json!("offline")),
2821                        ("project_id".into(), json!("p1")),
2822                    ]),
2823                    base_version: None,
2824                }])
2825                .unwrap();
2826            let revision = client.local_revision();
2827            let needed = client.sync_needed;
2828            client.drain_change_batches();
2829            client.drain_sync_intents();
2830            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();
2831            assert!(client.run_log_epoch_reset("epoch-new").is_err());
2832            assert!(client.get_meta(LOG_EPOCH_KEY).is_none());
2833            assert!(!client.upgrading);
2834            assert_eq!(client.sync_needed, needed);
2835            assert_eq!(client.local_revision(), revision);
2836            assert_eq!(client.pending_commit_ids(), [id]);
2837            assert!(client.drain_change_batches().is_empty());
2838            assert!(client.drain_sync_intents().is_empty());
2839        }
2840    }
2841
2842    #[test]
2843    fn failed_log_epoch_resets_restore_memory_and_durable_state() {
2844        for key in [
2845            "localSchemaVersion",
2846            "localSchemaDescriptor",
2847            "logEpoch",
2848            "localRevision",
2849        ] {
2850            let mut client = client();
2851            client.set_meta(LOG_EPOCH_KEY, "epoch-old");
2852            let commit = client
2853                .mutate(vec![Mutation::Upsert {
2854                    table: "tasks".into(),
2855                    values: Map::from_iter([
2856                        ("id".into(), json!("offline")),
2857                        ("project_id".into(), json!("p1")),
2858                    ]),
2859                    base_version: None,
2860                }])
2861                .unwrap();
2862            client.active_round = Some(uuid::Uuid::new_v4());
2863            client.overlay_dirty.set(false);
2864            let round = client.active_round;
2865            let needed = client.sync_needed;
2866            let revision = client.local_revision();
2867            let rows = serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap();
2868            client.drain_change_batches();
2869            client.drain_sync_intents();
2870            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();
2871            assert!(client.run_log_epoch_reset("epoch-new").is_err(), "{key}");
2872            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-old"));
2873            assert!(!client.upgrading);
2874            assert_eq!(client.active_round, round);
2875            assert!(!client.overlay_dirty.get());
2876            assert_eq!(client.sync_needed, needed);
2877            assert_eq!(client.local_revision(), revision);
2878            assert_eq!(
2879                client.pending_commit_ids().as_slice(),
2880                std::slice::from_ref(&commit)
2881            );
2882            assert_eq!(
2883                serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap(),
2884                rows
2885            );
2886            assert!(client.drain_change_batches().is_empty());
2887            assert!(client.drain_sync_intents().is_empty());
2888            assert!(client.conn.is_autocommit());
2889            client
2890                .conn
2891                .execute_batch("DROP TRIGGER fail_reset")
2892                .unwrap();
2893            client.run_log_epoch_reset("epoch-new").unwrap();
2894            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
2895            assert_eq!(client.pending_commit_ids(), [commit]);
2896        }
2897    }
2898
2899    #[test]
2900    fn acknowledged_restore_and_replay_ignore_unrelated_subscription_count() {
2901        for subscriptions in [0, 2, 6, 12] {
2902            let mut client = SyncClient::new("ack-scope".into(), &json!({ "version": 1, "tables": [
2903                { "name": "tasks", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] },
2904                { "name": "docs", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] }
2905            ] }), ClientLimits::default()).unwrap();
2906            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2907            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();
2908            for index in 0..32 {
2909                client
2910                    .mutate(vec![Mutation::Upsert {
2911                        table: "tasks".into(),
2912                        values: Map::from_iter([
2913                            ("id".into(), json!(format!("task-{index}"))),
2914                            ("project_id".into(), json!("p1")),
2915                        ]),
2916                        base_version: None,
2917                    }])
2918                    .unwrap();
2919            }
2920            let (_, meta) = client.build_request(false).unwrap();
2921            let mut frames = vec![Frame::RespHeader {
2922                required_schema_version: None,
2923                latest_schema_version: None,
2924                log_epoch: Some("epoch-1".into()),
2925                reset_required: Some(false),
2926            }];
2927            frames.extend(meta.pushed_ids.iter().enumerate().map(|(index, id)| {
2928                Frame::PushResult {
2929                    client_commit_id: id.clone(),
2930                    status: PushStatus::Applied,
2931                    commit_seq: Some(index as i64 + 1),
2932                    results: vec![OpResult::Applied { op_index: 0 }],
2933                }
2934            }));
2935            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2936            client.acknowledged_replay_count.set(0);
2937            client.conn.execute("DELETE FROM ack_writes", []).unwrap();
2938            assert!(matches!(
2939                client.process_response(
2940                    &mut transport,
2941                    Message {
2942                        wire_version: WIRE_VERSION,
2943                        msg_kind: MsgKind::Response,
2944                        frames
2945                    },
2946                    &meta
2947                ),
2948                SyncOutcome::Ok(_)
2949            ));
2950            assert_eq!(client.acknowledged_replay_count.get(), 32);
2951            assert_eq!(
2952                client
2953                    .query(
2954                        "SELECT count(*) AS n FROM ack_writes WHERE kind='restore'",
2955                        &[]
2956                    )
2957                    .unwrap()[0]["n"],
2958                json!(32)
2959            );
2960            assert_eq!(
2961                client
2962                    .query(
2963                        "SELECT count(*) AS n FROM ack_writes WHERE kind='replay'",
2964                        &[]
2965                    )
2966                    .unwrap()[0]["n"],
2967                json!(32)
2968            );
2969            client.conn.execute("DELETE FROM ack_writes", []).unwrap();
2970            client.acknowledged_replay_count.set(0);
2971            for index in 0..subscriptions {
2972                client
2973                    .subscribe(
2974                        format!("docs-{index}"),
2975                        "docs".into(),
2976                        vec![("project_id".into(), vec![format!("scope-{index}")])],
2977                        None,
2978                    )
2979                    .unwrap();
2980                let sub = client.subs.len() - 1;
2981                client.subs[sub].effective =
2982                    Some(vec![("project_id".into(), vec![format!("scope-{index}")])]);
2983                let segment = RowsSegment {
2984                    table: "docs".into(),
2985                    schema_version: 1,
2986                    columns: client.schema.table("docs").unwrap().wire_columns.clone(),
2987                    blocks: if index % 2 == 0 {
2988                        vec![vec![ssp2::segment::SegmentRow {
2989                            values: vec![
2990                                Some(ColumnValue::String(format!("doc-{index}"))),
2991                                Some(ColumnValue::String(format!("scope-{index}"))),
2992                            ],
2993                            server_version: 1,
2994                        }]]
2995                    } else {
2996                        vec![]
2997                    },
2998                };
2999                assert!(client.apply_segment(sub, &segment, true).is_ok());
3000            }
3001            assert_eq!(
3002                client
3003                    .query("SELECT count(*) AS n FROM ack_writes", &[])
3004                    .unwrap()[0]["n"],
3005                json!(0),
3006                "unrelated bootstrap must not restore/replay tasks"
3007            );
3008            assert_eq!(
3009                client.acknowledged_replay_count.get(),
3010                0,
3011                "unrelated bootstrap must not decode task ACKs"
3012            );
3013            assert_eq!(
3014                client
3015                    .query("SELECT count(*) AS n FROM tasks", &[])
3016                    .unwrap()[0]["n"],
3017                json!(32)
3018            );
3019            assert_eq!(
3020                client
3021                    .query("SELECT count(*) AS n FROM _syncular_acked_rows", &[])
3022                    .unwrap()[0]["n"],
3023                json!(32)
3024            );
3025        }
3026    }
3027
3028    #[test]
3029    fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
3030        let mut client = client();
3031        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3032        const COMMIT_COUNT: usize = 32;
3033
3034        for index in 0..COMMIT_COUNT {
3035            client
3036                .mutate(vec![Mutation::Upsert {
3037                    table: "tasks".to_owned(),
3038                    values: Map::from_iter([
3039                        ("id".to_owned(), Value::from(format!("task-{index}"))),
3040                        ("project_id".to_owned(), Value::from("project-1")),
3041                    ]),
3042                    base_version: None,
3043                }])
3044                .expect("queue commit");
3045        }
3046
3047        let (_, request_meta) = client.build_request(false).unwrap();
3048        assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
3049        let mut frames = vec![Frame::RespHeader {
3050            required_schema_version: None,
3051            latest_schema_version: None,
3052            log_epoch: Some("epoch-1".to_owned()),
3053            reset_required: Some(false),
3054        }];
3055        frames.extend(request_meta.pushed_ids.iter().enumerate().map(
3056            |(index, client_commit_id)| Frame::PushResult {
3057                client_commit_id: client_commit_id.clone(),
3058                status: PushStatus::Applied,
3059                commit_seq: Some(index as i64 + 1),
3060                results: vec![OpResult::Applied { op_index: 0 }],
3061            },
3062        ));
3063        let response = Message {
3064            wire_version: WIRE_VERSION,
3065            msg_kind: MsgKind::Response,
3066            frames,
3067        };
3068        let mut transport =
3069            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
3070
3071        client.overlay_rebuild_count.set(0);
3072        client.outcome_prune_count.set(0);
3073        let outcome = client.process_response(&mut transport, response, &request_meta);
3074        assert!(matches!(outcome, SyncOutcome::Ok(_)));
3075        assert!(client.pending_commit_ids().is_empty());
3076        assert_eq!(
3077            client.overlay_rebuild_count.get(),
3078            1,
3079            "one response must reconcile its acknowledged commits with one overlay rebuild"
3080        );
3081        assert_eq!(
3082            client.outcome_prune_count.get(),
3083            1,
3084            "one response must enforce outcome retention once"
3085        );
3086    }
3087
3088    #[test]
3089    fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
3090        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
3091            "columns": [
3092                {"name":"id", "type":"string", "nullable":false},
3093                {"name":"project_id", "type":"string", "nullable":false},
3094                {"name":"title", "type":"string", "nullable":false}
3095            ], "scopes": [{"pattern":"project:{project_id}"}],
3096            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
3097            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
3098        }] });
3099        let mut incremental =
3100            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
3101        let mut reference =
3102            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
3103        let tables = vec!["tasks".to_owned()];
3104        for index in 0..32 {
3105            let id = format!("task-{}", index % 8);
3106            let values = Map::from_iter([
3107                ("id".to_owned(), Value::from(id.clone())),
3108                (
3109                    "project_id".to_owned(),
3110                    Value::from(format!("p{}", index % 2)),
3111                ),
3112                ("title".to_owned(), Value::from(format!("needle{index}"))),
3113            ]);
3114            let payload = encode_row_json(
3115                incremental.schema.table("tasks").unwrap(),
3116                &id,
3117                &values,
3118                &incremental.encryption,
3119            )
3120            .unwrap();
3121            let change = ssp2::model::Change {
3122                table_index: 0,
3123                row_id: id,
3124                op: if index % 5 == 0 {
3125                    Op::Delete
3126                } else {
3127                    Op::Upsert
3128                },
3129                row_version: Some(index + 1),
3130                scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
3131                row: Some(payload),
3132            };
3133            reference.overlay_dirty.set(true);
3134            for instance in [&mut incremental, &mut reference] {
3135                instance.begin_observation("mirror_test").unwrap();
3136                let mut batch = ChangeAccumulator::default();
3137                instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
3138                instance
3139                    .apply_commit_changes(&tables, std::slice::from_ref(&change))
3140                    .unwrap();
3141                instance
3142                    .rebuild_overlay_if_dirty()
3143                    .expect("rebuild overlay");
3144                instance.finish_observation("mirror_test", batch).unwrap();
3145            }
3146            for sql in [
3147                "SELECT * FROM tasks ORDER BY id",
3148                "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
3149            ] {
3150                assert_eq!(
3151                    incremental.query(sql, &[]).unwrap(),
3152                    reference.query(sql, &[]).unwrap()
3153                );
3154            }
3155            assert_eq!(incremental.local_revision(), reference.local_revision());
3156            assert_eq!(
3157                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
3158                serde_json::to_value(reference.drain_change_batches()).unwrap()
3159            );
3160        }
3161        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
3162        assert_eq!(reference.overlay_rebuild_count.get(), 32);
3163    }
3164
3165    #[test]
3166    fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
3167        let cases = [
3168            (
3169                "string",
3170                vec![
3171                    json!("1"),
3172                    json!("01"),
3173                    json!(""),
3174                    json!("nul\u{0}id"),
3175                    json!("é"),
3176                ],
3177            ),
3178            (
3179                "integer",
3180                vec![
3181                    json!(i64::MIN),
3182                    json!(-1),
3183                    json!(0),
3184                    json!(1),
3185                    json!(i64::MAX),
3186                ],
3187            ),
3188            ("boolean", vec![json!(false), json!(true)]),
3189            ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
3190        ];
3191        for (kind, ids) in cases {
3192            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3193                "columns":[{"name":"id","type":kind,"nullable":false},
3194                    {"name":"project_id","type":"string","nullable":false}],
3195                "scopes":[{"pattern":"project:{project_id}"}]}]});
3196            let mut client =
3197                SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
3198            for (index, id) in ids.iter().enumerate() {
3199                let table = client.schema.table("tasks").unwrap();
3200                client
3201                    .write_base_row(
3202                        "tasks",
3203                        &vec![
3204                            if kind == "integer" {
3205                                // Exercise local SQLite key lookup across its
3206                                // full i64 range independently of authoring.
3207                                Some(ColumnValue::Integer(id.as_i64().unwrap()))
3208                            } else {
3209                                json_to_column_value(&table.columns[0], Some(id)).unwrap()
3210                            },
3211                            Some(ColumnValue::String(format!("p{index}"))),
3212                        ],
3213                        1,
3214                    )
3215                    .unwrap();
3216            }
3217            client.rebuild_overlay_if_dirty().expect("rebuild overlay");
3218            for base in [true, false] {
3219                let full = if base {
3220                    base_table("tasks")
3221                } else {
3222                    visible_table("tasks")
3223                };
3224                let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
3225                for row_id in [
3226                    "1",
3227                    "01",
3228                    "1.0",
3229                    "1.0000000000000002",
3230                    "+1",
3231                    "1e0",
3232                    " 1",
3233                    "-1",
3234                    "0",
3235                    "9223372036854775807",
3236                    "-9223372036854775808",
3237                    "true",
3238                    "false",
3239                    "",
3240                    "nul\u{0}id",
3241                    "é",
3242                    "null",
3243                    "{\"a\":1}",
3244                ] {
3245                    let old_sql =
3246                        format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
3247                    let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
3248                    let old = client
3249                        .conn
3250                        .prepare(&old_sql)
3251                        .unwrap()
3252                        .query_map([row_id], |row| row.get::<_, String>(0))
3253                        .unwrap()
3254                        .collect::<Result<Vec<_>, _>>()
3255                        .unwrap();
3256                    let new = client
3257                        .conn
3258                        .prepare_cached(&new_sql)
3259                        .unwrap()
3260                        .query_map([row_id], |row| row.get::<_, String>(0))
3261                        .unwrap()
3262                        .collect::<Result<Vec<_>, _>>()
3263                        .unwrap();
3264                    assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
3265                    let mut batch = ChangeAccumulator::default();
3266                    assert_eq!(
3267                        client.record_row_scopes(&mut batch, "tasks", row_id, base),
3268                        !old.is_empty()
3269                    );
3270                    let expected = old.first().map(|scope| {
3271                        BTreeMap::from([(
3272                            "tasks".to_owned(),
3273                            Some(BTreeSet::from([format!("project:{scope}")])),
3274                        )])
3275                    });
3276                    assert_eq!(batch.tables, expected.unwrap_or_default());
3277                    client
3278                        .conn
3279                        .execute_batch("SAVEPOINT lookup_delete")
3280                        .unwrap();
3281                    if base {
3282                        client.delete_base_row("tasks", row_id).unwrap();
3283                    } else {
3284                        client
3285                            .apply_outbox_ops(&[OutboxOp {
3286                                upsert: false,
3287                                table: "tasks".into(),
3288                                row_id: row_id.into(),
3289                                base_version: None,
3290                                values: None,
3291                            }])
3292                            .expect("replay delete");
3293                    }
3294                    let remaining: i64 = client
3295                        .conn
3296                        .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
3297                            row.get(0)
3298                        })
3299                        .unwrap();
3300                    assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
3301                    client
3302                        .conn
3303                        .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
3304                        .unwrap();
3305                    client.overlay_dirty.set(false);
3306                }
3307                for (predicate, expected) in [
3308                    ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
3309                    (predicate, "SEARCH"),
3310                ] {
3311                    let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
3312                    let details = client
3313                        .conn
3314                        .prepare(&sql)
3315                        .unwrap()
3316                        .query_map(["1"], |row| row.get::<_, String>(3))
3317                        .unwrap()
3318                        .collect::<Result<Vec<_>, _>>()
3319                        .unwrap();
3320                    assert!(
3321                        details.iter().any(|detail| detail.contains(expected)),
3322                        "{kind}: {details:?}"
3323                    );
3324                }
3325            }
3326        }
3327    }
3328
3329    #[test]
3330    fn primary_keys_reject_every_ineligible_column_type() {
3331        // §2.4: a `rowId` is a string, so a primary key type whose string form
3332        // differs per renderer, or cannot be reproduced by local storage
3333        // comparison, is a schema error rather than a silent lookup miss.
3334        for ty in ["float", "bytes", "crdt", "blob_ref"] {
3335            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3336                "columns":[{"name":"id","type":ty,"nullable":false},
3337                    {"name":"project_id","type":"string","nullable":false}],
3338                "scopes":[{"pattern":"project:{project_id}"}]}]});
3339            let error =
3340                match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
3341                    Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
3342                    Err(error) => error,
3343                };
3344            assert!(
3345                error.contains("has an ineligible column type (§2.4)"),
3346                "{ty}: {error}"
3347            );
3348        }
3349        for ty in ["string", "integer", "boolean", "json"] {
3350            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3351                "columns":[{"name":"id","type":ty,"nullable":false},
3352                    {"name":"project_id","type":"string","nullable":false}],
3353                "scopes":[{"pattern":"project:{project_id}"}]}]});
3354            SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
3355                .unwrap_or_else(|error| panic!("{ty}: {error}"));
3356        }
3357    }
3358
3359    #[test]
3360    fn pending_row_reconciliation_matches_full_fifo_replay() {
3361        for unique in [false, true] {
3362            let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
3363                "name": name, "primaryKey": "id", "columns": [
3364                    {"name":"id", "type":"string", "nullable":false},
3365                    {"name":"project_id", "type":"string", "nullable":false},
3366                    {"name":"title", "type":"string", "nullable":false}],
3367                "scopes": [{"pattern":"project:{project_id}"}],
3368                "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
3369                "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
3370            })))});
3371            let mut incremental =
3372                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
3373            let mut reference =
3374                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
3375            let tables = vec!["tasks".to_owned(), "notes".to_owned()];
3376            for instance in [&mut incremental, &mut reference] {
3377                for table in &tables {
3378                    for index in 0..12 {
3379                        instance
3380                            .write_base_row(
3381                                table,
3382                                &vec![
3383                                    Some(ColumnValue::String(format!("row{index}"))),
3384                                    Some(ColumnValue::String("p0".into())),
3385                                    Some(ColumnValue::String(format!("base{index}"))),
3386                                ],
3387                                1,
3388                            )
3389                            .unwrap();
3390                    }
3391                }
3392                instance
3393                    .rebuild_overlay_if_dirty()
3394                    .expect("rebuild overlay");
3395            }
3396            for index in 0..80 {
3397                let mutations = (0..3)
3398                    .map(|offset| {
3399                        let table = tables[(index + offset) % 2].clone();
3400                        let id = format!("row{}", (index + offset) % 16);
3401                        if (index + offset) % 7 == 0 {
3402                            Mutation::Delete {
3403                                table,
3404                                row_id: id,
3405                                base_version: None,
3406                            }
3407                        } else {
3408                            Mutation::Upsert {
3409                                table,
3410                                values: Map::from_iter([
3411                                    ("id".into(), Value::from(id)),
3412                                    ("project_id".into(), Value::from(format!("p{}", index % 3))),
3413                                    (
3414                                        "title".into(),
3415                                        Value::from(format!("needle{}", (index + offset) % 16)),
3416                                    ),
3417                                ]),
3418                                base_version: None,
3419                            }
3420                        }
3421                    })
3422                    .collect::<Vec<_>>();
3423                for instance in [&mut incremental, &mut reference] {
3424                    instance.mutate(mutations.clone()).unwrap();
3425                }
3426                // Most frames touch only the table without unique constraints.
3427                // Mixed frames must retain full replay for both tables.
3428                let changes = (0..3)
3429                    .map(|offset| {
3430                        let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
3431                        let id = format!("row{}", (index + offset) % 16);
3432                        let values = Map::from_iter([
3433                            ("id".into(), Value::from(id.clone())),
3434                            (
3435                                "project_id".into(),
3436                                Value::from(format!("p{}", (index + 1) % 3)),
3437                            ),
3438                            (
3439                                "title".into(),
3440                                Value::from(format!("remote{index}_{offset}")),
3441                            ),
3442                        ]);
3443                        ssp2::model::Change {
3444                            table_index: table_index as u16,
3445                            row_id: id.clone(),
3446                            op: if (index + offset) % 5 == 0 {
3447                                Op::Delete
3448                            } else {
3449                                Op::Upsert
3450                            },
3451                            row_version: Some(index as i64 + 2),
3452                            scopes: vec![],
3453                            row: Some(
3454                                encode_row_json(
3455                                    incremental.schema.table(&tables[table_index]).unwrap(),
3456                                    &id,
3457                                    &values,
3458                                    &incremental.encryption,
3459                                )
3460                                .unwrap(),
3461                            ),
3462                        }
3463                    })
3464                    .collect::<Vec<_>>();
3465                reference.overlay_dirty.set(true);
3466                incremental
3467                    .apply_commit_frame(&tables, &changes, None)
3468                    .unwrap();
3469                reference
3470                    .apply_commit_frame(&tables, &changes, None)
3471                    .unwrap();
3472                for table in &tables {
3473                    for sql in [format!("SELECT * FROM {table} ORDER BY id"),
3474                        format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
3475                        format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
3476                        format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
3477                        assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
3478                    }
3479                }
3480                assert_eq!(incremental.local_revision(), reference.local_revision());
3481                assert_eq!(
3482                    serde_json::to_value(incremental.drain_change_batches()).unwrap(),
3483                    serde_json::to_value(reference.drain_change_batches()).unwrap()
3484                );
3485            }
3486            assert_eq!(
3487                incremental.overlay_rebuild_count.get(),
3488                if unique { 81 } else { 21 }
3489            );
3490            assert_eq!(reference.overlay_rebuild_count.get(), 81);
3491        }
3492    }
3493
3494    #[test]
3495    fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
3496        let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
3497            "columns":[{"name":"id", "type":"string", "nullable":false},
3498                {"name":"title", "type":"string", "nullable":false}],
3499            "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
3500        let mut client =
3501            SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
3502        for (id, title) in [("a", "original"), ("b", "occupied")] {
3503            client
3504                .write_base_row(
3505                    "tasks",
3506                    &vec![
3507                        Some(ColumnValue::String(id.into())),
3508                        Some(ColumnValue::String(title.into())),
3509                    ],
3510                    1,
3511                )
3512                .unwrap();
3513        }
3514        client.rebuild_overlay_if_dirty().expect("rebuild overlay");
3515        let pending = client
3516            .mutate(vec![Mutation::Upsert {
3517                table: "tasks".into(),
3518                values: Map::from_iter([
3519                    ("id".into(), Value::from("a")),
3520                    ("title".into(), Value::from("available")),
3521                ]),
3522                base_version: None,
3523            }])
3524            .unwrap();
3525        assert_eq!(
3526            client
3527                .conn
3528                .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
3529                    .get::<_, String>(
3530                    0
3531                ))
3532                .unwrap(),
3533            "available"
3534        );
3535        for (title, expected) in [("available", "original"), ("occupied", "available")] {
3536            let change = ssp2::model::Change {
3537                table_index: 0,
3538                row_id: "b".into(),
3539                op: Op::Upsert,
3540                row_version: Some(2),
3541                scopes: vec![],
3542                row: Some(
3543                    encode_row_json(
3544                        client.schema.table("tasks").unwrap(),
3545                        "b",
3546                        &Map::from_iter([
3547                            ("id".into(), Value::from("b")),
3548                            ("title".into(), Value::from(title)),
3549                        ]),
3550                        &client.encryption,
3551                    )
3552                    .unwrap(),
3553                ),
3554            };
3555            client
3556                .apply_commit_frame(&["tasks".into()], &[change], None)
3557                .unwrap();
3558            assert_eq!(
3559                client
3560                    .conn
3561                    .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
3562                        .get::<_, String>(
3563                        0
3564                    ))
3565                    .unwrap(),
3566                expected
3567            );
3568            assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
3569        }
3570        assert_eq!(client.overlay_rebuild_count.get(), 3);
3571    }
3572
3573    fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
3574        let schema = json!({ "version": 1, "tables": [
3575            { "name":"tasks", "primaryKey":"id", "columns":[
3576                {"name":"id","type":"string","nullable":false},
3577                {"name":"project_id","type":"string","nullable":false}],
3578                "scopes":[{"pattern":"project:{project_id}"}] },
3579            { "name":"catalogue", "primaryKey":"id", "columns":[
3580                {"name":"id","type":"string","nullable":false},
3581                {"name":"project_id","type":"string","nullable":false},
3582                {"name":"title","type":"string","nullable":false}],
3583                "scopes":[{"pattern":"project:{project_id}"}],
3584                "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
3585        ] });
3586        let mut client =
3587            SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
3588        if unchanged_rows > 0 {
3589            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();
3590        }
3591        client.rebuild_overlay().expect("rebuild overlay");
3592        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();
3593        let mut elapsed = Vec::new();
3594        for round in 1..=rounds {
3595            let pending = client
3596                .mutate(vec![Mutation::Upsert {
3597                    table: "tasks".into(),
3598                    values: Map::from_iter([
3599                        ("id".into(), json!("t1")),
3600                        ("project_id".into(), json!("p1")),
3601                    ]),
3602                    base_version: None,
3603                }])
3604                .unwrap();
3605            let started = std::time::Instant::now();
3606            client
3607                .write_base_row(
3608                    "tasks",
3609                    &vec![
3610                        Some(ColumnValue::String("t1".into())),
3611                        Some(ColumnValue::String("p1".into())),
3612                    ],
3613                    round,
3614                )
3615                .unwrap();
3616            let mut report = SyncReport::default();
3617            client
3618                .handle_push_results(
3619                    &[Frame::PushResult {
3620                        client_commit_id: pending.clone(),
3621                        status: PushStatus::Applied,
3622                        commit_seq: Some(round),
3623                        results: vec![OpResult::Applied { op_index: 0 }],
3624                    }],
3625                    &HashSet::from([pending.as_str()]),
3626                    &HashMap::new(),
3627                    &mut report,
3628                    Some(&pending),
3629                )
3630                .unwrap();
3631            client.rebuild_overlay_if_dirty().expect("rebuild overlay");
3632            elapsed.push(started.elapsed().as_nanos());
3633        }
3634        assert_eq!(
3635            client
3636                .conn
3637                .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
3638                .unwrap(),
3639            0,
3640            "unchanged catalogue must never be copied"
3641        );
3642        assert_eq!(
3643            client
3644                .conn
3645                .query_row(
3646                    "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
3647                    [],
3648                    |r| r.get::<_, i64>(0)
3649                )
3650                .unwrap(),
3651            unchanged_rows
3652        );
3653        assert_eq!(
3654            client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
3655            json!(rounds)
3656        );
3657        elapsed
3658    }
3659
3660    #[test]
3661    fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
3662        let elapsed = measure_scoped_overlay(100_000, 1);
3663        eprintln!(
3664            "table-scoped overlay with 100k unchanged rows: {} ns",
3665            elapsed[0]
3666        );
3667    }
3668
3669    #[test]
3670    #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
3671    fn benchmark_scoped_overlay() {
3672        for rows in [0, 100_000, 200_000] {
3673            let mut elapsed = measure_scoped_overlay(rows, 30);
3674            elapsed.sort_unstable();
3675            eprintln!(
3676                "{}",
3677                json!({"unchangedRows": rows, "rounds": elapsed.len(),
3678                "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
3679                "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
3680            );
3681        }
3682    }
3683
3684    #[test]
3685    fn released_context_discards_in_flight_ack_and_server_rows() {
3686        for barrier in [
3687            "preflight",
3688            "purge",
3689            "rebootstrap",
3690            "subscription",
3691            "failed-purge-replay",
3692            "failed-purge-publish",
3693            "failed-rebootstrap",
3694        ] {
3695            let mut client = client();
3696            client.set_meta(LOG_EPOCH_KEY, "epoch");
3697            client
3698                .subscribe(
3699                    "tasks".into(),
3700                    "tasks".into(),
3701                    vec![("project_id".into(), vec!["p1".into()])],
3702                    None,
3703                )
3704                .unwrap();
3705            let commit = client
3706                .mutate(vec![Mutation::Upsert {
3707                    table: "tasks".into(),
3708                    values: Map::from_iter([
3709                        ("id".into(), json!("local")),
3710                        ("project_id".into(), json!("p1")),
3711                    ]),
3712                    base_version: None,
3713                }])
3714                .unwrap();
3715            let prepared = client.prepare_sync_round(false).unwrap();
3716            client.set_transport_enabled(&mut CountingRealtimeTransport::default(), false);
3717            let payload = encode_row_json(
3718                &client.schema.tables[0],
3719                "server",
3720                &Map::from_iter([
3721                    ("id".into(), json!("server")),
3722                    ("project_id".into(), json!("p1")),
3723                ]),
3724                &client.encryption,
3725            )
3726            .unwrap();
3727            let completed = crate::CompletedSyncRound {
3728                prepared,
3729                exchange: crate::round::ExchangeResult::Reply {
3730                    transport_failed: false,
3731                    downloads: Default::default(),
3732                    response: Ok(Message {
3733                        wire_version: WIRE_VERSION,
3734                        msg_kind: MsgKind::Response,
3735                        frames: vec![
3736                            Frame::RespHeader {
3737                                required_schema_version: None,
3738                                latest_schema_version: None,
3739                                log_epoch: Some("epoch".into()),
3740                                reset_required: Some(false),
3741                            },
3742                            Frame::PushResult {
3743                                client_commit_id: commit.clone(),
3744                                status: PushStatus::Applied,
3745                                commit_seq: Some(1),
3746                                results: vec![OpResult::Applied { op_index: 0 }],
3747                            },
3748                            Frame::SubStart {
3749                                id: "tasks".into(),
3750                                status: SubStatus::Active,
3751                                reason_code: String::new(),
3752                                effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
3753                                bootstrap: true,
3754                            },
3755                            Frame::Commit {
3756                                commit_seq: 1,
3757                                created_at_ms: 0,
3758                                actor_id: "actor".into(),
3759                                tables: vec!["tasks".into()],
3760                                changes: vec![ssp2::model::Change {
3761                                    table_index: 0,
3762                                    row_id: "server".into(),
3763                                    op: Op::Upsert,
3764                                    row_version: Some(1),
3765                                    scopes: vec![("project_id".into(), "p1".into())],
3766                                    row: Some(payload),
3767                                }],
3768                            },
3769                            Frame::SubEnd {
3770                                next_cursor: 1,
3771                                bootstrap_state: None,
3772                            },
3773                        ],
3774                    }),
3775                },
3776            };
3777            match barrier {
3778                "preflight" => client.begin_security_preflight(),
3779                "purge" => {
3780                    client
3781                        .purge_local_data(&LocalDataPurgeInput {
3782                            purge_id: "pending-round".into(),
3783                            targets: vec![LocalDataPurgeTarget {
3784                                table: "tasks".into(),
3785                                selectors: BTreeMap::from([(
3786                                    "project_id".into(),
3787                                    vec!["p1".into()],
3788                                )]),
3789                            }],
3790                        })
3791                        .unwrap();
3792                }
3793                "rebootstrap" => {
3794                    client
3795                        .rebootstrap_local_data(&LocalDataRebootstrapInput {
3796                            rebootstrap_id: "pending-round".into(),
3797                        })
3798                        .unwrap();
3799                }
3800                "failed-purge-replay" | "failed-purge-publish" | "failed-rebootstrap" => {
3801                    let trigger = if barrier == "failed-purge-replay" {
3802                        "CREATE TRIGGER fail_local BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT, 'local fault'); END"
3803                    } else {
3804                        "CREATE TRIGGER fail_local BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT, 'local fault'); END"
3805                    };
3806                    client.conn.execute_batch(trigger).unwrap();
3807                    let revision = client.local_revision();
3808                    if barrier == "failed-rebootstrap" {
3809                        assert!(client
3810                            .rebootstrap_local_data(&LocalDataRebootstrapInput {
3811                                rebootstrap_id: "failed-round".into(),
3812                            })
3813                            .is_err());
3814                    } else {
3815                        assert!(client
3816                            .purge_local_data(&LocalDataPurgeInput {
3817                                purge_id: "failed-round".into(),
3818                                targets: vec![LocalDataPurgeTarget {
3819                                    table: "tasks".into(),
3820                                    selectors: BTreeMap::from([(
3821                                        "project_id".into(),
3822                                        vec!["p1".into()]
3823                                    )]),
3824                                }],
3825                            })
3826                            .is_err());
3827                    }
3828                    assert_eq!(client.local_revision(), revision);
3829                    assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
3830                    client
3831                        .conn
3832                        .execute_batch("DROP TRIGGER fail_local")
3833                        .unwrap();
3834                    let applied = client.apply_sync_round(completed);
3835                    assert!(
3836                        matches!(
3837                            applied,
3838                            crate::AppliedSyncRound::Complete {
3839                                outcome: SyncOutcome::Ok(_),
3840                                ..
3841                            }
3842                        ),
3843                        "{barrier}"
3844                    );
3845                    assert!(client.pending_commit_ids().is_empty());
3846                    assert_eq!(
3847                        client
3848                            .query("SELECT id FROM tasks WHERE id = 'server'", &[])
3849                            .unwrap()
3850                            .len(),
3851                        1
3852                    );
3853                    continue;
3854                }
3855                "subscription" => {
3856                    client.unsubscribe("tasks");
3857                    client
3858                        .subscribe(
3859                            "tasks".into(),
3860                            "tasks".into(),
3861                            vec![("project_id".into(), vec!["p2".into()])],
3862                            None,
3863                        )
3864                        .unwrap();
3865                }
3866                _ => unreachable!(),
3867            }
3868            let pending_before_reply = client.pending_commit_ids();
3869            let revision = client.local_revision();
3870            let applied = client.apply_sync_round(completed);
3871            assert!(
3872                matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
3873            );
3874            assert_eq!(client.local_revision(), revision);
3875            assert!(client
3876                .conn
3877                .query_row(
3878                    "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
3879                    [],
3880                    |r| r.get::<_, bool>(0)
3881                )
3882                .unwrap());
3883            assert_eq!(client.pending_commit_ids(), pending_before_reply);
3884        }
3885    }
3886
3887    #[test]
3888    fn sync_round_deadline_is_anchored_once_per_round_and_carried_across_continues() {
3889        struct DeadlineRecording {
3890            budget: std::time::Duration,
3891            seen: Vec<Option<std::time::Instant>>,
3892        }
3893        impl Transport for DeadlineRecording {
3894            fn round_deadline(&self) -> Option<std::time::Duration> {
3895                Some(self.budget)
3896            }
3897            fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
3898                self.seen.push(deadline);
3899            }
3900            fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
3901                Err(TransportError::new("transport.failed", "injected"))
3902            }
3903            fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
3904                self.sync(request)
3905            }
3906            fn download_segment(
3907                &mut self,
3908                _: &SegmentRequest,
3909                _: &mut dyn FnMut(u64),
3910            ) -> Result<Vec<u8>, TransportError> {
3911                Err(TransportError::new("transport.failed", "injected"))
3912            }
3913            fn blob_upload_grant(
3914                &mut self,
3915                _: &str,
3916                _: u64,
3917                _: Option<&str>,
3918            ) -> Result<crate::BlobUploadGrant, TransportError> {
3919                Ok(crate::BlobUploadGrant::Present)
3920            }
3921            fn realtime_connect(&mut self) -> Result<(), TransportError> {
3922                Ok(())
3923            }
3924            fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
3925                Ok(())
3926            }
3927            fn realtime_close(&mut self) -> Result<(), TransportError> {
3928                Ok(())
3929            }
3930        }
3931
3932        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
3933            {"name":"id","type":"string","nullable":false},
3934            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
3935        let mut client =
3936            SyncClient::new("deadline-round".into(), &schema, Default::default()).unwrap();
3937        client.set_meta(LOG_EPOCH_KEY, "epoch");
3938        client
3939            .upload_blob(b"pending upload", Some("text/plain".into()), None)
3940            .unwrap();
3941
3942        let mut transport = DeadlineRecording {
3943            budget: std::time::Duration::from_secs(30),
3944            seen: Vec::new(),
3945        };
3946        let prepared = client.prepare_sync_round(false).unwrap();
3947        assert_eq!(prepared.uploads.len(), 1, "one staged upload");
3948        // Upload exchange -> Continue (the pin removal is persisted by apply).
3949        let prepared = match client.apply_sync_round(prepared.exchange(&mut transport)) {
3950            crate::AppliedSyncRound::Continue(prepared) => prepared,
3951            crate::AppliedSyncRound::Complete { .. } => panic!("the upload must continue"),
3952        };
3953        // Main request exchange -> Complete.
3954        let completed = prepared.exchange(&mut transport);
3955        let anchor = completed
3956            .prepared
3957            .round_deadline_at
3958            .expect("the upload round anchors a deadline");
3959        assert!(matches!(
3960            client.apply_sync_round(completed),
3961            crate::AppliedSyncRound::Complete { .. }
3962        ));
3963        assert_eq!(transport.seen.len(), 4, "one scope per exchange");
3964        assert_eq!(
3965            transport.seen[0],
3966            Some(anchor),
3967            "the upload anchors the round"
3968        );
3969        assert_eq!(
3970            transport.seen[1], None,
3971            "the upload exchange clears its scope"
3972        );
3973        assert_eq!(
3974            transport.seen[2],
3975            Some(anchor),
3976            "a continuation reuses the carried anchor instead of refreshing it"
3977        );
3978        assert_eq!(transport.seen[3], None, "the continuation clears its scope");
3979
3980        // A fresh round anchors a new deadline from the transport's (now larger)
3981        // budget and never inherits the old one; the clock granule is irrelevant.
3982        transport.budget = std::time::Duration::from_secs(60);
3983        let prepared = client.prepare_sync_round(false).unwrap();
3984        let completed = prepared.exchange(&mut transport);
3985        let fresh = completed
3986            .prepared
3987            .round_deadline_at
3988            .expect("the next round anchors a deadline");
3989        client.apply_sync_round(completed);
3990        assert!(
3991            fresh >= anchor + std::time::Duration::from_secs(30),
3992            "the next round anchors a fresh deadline from the new budget"
3993        );
3994        assert_eq!(transport.seen[4], Some(fresh));
3995        assert_eq!(
3996            transport.seen[5], None,
3997            "the new round clears its scope too"
3998        );
3999    }
4000
4001    /// Records the round-deadline traffic so a test can prove whether a round
4002    /// asked for a budget and whether it ever scoped an anchor.
4003    #[derive(Default)]
4004    struct RoundDeadlineProbe {
4005        budget: Option<std::time::Duration>,
4006        budget_queries: std::cell::Cell<usize>,
4007        scoped: Vec<Option<std::time::Instant>>,
4008    }
4009
4010    impl Transport for RoundDeadlineProbe {
4011        fn round_deadline(&self) -> Option<std::time::Duration> {
4012            self.budget_queries.set(self.budget_queries.get() + 1);
4013            self.budget
4014        }
4015        fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
4016            self.scoped.push(deadline);
4017        }
4018        fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4019            Err(TransportError::new("transport.failed", "injected"))
4020        }
4021        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4022            self.sync(request)
4023        }
4024        fn download_segment(
4025            &mut self,
4026            _: &SegmentRequest,
4027            _: &mut dyn FnMut(u64),
4028        ) -> Result<Vec<u8>, TransportError> {
4029            Err(TransportError::new("transport.failed", "injected"))
4030        }
4031        fn blob_upload_grant(
4032            &mut self,
4033            _: &str,
4034            _: u64,
4035            _: Option<&str>,
4036        ) -> Result<crate::BlobUploadGrant, TransportError> {
4037            Ok(crate::BlobUploadGrant::Present)
4038        }
4039        fn realtime_connect(&mut self) -> Result<(), TransportError> {
4040            Ok(())
4041        }
4042        fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4043            Ok(())
4044        }
4045        fn realtime_close(&mut self) -> Result<(), TransportError> {
4046            Ok(())
4047        }
4048    }
4049
4050    #[test]
4051    fn an_unusable_upload_body_does_not_anchor_the_round_deadline() {
4052        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4053            {"name":"id","type":"string","nullable":false},
4054            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4055        let mut client =
4056            SyncClient::new("deadline-unusable".into(), &schema, ClientLimits::default()).unwrap();
4057        client.set_meta(LOG_EPOCH_KEY, "epoch");
4058        client
4059            .upload_blob(b"pending upload", Some("text/plain".into()), None)
4060            .unwrap();
4061        client
4062            .conn
4063            .execute_batch("DELETE FROM _syncular_blobs")
4064            .unwrap();
4065        let prepared = client.prepare_sync_round(false).unwrap();
4066        assert!(
4067            prepared.uploads.front().is_some_and(|u| u.bytes.is_err()),
4068            "the staged body must fail locally"
4069        );
4070        let mut transport = RoundDeadlineProbe {
4071            budget: Some(std::time::Duration::from_secs(30)),
4072            ..Default::default()
4073        };
4074        let completed = prepared.exchange(&mut transport);
4075        assert!(matches!(
4076            completed.exchange,
4077            crate::round::ExchangeResult::Upload { result: Err(_), .. }
4078        ));
4079        assert_eq!(
4080            transport.budget_queries.get(),
4081            0,
4082            "a round with no network work never asks for a budget"
4083        );
4084        assert_eq!(transport.scoped, vec![None]);
4085    }
4086
4087    #[test]
4088    fn a_transport_budget_the_clock_cannot_represent_fails_before_network() {
4089        struct HugeBudget;
4090        impl Transport for HugeBudget {
4091            fn round_deadline(&self) -> Option<std::time::Duration> {
4092                Some(std::time::Duration::MAX)
4093            }
4094            fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4095                panic!("no network for an invalid budget")
4096            }
4097            fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4098                self.sync(request)
4099            }
4100            fn download_segment(
4101                &mut self,
4102                _: &SegmentRequest,
4103                _: &mut dyn FnMut(u64),
4104            ) -> Result<Vec<u8>, TransportError> {
4105                panic!("no network for an invalid budget")
4106            }
4107            fn realtime_connect(&mut self) -> Result<(), TransportError> {
4108                Ok(())
4109            }
4110            fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4111                Ok(())
4112            }
4113            fn realtime_close(&mut self) -> Result<(), TransportError> {
4114                Ok(())
4115            }
4116        }
4117
4118        let mut client = client();
4119        client.set_meta(LOG_EPOCH_KEY, "epoch");
4120        let prepared = client.prepare_sync_round(false).unwrap();
4121        let outcome = match client.apply_sync_round(prepared.exchange(&mut HugeBudget)) {
4122            crate::AppliedSyncRound::Complete { outcome, .. } => outcome,
4123            crate::AppliedSyncRound::Continue(_) => {
4124                panic!("an unrepresentable budget must not continue")
4125            }
4126        };
4127        match outcome {
4128            SyncOutcome::Failed {
4129                error_code,
4130                message,
4131                ..
4132            } => {
4133                assert_eq!(error_code, "sync.invalid_request");
4134                assert_eq!(message, "transport round deadline is out of range");
4135            }
4136            _ => panic!("an unrepresentable budget must fail synchronously"),
4137        }
4138    }
4139
4140    #[test]
4141    fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
4142        for failure in ["visible", "revision", "commit"] {
4143            let mut client = client();
4144            let id = client
4145                .mutate(vec![Mutation::Upsert {
4146                    table: "tasks".into(),
4147                    values: Map::from_iter([
4148                        ("id".into(), Value::from("t1")),
4149                        ("project_id".into(), Value::from("pending")),
4150                    ]),
4151                    base_version: None,
4152                }])
4153                .unwrap();
4154            client.drain_change_batches();
4155            let revision = client.local_revision();
4156            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
4157            let trigger = match failure {
4158                "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
4159                "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"),
4160                _ => "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(),
4161            };
4162            client.conn.execute_batch(&trigger).unwrap();
4163            let change = ssp2::model::Change {
4164                table_index: 0,
4165                row_id: "t1".into(),
4166                op: Op::Upsert,
4167                row_version: Some(2),
4168                scopes: vec![],
4169                row: Some(
4170                    encode_row_json(
4171                        client.schema.table("tasks").unwrap(),
4172                        "t1",
4173                        &Map::from_iter([
4174                            ("id".into(), Value::from("t1")),
4175                            ("project_id".into(), Value::from("remote")),
4176                        ]),
4177                        &client.encryption,
4178                    )
4179                    .unwrap(),
4180                ),
4181            };
4182            assert!(client
4183                .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), None)
4184                .is_err());
4185            assert!(client.conn.is_autocommit());
4186            assert!(!client.overlay_dirty.get());
4187            assert_eq!(client.local_revision(), revision);
4188            assert!(client.drain_change_batches().is_empty());
4189            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
4190            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
4191            assert!(client
4192                .query("SELECT * FROM _syncular_base_tasks", &[])
4193                .unwrap()
4194                .is_empty());
4195            client
4196                .conn
4197                .execute_batch("DROP TRIGGER fail_pending")
4198                .unwrap();
4199            client
4200                .apply_commit_frame(&["tasks".into()], &[change], None)
4201                .unwrap();
4202            assert_eq!(client.local_revision(), revision + 1);
4203            assert_eq!(client.pending_commit_ids(), vec![id]);
4204            // The overlay keeps the pending write visible; the row now carries
4205            // the server row's version it is layered on (§7.1).
4206            let mut expected = before[0].clone();
4207            expected.insert("_syncular_version".to_owned(), Value::from(2));
4208            assert_eq!(
4209                client.query("SELECT * FROM tasks", &[]).unwrap(),
4210                vec![expected]
4211            );
4212            assert_eq!(
4213                client
4214                    .conn
4215                    .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
4216                        .get::<_, String>(0))
4217                    .unwrap(),
4218                "remote"
4219            );
4220        }
4221    }
4222
4223    #[test]
4224    fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
4225        let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
4226            "columns": [
4227                {"name":"id", "type":"string", "nullable":false},
4228                {"name":"project_id", "type":"string", "nullable":false},
4229                {"name":"title", "type":"string", "nullable":false}],
4230            "scopes": [{"pattern":"project:{project_id}"}],
4231            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
4232            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
4233        let mut incremental =
4234            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
4235        let mut reference =
4236            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
4237        for instance in [&mut incremental, &mut reference] {
4238            for index in 0..16 {
4239                instance
4240                    .write_base_row(
4241                        "tasks",
4242                        &vec![
4243                            Some(ColumnValue::String(format!("task{index}"))),
4244                            Some(ColumnValue::String("p0".to_owned())),
4245                            Some(ColumnValue::String(format!("base{index}"))),
4246                        ],
4247                        1,
4248                    )
4249                    .unwrap();
4250            }
4251            instance
4252                .rebuild_overlay_if_dirty()
4253                .expect("rebuild overlay");
4254            instance.overlay_rebuild_count.set(0);
4255        }
4256        for index in 0..100 {
4257            let mutations = (0..1 + index % 4)
4258                .map(|offset| {
4259                    let id = format!("task{}", (index + offset) % 24);
4260                    if (index + offset) % 7 == 0 {
4261                        Mutation::Delete {
4262                            table: "tasks".to_owned(),
4263                            row_id: id,
4264                            base_version: None,
4265                        }
4266                    } else {
4267                        Mutation::Upsert {
4268                            table: "tasks".to_owned(),
4269                            values: Map::from_iter([
4270                                ("id".to_owned(), Value::from(id)),
4271                                (
4272                                    "project_id".to_owned(),
4273                                    Value::from(format!("p{}", index % 2)),
4274                                ),
4275                                (
4276                                    "title".to_owned(),
4277                                    Value::from(format!("needle{}", (index + offset) % 24)),
4278                                ),
4279                            ]),
4280                            base_version: None,
4281                        }
4282                    }
4283                })
4284                .collect::<Vec<_>>();
4285            reference.overlay_dirty.set(true);
4286            incremental.mutate(mutations.clone()).unwrap();
4287            reference.mutate(mutations).unwrap();
4288            for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
4289                "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
4290                assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
4291            }
4292            assert_eq!(incremental.local_revision(), reference.local_revision());
4293            assert_eq!(
4294                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4295                serde_json::to_value(reference.drain_change_batches()).unwrap()
4296            );
4297        }
4298        assert_eq!(incremental.outbox.len(), 100);
4299        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
4300        assert_eq!(reference.overlay_rebuild_count.get(), 100);
4301    }
4302
4303    #[test]
4304    fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
4305        for fail_at in ["outbox", "revision"] {
4306            let mut client = client();
4307            let values = Map::from_iter([
4308                ("id".to_owned(), Value::from("t1")),
4309                ("project_id".to_owned(), Value::from("p1")),
4310            ]);
4311            let mutation = Mutation::Upsert {
4312                table: "tasks".to_owned(),
4313                values,
4314                base_version: None,
4315            };
4316            let trigger = if fail_at == "outbox" {
4317                "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
4318            } else {
4319                format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
4320            };
4321            client.conn.execute_batch(&trigger).unwrap();
4322            let revision = client.local_revision();
4323            assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
4324            assert_eq!(client.local_revision(), revision);
4325            assert!(client.outbox.is_empty());
4326            assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
4327            assert_eq!(
4328                client
4329                    .conn
4330                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
4331                        .get::<_, i64>(0))
4332                    .unwrap(),
4333                0
4334            );
4335            assert!(client.drain_change_batches().is_empty());
4336            assert!(!client.overlay_dirty.get());
4337            client
4338                .conn
4339                .execute_batch("DROP TRIGGER fail_append")
4340                .unwrap();
4341            client.mutate(vec![mutation]).unwrap();
4342            assert_eq!(client.outbox.len(), 1);
4343            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
4344        }
4345    }
4346
4347    #[test]
4348    fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
4349        let mut client = client();
4350        let tables = vec!["tasks".to_owned(), "missing".to_owned()];
4351        let values = Map::from_iter([
4352            ("id".to_owned(), Value::from("t1")),
4353            ("project_id".to_owned(), Value::from("p1")),
4354        ]);
4355        let payload = encode_row_json(
4356            client.schema.table("tasks").unwrap(),
4357            "t1",
4358            &values,
4359            &client.encryption,
4360        )
4361        .unwrap();
4362        let first = ssp2::model::Change {
4363            table_index: 0,
4364            row_id: "t1".to_owned(),
4365            op: Op::Upsert,
4366            row_version: Some(1),
4367            scopes: vec![("project_id".to_owned(), "p1".to_owned())],
4368            row: Some(payload),
4369        };
4370        let mut invalid = first.clone();
4371        invalid.table_index = 1;
4372        let revision = client.local_revision();
4373        client.begin_observation("failed_mirror").unwrap();
4374        assert!(client
4375            .apply_commit_changes(&tables, &[first, invalid])
4376            .is_err());
4377        client.rollback_observation("failed_mirror");
4378        client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4379        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
4380        assert_eq!(
4381            client
4382                .conn
4383                .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
4384                    .get::<_, i64>(
4385                    0
4386                ))
4387                .unwrap(),
4388            0
4389        );
4390        assert_eq!(client.local_revision(), revision);
4391        assert!(client.drain_change_batches().is_empty());
4392    }
4393
4394    #[test]
4395    fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
4396        for failure in ["journal", "revision", "commit", "later-frame"] {
4397            let mut client = client();
4398            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4399            for id in ["first", "second"] {
4400                client
4401                    .mutate(vec![Mutation::Upsert {
4402                        table: "tasks".to_owned(),
4403                        values: Map::from_iter([
4404                            ("id".to_owned(), Value::from(id)),
4405                            ("project_id".to_owned(), Value::from("p1")),
4406                        ]),
4407                        base_version: None,
4408                    }])
4409                    .unwrap();
4410            }
4411            let (_, meta) = client.build_request(false).unwrap();
4412            // A malicious or stale response must not drain an unsent local commit.
4413            client
4414                .mutate(vec![Mutation::Upsert {
4415                    table: "tasks".to_owned(),
4416                    values: Map::from_iter([
4417                        ("id".to_owned(), Value::from("later")),
4418                        ("project_id".to_owned(), Value::from("p1")),
4419                    ]),
4420                    base_version: None,
4421                }])
4422                .unwrap();
4423            let ids = client.pending_commit_ids();
4424            let rows = client
4425                .query("SELECT * FROM tasks ORDER BY id", &[])
4426                .unwrap();
4427            let revision = client.local_revision();
4428            client.drain_change_batches();
4429            if failure != "later-frame" {
4430                client.conn.execute_batch(match failure {
4431                    "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",
4432                    "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",
4433                    _ => "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",
4434                }).unwrap();
4435            }
4436            let mut response = Message {
4437                wire_version: WIRE_VERSION,
4438                msg_kind: MsgKind::Response,
4439                frames: vec![Frame::RespHeader {
4440                    required_schema_version: None,
4441                    latest_schema_version: None,
4442                    log_epoch: Some("epoch-1".to_owned()),
4443                    reset_required: Some(false),
4444                }],
4445            };
4446            for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
4447                response.frames.push(Frame::PushResult {
4448                    client_commit_id: id.to_owned(),
4449                    status: PushStatus::Applied,
4450                    commit_seq: Some(1),
4451                    results: vec![OpResult::Applied { op_index: 0 }],
4452                });
4453            }
4454            if failure == "later-frame" {
4455                response.frames.push(Frame::Error {
4456                    code: "sync.invalid_request".to_owned(),
4457                    message: "later error".to_owned(),
4458                    category: "protocol".to_owned(),
4459                    retryable: false,
4460                    recommended_action: "retry".to_owned(),
4461                    details: None,
4462                });
4463            }
4464            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
4465            let outcome = client.process_response(&mut transport, response.clone(), &meta);
4466            assert!(
4467                matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
4468            );
4469            if failure == "later-frame" {
4470                assert_eq!(client.pending_commit_ids(), ids[2..]);
4471                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
4472                assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
4473                let batches = client.drain_change_batches();
4474                assert_eq!(batches.len(), 1);
4475                assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
4476                assert_eq!(client.local_revision(), revision + 1);
4477                response.frames.pop();
4478            } else {
4479                assert_eq!(client.pending_commit_ids(), ids);
4480                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
4481                assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
4482                assert_eq!(client.local_revision(), revision);
4483                assert!(client.drain_change_batches().is_empty());
4484                assert_eq!(
4485                    client
4486                        .query("SELECT * FROM tasks ORDER BY id", &[])
4487                        .unwrap(),
4488                    rows
4489                );
4490                client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
4491            }
4492            for frame in &mut response.frames {
4493                if let Frame::PushResult { status, .. } = frame {
4494                    *status = PushStatus::Cached;
4495                }
4496            }
4497            let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
4498            else {
4499                panic!("retry failed");
4500            };
4501            assert_eq!(
4502                report.applied,
4503                if failure == "later-frame" {
4504                    vec![]
4505                } else {
4506                    ids[..2].to_vec()
4507                }
4508            );
4509            assert_eq!(client.pending_commit_ids(), ids[2..]);
4510            let batches = client.drain_change_batches();
4511            assert_eq!(batches.len(), usize::from(failure != "later-frame"));
4512            if let Some(batch) = batches.first() {
4513                assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
4514            }
4515            assert_eq!(client.local_revision(), revision + 1);
4516        }
4517    }
4518
4519    #[test]
4520    fn failed_push_result_preserves_memory_disk_and_retry_identity() {
4521        for failure in ["revision", "journal", "commit"] {
4522            for status in ["applied", "cached", "rejected", "conflict"] {
4523                let mut client = client();
4524                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4525                for id in ["first", "later"] {
4526                    client
4527                        .mutate(vec![Mutation::Upsert {
4528                            table: "tasks".to_owned(),
4529                            values: Map::from_iter([
4530                                ("id".to_owned(), Value::from(id)),
4531                                ("project_id".to_owned(), Value::from("p1")),
4532                            ]),
4533                            base_version: None,
4534                        }])
4535                        .unwrap();
4536                }
4537                let (_, meta) = client.build_request(false).unwrap();
4538                let ids = client.pending_commit_ids();
4539                let rows = client
4540                    .query("SELECT * FROM tasks ORDER BY id", &[])
4541                    .unwrap();
4542                let revision = client.local_revision();
4543                client.drain_change_batches();
4544                let (push_status, results) = match status {
4545                    "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
4546                    "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
4547                    "conflict" => (
4548                        PushStatus::Rejected,
4549                        vec![OpResult::Conflict {
4550                            op_index: 0,
4551                            code: "sync.version_conflict".to_owned(),
4552                            message: "conflict".to_owned(),
4553                            server_version: 1,
4554                            server_row: encode_row_json(
4555                                client.schema.table("tasks").unwrap(),
4556                                "first",
4557                                &Map::from_iter([
4558                                    ("id".to_owned(), Value::from("first")),
4559                                    ("project_id".to_owned(), Value::from("p1")),
4560                                ]),
4561                                &client.encryption,
4562                            )
4563                            .unwrap(),
4564                            conflict_columns: Vec::new(),
4565                        }],
4566                    ),
4567                    _ => (
4568                        PushStatus::Rejected,
4569                        vec![OpResult::Error {
4570                            op_index: 0,
4571                            code: "sync.validation_failed".to_owned(),
4572                            message: "rejected".to_owned(),
4573                            retryable: false,
4574                        }],
4575                    ),
4576                };
4577                let response = Message {
4578                    wire_version: WIRE_VERSION,
4579                    msg_kind: MsgKind::Response,
4580                    frames: vec![
4581                        Frame::RespHeader {
4582                            required_schema_version: None,
4583                            latest_schema_version: None,
4584                            log_epoch: Some("epoch-1".to_owned()),
4585                            reset_required: Some(false),
4586                        },
4587                        Frame::PushResult {
4588                            client_commit_id: ids[0].clone(),
4589                            status: push_status,
4590                            commit_seq: Some(1),
4591                            results,
4592                        },
4593                    ],
4594                };
4595                client.conn.execute_batch(match failure {
4596                    "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
4597                    "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
4598                    _ => "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",
4599                }).unwrap();
4600                let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
4601                let outcome = client.process_response(&mut transport, response.clone(), &meta);
4602                assert_eq!(
4603                    client.pending_commit_ids(),
4604                    ids,
4605                    "{failure}/{status}: pending identity"
4606                );
4607                assert_eq!(
4608                    client.local_revision(),
4609                    revision,
4610                    "{failure}/{status}: revision"
4611                );
4612                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
4613                assert!(client.conflicts().is_empty());
4614                assert!(client.rejections().is_empty());
4615                assert!(client.drain_change_batches().is_empty());
4616                assert_eq!(
4617                    client
4618                        .query("SELECT * FROM tasks ORDER BY id", &[])
4619                        .unwrap(),
4620                    rows
4621                );
4622                assert!(
4623                    matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
4624                    "{failure}/{status}: failed round"
4625                );
4626                client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
4627                assert!(matches!(
4628                    client.process_response(&mut transport, response, &meta),
4629                    SyncOutcome::Ok(_)
4630                ));
4631                assert_eq!(client.pending_commit_ids(), ids[1..]);
4632                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
4633                assert_eq!(client.drain_change_batches().len(), 1);
4634            }
4635        }
4636    }
4637
4638    #[test]
4639    fn mixed_push_results_reconcile_each_boundary_and_prune_once_per_response() {
4640        let mut client = client();
4641        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4642        for index in 0..4 {
4643            client
4644                .mutate(vec![Mutation::Upsert {
4645                    table: "tasks".to_owned(),
4646                    values: Map::from_iter([
4647                        ("id".to_owned(), Value::from(format!("task-{index}"))),
4648                        ("project_id".to_owned(), Value::from("project-1")),
4649                    ]),
4650                    base_version: None,
4651                }])
4652                .expect("queue commit");
4653        }
4654
4655        let (_, request_meta) = client.build_request(false).unwrap();
4656        let ids = &request_meta.pushed_ids;
4657        assert_eq!(ids.len(), 4);
4658        let response = Message {
4659            wire_version: WIRE_VERSION,
4660            msg_kind: MsgKind::Response,
4661            frames: vec![
4662                Frame::RespHeader {
4663                    required_schema_version: None,
4664                    latest_schema_version: None,
4665                    log_epoch: Some("epoch-1".to_owned()),
4666                    reset_required: Some(false),
4667                },
4668                Frame::PushResult {
4669                    client_commit_id: ids[0].clone(),
4670                    status: PushStatus::Applied,
4671                    commit_seq: Some(1),
4672                    results: vec![OpResult::Applied { op_index: 0 }],
4673                },
4674                Frame::PushResult {
4675                    client_commit_id: ids[1].clone(),
4676                    status: PushStatus::Cached,
4677                    commit_seq: Some(2),
4678                    results: vec![OpResult::Applied { op_index: 0 }],
4679                },
4680                Frame::PushResult {
4681                    client_commit_id: ids[2].clone(),
4682                    status: PushStatus::Rejected,
4683                    commit_seq: None,
4684                    results: vec![OpResult::Error {
4685                        op_index: 0,
4686                        code: "sync.validation_failed".to_owned(),
4687                        message: "rejected".to_owned(),
4688                        retryable: false,
4689                    }],
4690                },
4691                Frame::PushResult {
4692                    client_commit_id: ids[3].clone(),
4693                    status: PushStatus::Rejected,
4694                    commit_seq: None,
4695                    results: vec![OpResult::Error {
4696                        op_index: 0,
4697                        code: "sync.idempotency_cache_miss".to_owned(),
4698                        message: "retry".to_owned(),
4699                        retryable: true,
4700                    }],
4701                },
4702            ],
4703        };
4704        let mut transport =
4705            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
4706
4707        client.overlay_rebuild_count.set(0);
4708        client.outcome_prune_count.set(0);
4709        let outcome = client.process_response(&mut transport, response, &request_meta);
4710        let SyncOutcome::Ok(report) = outcome else {
4711            panic!("mixed push-result response failed");
4712        };
4713
4714        assert_eq!(report.applied, ids[..2]);
4715        assert_eq!(report.rejected, ids[2..3]);
4716        assert_eq!(report.retryable, ids[3..4]);
4717        assert_eq!(client.pending_commit_ids(), ids[3..4]);
4718        assert_eq!(
4719            client
4720                .query("SELECT id FROM tasks ORDER BY id", &[])
4721                .expect("query visible overlay"),
4722            ["task-0", "task-1", "task-3"]
4723                .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
4724                .to_vec()
4725        );
4726        // The successful ACK run and final rejection have distinct durable boundaries.
4727        assert_eq!(client.overlay_rebuild_count.get(), 2);
4728        assert_eq!(client.outcome_prune_count.get(), 1);
4729    }
4730
4731    fn replay_schema(fts: bool) -> Value {
4732        json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
4733            {"name":"id","type":"string","nullable":false},
4734            {"name":"project_id","type":"string","nullable":false},
4735            {"name":"title","type":"string","nullable":false},
4736            {"name":"done","type":"boolean","nullable":false}
4737        ],"scopes":[{"pattern":"project:{project_id}"}], "ftsIndexes": if fts { json!([{"name":"tasks_fts","columns":["title"],"tokenize":"unicode61"}]) } else { json!([]) }}]})
4738    }
4739
4740    #[test]
4741    fn aborted_schema_and_epoch_resets_restore_memory_and_durable_intent() {
4742        for schema_reset in [false, true] {
4743            let mut schema = replay_schema(false);
4744            let mut client =
4745                SyncClient::new("reset-failure".into(), &schema, ClientLimits::default()).unwrap();
4746            let id = client
4747                .mutate(vec![Mutation::Upsert {
4748                    table: "tasks".into(),
4749                    values: Map::from_iter([
4750                        ("id".into(), json!("queued")),
4751                        ("project_id".into(), json!("p1")),
4752                        ("title".into(), json!("old")),
4753                        ("done".into(), json!(false)),
4754                    ]),
4755                    base_version: None,
4756                }])
4757                .unwrap();
4758            client.set_meta(LOG_EPOCH_KEY, "old");
4759            let revision = client.local_revision();
4760            let needed = client.sync_needed;
4761            client.drain_change_batches();
4762            client.drain_sync_intents();
4763            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();
4764            if schema_reset {
4765                schema["version"] = json!(2);
4766                schema["tables"][0]["columns"]
4767                    .as_array_mut()
4768                    .unwrap()
4769                    .retain(|column| column["name"] != "title");
4770                client.schema = parse_schema_json(&schema).unwrap();
4771                assert!(client.run_schema_reset().is_err());
4772            } else {
4773                assert!(client.run_log_epoch_reset("new").is_err());
4774            }
4775            assert!(!client.upgrading);
4776            assert_eq!(client.sync_needed, needed);
4777            assert_eq!(client.local_revision(), revision);
4778            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
4779            assert!(client.rejections.is_empty());
4780            assert!(client.commit_outcome(&id).unwrap().is_none());
4781            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("old"));
4782            assert_eq!(
4783                client
4784                    .query("SELECT title FROM tasks WHERE id='queued'", &[])
4785                    .unwrap()[0]["title"],
4786                "old"
4787            );
4788            assert_eq!(
4789                client
4790                    .conn
4791                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
4792                        .get::<_, i64>(0))
4793                    .unwrap(),
4794                1
4795            );
4796            assert!(client.drain_change_batches().is_empty());
4797            assert!(client.drain_sync_intents().is_empty());
4798            assert!(client.conn.is_autocommit());
4799        }
4800    }
4801
4802    #[test]
4803    fn cached_blob_cleanup_propagates_delete_failures() {
4804        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4805            {"name":"id","type":"string","nullable":false},
4806            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4807        let mut client =
4808            SyncClient::new("blob-cleanup".into(), &schema, ClientLimits::default()).unwrap();
4809        client.conn.execute_batch("CREATE TRIGGER refuse_blob_cleanup BEFORE DELETE ON _syncular_blobs BEGIN SELECT RAISE(ABORT,'blob cleanup failed'); END;").unwrap();
4810        client.conn.execute("INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,created_at_ms) VALUES ('test',X'01',1,0)", []).unwrap();
4811        client
4812            .subscribe("attachments".into(), "attachments".into(), vec![], None)
4813            .unwrap();
4814        client.subs[0].effective = Some(vec![]);
4815        client.persist_sub(&client.subs[0]).unwrap();
4816        let revision = client.local_revision();
4817        let (_, meta) = client.build_request(false).unwrap();
4818        let mut transport = CountingRealtimeTransport::default();
4819        let mut report = SyncReport::default();
4820        assert!(client
4821            .process_section(
4822                &mut transport,
4823                "attachments",
4824                SubStatus::Revoked,
4825                "sync.scope_revoked",
4826                vec![],
4827                vec![],
4828                Some((0, None)),
4829                &meta,
4830                &mut report
4831            )
4832            .is_err());
4833        assert_eq!(client.subs[0].state, SubState::Active);
4834        assert_eq!(client.local_revision(), revision);
4835        assert!(report.revoked.is_empty());
4836        assert!(client.conn.is_autocommit());
4837        client
4838            .conn
4839            .execute_batch("DROP TRIGGER refuse_blob_cleanup")
4840            .unwrap();
4841        assert!(client
4842            .process_section(
4843                &mut transport,
4844                "attachments",
4845                SubStatus::Revoked,
4846                "sync.scope_revoked",
4847                vec![],
4848                vec![],
4849                Some((0, None)),
4850                &meta,
4851                &mut report
4852            )
4853            .is_ok());
4854        assert_eq!(client.subs[0].state, SubState::Revoked);
4855        assert_eq!(
4856            client
4857                .conn
4858                .query_row("SELECT count(*) FROM _syncular_blobs", [], |row| row
4859                    .get::<_, i64>(0))
4860                .unwrap(),
4861            0
4862        );
4863    }
4864
4865    #[test]
4866    fn overlay_replay_trigger_refuses_reopen_and_preserves_durable_intent() {
4867        let path =
4868            std::env::temp_dir().join(format!("syncular-replay-{}.db", uuid::Uuid::new_v4()));
4869        let schema = replay_schema(false);
4870        let mut client = SyncClient::open_path(
4871            "replay".into(),
4872            &schema,
4873            ClientLimits::default(),
4874            path.to_str().unwrap(),
4875        )
4876        .unwrap();
4877        let ids = ["early", "bad"].map(|id| {
4878            client
4879                .mutate(vec![Mutation::Upsert {
4880                    table: "tasks".into(),
4881                    values: Map::from_iter([
4882                        ("id".into(), json!(id)),
4883                        ("project_id".into(), json!("p1")),
4884                        ("title".into(), json!(id)),
4885                        ("done".into(), json!(false)),
4886                    ]),
4887                    base_version: None,
4888                }])
4889                .unwrap()
4890        });
4891        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();
4892        let revision = client.local_revision();
4893        drop(client);
4894        let error = SyncClient::open_path(
4895            "replay".into(),
4896            &schema,
4897            ClientLimits::default(),
4898            path.to_str().unwrap(),
4899        )
4900        .err()
4901        .expect("reopen must fail");
4902        assert!(error.contains("replay failed"));
4903        let conn = Connection::open(&path).unwrap();
4904        assert_eq!(
4905            conn.query_row("SELECT count(*) FROM tasks", [], |row| row.get::<_, i64>(0))
4906                .unwrap(),
4907            0
4908        );
4909        assert_eq!(
4910            conn.query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
4911                .get::<_, i64>(0))
4912                .unwrap(),
4913            2
4914        );
4915        assert_eq!(
4916            meta_get(&conn, LOCAL_REVISION_KEY),
4917            Some(revision.to_string())
4918        );
4919        conn.execute_batch("DROP TRIGGER fail_replay").unwrap();
4920        drop(conn);
4921        let reopened = SyncClient::open_path(
4922            "replay".into(),
4923            &schema,
4924            ClientLimits::default(),
4925            path.to_str().unwrap(),
4926        )
4927        .unwrap();
4928        assert_eq!(reopened.pending_commit_ids(), ids);
4929        assert_eq!(
4930            reopened
4931                .query("SELECT id FROM tasks ORDER BY id", &[])
4932                .unwrap()
4933                .len(),
4934            2
4935        );
4936        drop(reopened);
4937        std::fs::remove_file(path).unwrap();
4938    }
4939
4940    #[test]
4941    fn overlay_replay_failure_rolls_back_push_and_pull_apply_boundaries() {
4942        let schema = replay_schema(false);
4943        let mut client =
4944            SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
4945        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4946        client
4947            .write_base_row(
4948                "tasks",
4949                &vec![
4950                    Some(ColumnValue::String("queued".into())),
4951                    Some(ColumnValue::String("p1".into())),
4952                    Some(ColumnValue::String("server".into())),
4953                    Some(ColumnValue::Boolean(false)),
4954                ],
4955                1,
4956            )
4957            .unwrap();
4958        client.rebuild_overlay().unwrap();
4959        let id = client
4960            .patch(
4961                "tasks",
4962                "queued",
4963                Map::from_iter([("title".into(), json!("local"))]),
4964                Some(1),
4965            )
4966            .unwrap();
4967        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
4968        let base = client
4969            .query("SELECT * FROM _syncular_base_tasks", &[])
4970            .unwrap();
4971        let revision = client.local_revision();
4972        client.drain_change_batches();
4973        client.conn.execute_batch("CREATE TRIGGER fail_replay BEFORE INSERT ON tasks WHEN new.title='local' BEGIN SELECT RAISE(ABORT,'replay failed'); END").unwrap();
4974        let remote = vec![
4975            Some(ColumnValue::String("queued".into())),
4976            Some(ColumnValue::String("p1".into())),
4977            Some(ColumnValue::String("remote".into())),
4978            Some(ColumnValue::Boolean(false)),
4979        ];
4980        let bytes = crate::values::encode_row_json(
4981            client.schema.table("tasks").unwrap(),
4982            "queued",
4983            &Map::from_iter(
4984                client
4985                    .schema
4986                    .table("tasks")
4987                    .unwrap()
4988                    .columns
4989                    .iter()
4990                    .zip(&remote)
4991                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value))),
4992            ),
4993            &client.encryption,
4994        )
4995        .unwrap();
4996        let change = ssp2::model::Change {
4997            table_index: 0,
4998            op: Op::Upsert,
4999            row_id: "queued".into(),
5000            row_version: Some(2),
5001            scopes: vec![("project_id".into(), "p1".into())],
5002            row: Some(bytes),
5003        };
5004        assert!(client
5005            .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), Some(2))
5006            .is_err());
5007        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
5008        assert_eq!(
5009            client
5010                .query("SELECT * FROM _syncular_base_tasks", &[])
5011                .unwrap(),
5012            base
5013        );
5014        assert_eq!(client.local_revision(), revision);
5015        assert!(client.drain_change_batches().is_empty());
5016        let response = Message {
5017            wire_version: 2,
5018            msg_kind: MsgKind::Response,
5019            frames: vec![
5020                Frame::RespHeader {
5021                    required_schema_version: None,
5022                    latest_schema_version: None,
5023                    log_epoch: Some("epoch-1".into()),
5024                    reset_required: Some(false),
5025                },
5026                Frame::PushResult {
5027                    client_commit_id: id.clone(),
5028                    status: PushStatus::Applied,
5029                    commit_seq: Some(2),
5030                    results: vec![OpResult::Applied { op_index: 0 }],
5031                },
5032            ],
5033        };
5034        let (_, meta) = client.build_request(false).unwrap();
5035        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5036        assert!(
5037            matches!(client.process_response(&mut transport,response.clone(),&meta),SyncOutcome::Failed { error_code,.. } if error_code=="client.outcome_persistence_failed")
5038        );
5039        assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5040        assert!(client.commit_outcome(&id).unwrap().is_none());
5041        assert_eq!(
5042            client
5043                .conn
5044                .query_row("SELECT count(*) FROM _syncular_acked_rows", [], |row| row
5045                    .get::<_, i64>(
5046                    0
5047                ))
5048                .unwrap(),
5049            0
5050        );
5051        assert_eq!(
5052            client
5053                .conn
5054                .query_row("SELECT count(*) FROM _syncular_row_deliveries", [], |row| {
5055                    row.get::<_, i64>(0)
5056                })
5057                .unwrap(),
5058            0
5059        );
5060        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
5061        assert_eq!(client.local_revision(), revision);
5062        assert!(client.drain_change_batches().is_empty());
5063        client
5064            .conn
5065            .execute_batch("DROP TRIGGER fail_replay")
5066            .unwrap();
5067        assert!(matches!(
5068            client.process_response(&mut transport, response, &meta),
5069            SyncOutcome::Ok(_)
5070        ));
5071        client
5072            .apply_commit_frame(&["tasks".into()], &[change], Some(2))
5073            .unwrap();
5074        assert!(client.pending_commit_ids().is_empty());
5075        assert_eq!(
5076            client.query("SELECT title FROM tasks", &[]).unwrap()[0]["title"],
5077            json!("remote")
5078        );
5079    }
5080
5081    #[test]
5082    fn overlay_replay_distinguishes_absence_from_sql_and_value_decode_errors() {
5083        let client = SyncClient::new(
5084            "replay".into(),
5085            &replay_schema(false),
5086            ClientLimits::default(),
5087        )
5088        .unwrap();
5089        let table = client.schema.table("tasks").unwrap();
5090        let patch = OutboxOp {
5091            table: "tasks".into(),
5092            row_id: "absent".into(),
5093            upsert: true,
5094            base_version: Some(1),
5095            values: Some(Map::from_iter([
5096                ("id".into(), json!("absent")),
5097                ("title".into(), json!("patch")),
5098            ])),
5099        };
5100        assert!(client.visible_row(table, "absent").unwrap().is_none());
5101        client
5102            .apply_outbox_ops([&patch])
5103            .expect("sparse replay over truly absent row");
5104        client
5105            .conn
5106            .execute_batch("INSERT INTO tasks VALUES ('bad','p1','title','invalid',1)")
5107            .unwrap();
5108        assert!(client
5109            .visible_row(table, "bad")
5110            .unwrap_err()
5111            .starts_with("sync.local_corrupt:"));
5112        let mut invalid = patch.clone();
5113        invalid.values = Some(Map::from_iter([
5114            ("id".into(), json!("bad-value")),
5115            ("project_id".into(), json!("p1")),
5116            ("title".into(), json!("title")),
5117            ("done".into(), json!({"bad":true})),
5118        ]));
5119        assert!(client
5120            .apply_outbox_ops([&invalid])
5121            .unwrap_err()
5122            .starts_with("sync.local_corrupt:"));
5123        client.conn.execute_batch("DROP TABLE tasks").unwrap();
5124        assert!(
5125            client.apply_outbox_ops([&patch]).is_err(),
5126            "SQL failure must not count as absence"
5127        );
5128    }
5129
5130    #[test]
5131    fn overlay_replay_keeps_secondary_unique_deferral_and_lazy_base_precedence() {
5132        let mut schema = replay_schema(false);
5133        schema["tables"][0]["indexes"] =
5134            json!([{"name":"tasks_project_title", "columns":["project_id","title"],"unique":true}]);
5135        let mut client =
5136            SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
5137        let id = client
5138            .mutate(vec![Mutation::Upsert {
5139                table: "tasks".into(),
5140                values: Map::from_iter([
5141                    ("id".into(), json!("queued")),
5142                    ("project_id".into(), json!("p1")),
5143                    ("title".into(), json!("occupied")),
5144                    ("done".into(), json!(false)),
5145                ]),
5146                base_version: None,
5147            }])
5148            .unwrap();
5149        client
5150            .write_base_row(
5151                "tasks",
5152                &vec![
5153                    Some(ColumnValue::String("server".into())),
5154                    Some(ColumnValue::String("p1".into())),
5155                    Some(ColumnValue::String("occupied".into())),
5156                    Some(ColumnValue::Boolean(false)),
5157                ],
5158                1,
5159            )
5160            .unwrap();
5161        client.rebuild_overlay_if_dirty().unwrap();
5162        assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5163        assert!(client
5164            .visible_row(client.schema.table("tasks").unwrap(), "queued")
5165            .unwrap()
5166            .is_none());
5167        assert!(client.rejections().is_empty());
5168        client.delete_base_row("tasks", "server").unwrap();
5169        client.rebuild_overlay_if_dirty().unwrap();
5170        assert!(client
5171            .visible_row(client.schema.table("tasks").unwrap(), "queued")
5172            .unwrap()
5173            .is_some());
5174        client
5175            .write_base_row(
5176                "tasks",
5177                &vec![
5178                    Some(ColumnValue::String("base".into())),
5179                    Some(ColumnValue::String("p1".into())),
5180                    Some(ColumnValue::String("base-title".into())),
5181                    Some(ColumnValue::Boolean(false)),
5182                ],
5183                1,
5184            )
5185            .unwrap();
5186        client.conn.execute_batch("DROP TABLE tasks").unwrap();
5187        client
5188            .retain_failed_commit(
5189                "retained",
5190                &[OutboxOp {
5191                    table: "tasks".into(),
5192                    row_id: "base".into(),
5193                    upsert: true,
5194                    base_version: Some(1),
5195                    values: Some(Map::from_iter([
5196                        ("id".into(), json!("base")),
5197                        ("title".into(), json!("patch")),
5198                    ])),
5199                }],
5200            )
5201            .expect("an existing base must not read the unavailable visible table");
5202    }
5203
5204    #[test]
5205    fn overlay_replay_fts_and_savepoint_release_failures_roll_back_all_visible_changes() {
5206        for failure in ["fts", "release"] {
5207            let mut client = SyncClient::new(
5208                "replay".into(),
5209                &replay_schema(true),
5210                ClientLimits::default(),
5211            )
5212            .unwrap();
5213            client
5214                .mutate(vec![Mutation::Upsert {
5215                    table: "tasks".into(),
5216                    values: Map::from_iter([
5217                        ("id".into(), json!("queued")),
5218                        ("project_id".into(), json!("p1")),
5219                        ("title".into(), json!("needle")),
5220                        ("done".into(), json!(false)),
5221                    ]),
5222                    base_version: None,
5223                }])
5224                .unwrap();
5225            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
5226            let fts_before = client
5227                .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
5228                .unwrap();
5229            let triggers_before: Vec<String> = client
5230                .conn
5231                .prepare("SELECT sql FROM sqlite_master WHERE type='trigger' ORDER BY name")
5232                .unwrap()
5233                .query_map([], |row| row.get(0))
5234                .unwrap()
5235                .collect::<Result<_, _>>()
5236                .unwrap();
5237            if failure == "fts" {
5238                client.conn.execute_batch("CREATE TRIGGER fail_fts BEFORE INSERT ON _syncular_fts_tasks_fts BEGIN SELECT RAISE(ABORT,'fts replay failed'); END;").unwrap();
5239            } else {
5240                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();
5241            }
5242            client.overlay_dirty.set(true);
5243            assert!(client.rebuild_overlay().is_err());
5244            assert!(client.overlay_dirty.get());
5245            assert!(client.conn.is_autocommit());
5246            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
5247            assert_eq!(
5248                client
5249                    .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
5250                    .unwrap(),
5251                fts_before
5252            );
5253            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();
5254            assert_eq!(original_triggers, triggers_before);
5255            assert_eq!(client.pending_commit_ids().len(), 1);
5256            client
5257                .conn
5258                .execute_batch(if failure == "fts" {
5259                    "DROP TRIGGER fail_fts"
5260                } else {
5261                    "DROP TRIGGER fail_release"
5262                })
5263                .unwrap();
5264            client
5265                .rebuild_overlay()
5266                .expect("retry after removing the fault");
5267        }
5268    }
5269
5270    #[test]
5271    fn secondary_unique_collision_preserves_existing_synced_row() {
5272        let client = SyncClient::new(
5273            "unique-upsert-test".to_owned(),
5274            &json!({
5275                "version": 1,
5276                "tables": [{
5277                    "name": "tasks",
5278                    "primaryKey": "id",
5279                    "columns": [
5280                        { "name": "id", "type": "string", "nullable": false },
5281                        { "name": "project_id", "type": "string", "nullable": false },
5282                        { "name": "title", "type": "string", "nullable": false }
5283                    ],
5284                    "scopes": [{ "pattern": "project:{project_id}" }],
5285                    "indexes": [{
5286                        "name": "tasks_by_project_title",
5287                        "columns": ["project_id", "title"],
5288                        "unique": true
5289                    }]
5290                }]
5291            }),
5292            ClientLimits::default(),
5293        )
5294        .expect("test client");
5295        let table = client.schema.table("tasks").expect("tasks table");
5296        let sql = client.insert_row_sql(&base_table("tasks"), table);
5297
5298        client
5299            .conn
5300            .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
5301            .expect("insert first row");
5302        client
5303            .conn
5304            .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
5305            .expect("update same primary key");
5306        client
5307            .conn
5308            .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
5309            .expect("insert second row");
5310        assert!(client
5311            .conn
5312            .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
5313            .is_err());
5314
5315        let rows = client
5316            .conn
5317            .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
5318            .expect("prepare rows")
5319            .query_map([], |row| {
5320                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5321            })
5322            .expect("query rows")
5323            .collect::<Result<Vec<_>, _>>()
5324            .expect("collect rows");
5325        assert_eq!(
5326            rows,
5327            vec![
5328                ("t1".to_owned(), "updated".to_owned()),
5329                ("t2".to_owned(), "original".to_owned())
5330            ]
5331        );
5332    }
5333
5334    #[test]
5335    fn reopening_active_subscriptions_emits_a_catch_up_intent() {
5336        let path = std::env::temp_dir().join(format!(
5337            "syncular-startup-intent-{}.db",
5338            uuid::Uuid::new_v4()
5339        ));
5340        let schema = json!({
5341            "version": 1,
5342            "tables": [{
5343                "name": "tasks",
5344                "primaryKey": "id",
5345                "columns": [
5346                    { "name": "id", "type": "string", "nullable": false },
5347                    { "name": "project_id", "type": "string", "nullable": false }
5348                ],
5349                "scopes": [{ "pattern": "project:{project_id}" }]
5350            }]
5351        });
5352
5353        {
5354            let mut first = SyncClient::open_path_with_identity(
5355                None,
5356                &schema,
5357                ClientLimits::default(),
5358                path.to_str().expect("UTF-8 temp path"),
5359            )
5360            .expect("first open");
5361            first
5362                .set_window(
5363                    &WindowBase {
5364                        table: "tasks".to_owned(),
5365                        variable: "project_id".to_owned(),
5366                        fixed_scopes: Vec::new(),
5367                        params: None,
5368                    },
5369                    &["persisted".to_owned()],
5370                )
5371                .expect("persist window");
5372        }
5373
5374        let mut reopened = SyncClient::open_path_with_identity(
5375            None,
5376            &schema,
5377            ClientLimits::default(),
5378            path.to_str().expect("UTF-8 temp path"),
5379        )
5380        .expect("reopen");
5381        assert!(reopened.sync_needed());
5382        assert!(matches!(
5383            reopened.drain_sync_intents().as_slice(),
5384            [SyncIntent::Interactive]
5385        ));
5386        drop(reopened);
5387        std::fs::remove_file(path).expect("remove temp database");
5388    }
5389
5390    #[test]
5391    fn reopening_preserves_immutable_subscription_identity_and_progress() {
5392        let path = std::env::temp_dir().join(format!(
5393            "syncular-subscription-identity-{}.db",
5394            uuid::Uuid::new_v4()
5395        ));
5396        let schema = json!({
5397            "version": 1,
5398            "tables": [
5399                {
5400                    "name": "tasks",
5401                    "primaryKey": "id",
5402                    "columns": [
5403                        { "name": "id", "type": "string", "nullable": false },
5404                        { "name": "project_id", "type": "string", "nullable": false }
5405                    ],
5406                    "scopes": [{ "pattern": "project:{project_id}" }]
5407                },
5408                {
5409                    "name": "docs",
5410                    "primaryKey": "id",
5411                    "columns": [
5412                        { "name": "id", "type": "string", "nullable": false },
5413                        { "name": "org_id", "type": "string", "nullable": false },
5414                        { "name": "project_id", "type": "string", "nullable": false }
5415                    ],
5416                    "scopes": [
5417                        { "pattern": "org:{org_id}" },
5418                        { "pattern": "project:{projectId}", "column": "project_id" }
5419                    ]
5420                }
5421            ]
5422        });
5423
5424        {
5425            let mut first = SyncClient::open_path_with_identity(
5426                None,
5427                &schema,
5428                ClientLimits::default(),
5429                path.to_str().expect("UTF-8 temp path"),
5430            )
5431            .expect("first open");
5432            first
5433                .subscribe(
5434                    "stable-subscription".to_owned(),
5435                    "tasks".to_owned(),
5436                    vec![(
5437                        "project_id".to_owned(),
5438                        vec!["p2".to_owned(), "p1".to_owned()],
5439                    )],
5440                    Some(r#"{"view":"v1"}"#.to_owned()),
5441                )
5442                .expect("persist subscription");
5443            let persisted = {
5444                let subscription = first
5445                    .subs
5446                    .iter_mut()
5447                    .find(|subscription| subscription.id == "stable-subscription")
5448                    .expect("subscription");
5449                subscription.cursor = 41;
5450                subscription.bootstrap_state = Some("resume-token".to_owned());
5451                subscription.effective = Some(vec![(
5452                    "project_id".to_owned(),
5453                    vec!["p1".to_owned(), "p2".to_owned()],
5454                )]);
5455                subscription.synced_once = true;
5456                subscription.clone()
5457            };
5458            first.persist_sub(&persisted).unwrap();
5459        }
5460
5461        let mut reopened = SyncClient::open_path_with_identity(
5462            None,
5463            &schema,
5464            ClientLimits::default(),
5465            path.to_str().expect("UTF-8 temp path"),
5466        )
5467        .expect("reopen");
5468        let progress = reopened
5469            .subscription_state("stable-subscription")
5470            .expect("persisted state");
5471        assert_eq!(progress.cursor, 41);
5472        assert!(progress.has_resume_token);
5473
5474        reopened
5475            .subscribe(
5476                "stable-subscription".to_owned(),
5477                "tasks".to_owned(),
5478                vec![(
5479                    "project_id".to_owned(),
5480                    vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
5481                )],
5482                Some(r#"{"view":"v1"}"#.to_owned()),
5483            )
5484            .expect("canonical intent is idempotent");
5485        assert_eq!(
5486            reopened
5487                .subscription_state("stable-subscription")
5488                .expect("unchanged state")
5489                .cursor,
5490            progress.cursor
5491        );
5492
5493        for (table, scopes, params) in [
5494            (
5495                "tasks",
5496                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
5497                Some(r#"{"view":"v1"}"#.to_owned()),
5498            ),
5499            (
5500                "tasks",
5501                vec![(
5502                    "project_id".to_owned(),
5503                    vec!["p2".to_owned(), "p1".to_owned()],
5504                )],
5505                Some(r#"{"view":"v2"}"#.to_owned()),
5506            ),
5507            (
5508                "docs",
5509                vec![
5510                    ("org_id".to_owned(), vec!["o1".to_owned()]),
5511                    ("projectId".to_owned(), vec!["p1".to_owned()]),
5512                ],
5513                Some(r#"{"view":"v1"}"#.to_owned()),
5514            ),
5515        ] {
5516            let error = reopened
5517                .subscribe(
5518                    "stable-subscription".to_owned(),
5519                    table.to_owned(),
5520                    scopes,
5521                    params,
5522                )
5523                .expect_err("identity rebind must fail");
5524            assert!(error.starts_with("client.subscription_intent_mismatch:"));
5525            assert_eq!(
5526                reopened
5527                    .subscription_state("stable-subscription")
5528                    .expect("unchanged state")
5529                    .cursor,
5530                progress.cursor
5531            );
5532        }
5533
5534        drop(reopened);
5535        std::fs::remove_file(path).expect("remove temp database");
5536    }
5537
5538    #[test]
5539    fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
5540        let path = std::env::temp_dir().join(format!(
5541            "syncular-satisfied-schema-floor-{}.db",
5542            uuid::Uuid::new_v4()
5543        ));
5544        let schema = json!({
5545            "version": 23,
5546            "tables": [{
5547                "name": "tasks",
5548                "primaryKey": "id",
5549                "columns": [
5550                    { "name": "id", "type": "string", "nullable": false },
5551                    { "name": "project_id", "type": "string", "nullable": false }
5552                ],
5553                "scopes": [{ "pattern": "project:{project_id}" }]
5554            }]
5555        });
5556
5557        {
5558            let mut first = SyncClient::open_path_with_identity(
5559                None,
5560                &schema,
5561                ClientLimits::default(),
5562                path.to_str().expect("UTF-8 temp path"),
5563            )
5564            .expect("first open");
5565            first
5566                .subscribe(
5567                    "tasks".to_owned(),
5568                    "tasks".to_owned(),
5569                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
5570                    None,
5571                )
5572                .expect("persist subscription");
5573            first.set_schema_floor(Some(SchemaFloor {
5574                required_schema_version: Some(22),
5575                latest_schema_version: Some(22),
5576            }));
5577        }
5578
5579        let mut reopened = SyncClient::open_path_with_identity(
5580            None,
5581            &schema,
5582            ClientLimits::default(),
5583            path.to_str().expect("UTF-8 temp path"),
5584        )
5585        .expect("reopen");
5586        assert!(reopened.schema_floor().is_none());
5587        assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
5588        assert!(reopened.sync_needed());
5589        assert!(matches!(
5590            reopened.drain_sync_intents().as_slice(),
5591            [SyncIntent::Interactive]
5592        ));
5593        drop(reopened);
5594        std::fs::remove_file(path).expect("remove temp database");
5595    }
5596
5597    #[test]
5598    fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
5599        let path = std::env::temp_dir().join(format!(
5600            "syncular-unsatisfied-schema-floor-{}.db",
5601            uuid::Uuid::new_v4()
5602        ));
5603        let schema = json!({
5604            "version": 1,
5605            "tables": [{
5606                "name": "tasks",
5607                "primaryKey": "id",
5608                "columns": [
5609                    { "name": "id", "type": "string", "nullable": false },
5610                    { "name": "project_id", "type": "string", "nullable": false }
5611                ],
5612                "scopes": [{ "pattern": "project:{project_id}" }]
5613            }]
5614        });
5615
5616        {
5617            let mut first = SyncClient::open_path_with_identity(
5618                None,
5619                &schema,
5620                ClientLimits::default(),
5621                path.to_str().expect("UTF-8 temp path"),
5622            )
5623            .expect("first open");
5624            first
5625                .subscribe(
5626                    "tasks".to_owned(),
5627                    "tasks".to_owned(),
5628                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
5629                    None,
5630                )
5631                .expect("persist subscription");
5632            first.set_schema_floor(Some(SchemaFloor {
5633                required_schema_version: Some(2),
5634                latest_schema_version: Some(2),
5635            }));
5636        }
5637
5638        let reopened = SyncClient::open_path_with_identity(
5639            None,
5640            &schema,
5641            ClientLimits::default(),
5642            path.to_str().expect("UTF-8 temp path"),
5643        )
5644        .expect("reopen");
5645        assert_eq!(
5646            reopened.schema_floor(),
5647            Some(&SchemaFloor {
5648                required_schema_version: Some(2),
5649                latest_schema_version: Some(2),
5650            })
5651        );
5652        assert!(!reopened.sync_needed());
5653        drop(reopened);
5654        std::fs::remove_file(path).expect("remove temp database");
5655    }
5656
5657    #[test]
5658    fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
5659        let path = std::env::temp_dir().join(format!(
5660            "syncular-indexed-column-bump-{}.db",
5661            uuid::Uuid::new_v4()
5662        ));
5663        let old_schema = json!({
5664            "version": 1,
5665            "tables": [
5666                {
5667                    "name": "tasks",
5668                    "primaryKey": "id",
5669                    "columns": [
5670                        { "name": "id", "type": "string", "nullable": false },
5671                        { "name": "project_id", "type": "string", "nullable": false }
5672                    ],
5673                    "scopes": [{ "pattern": "project:{project_id}" }]
5674                },
5675                {
5676                    "name": "legacy",
5677                    "primaryKey": "id",
5678                    "columns": [
5679                        { "name": "id", "type": "string", "nullable": false },
5680                        { "name": "project_id", "type": "string", "nullable": false }
5681                    ],
5682                    "scopes": [{ "pattern": "project:{project_id}" }]
5683                }
5684            ]
5685        });
5686        {
5687            let mut first = SyncClient::open_path_with_identity(
5688                None,
5689                &old_schema,
5690                ClientLimits::default(),
5691                path.to_str().expect("UTF-8 temp path"),
5692            )
5693            .expect("open old schema");
5694            first
5695                .subscribe(
5696                    "legacy-sub".to_owned(),
5697                    "legacy".to_owned(),
5698                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
5699                    None,
5700                )
5701                .expect("persist legacy subscription");
5702        }
5703
5704        let new_schema = json!({
5705            "version": 2,
5706            "tables": [{
5707                "name": "tasks",
5708                "primaryKey": "id",
5709                "columns": [
5710                    { "name": "id", "type": "string", "nullable": false },
5711                    { "name": "project_id", "type": "string", "nullable": false },
5712                    { "name": "facility_membership_id", "type": "string", "nullable": true }
5713                ],
5714                "scopes": [{ "pattern": "project:{project_id}" }],
5715                "indexes": [{
5716                    "name": "tasks_by_membership",
5717                    "columns": ["project_id", "facility_membership_id"],
5718                    "unique": false
5719                }]
5720            }]
5721        });
5722        let upgraded = SyncClient::open_path_with_identity(
5723            None,
5724            &new_schema,
5725            ClientLimits::default(),
5726            path.to_str().expect("UTF-8 temp path"),
5727        )
5728        .expect("open upgraded schema");
5729        let columns = upgraded
5730            .conn
5731            .prepare("PRAGMA table_info(tasks)")
5732            .expect("prepare columns")
5733            .query_map([], |row| row.get::<_, String>(1))
5734            .expect("query columns")
5735            .collect::<Result<Vec<_>, _>>()
5736            .expect("collect columns");
5737        assert!(columns.contains(&"facility_membership_id".to_owned()));
5738        let index_count: i64 = upgraded
5739            .conn
5740            .query_row(
5741                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
5742                [],
5743                |row| row.get(0),
5744            )
5745            .expect("query index");
5746        assert_eq!(index_count, 1);
5747        assert!(upgraded.subscription_state("legacy-sub").is_none());
5748        drop(upgraded);
5749        std::fs::remove_file(path).expect("remove temp database");
5750    }
5751
5752    #[test]
5753    fn migrates_pre_envelope_outcome_journal_additively() {
5754        let path = std::env::temp_dir().join(format!(
5755            "syncular-outcome-migration-{}.db",
5756            uuid::Uuid::new_v4()
5757        ));
5758        let conn = Connection::open(&path).expect("open legacy database");
5759        conn.execute_batch(
5760            "CREATE TABLE _syncular_commit_outcomes (
5761               seq INTEGER PRIMARY KEY AUTOINCREMENT,
5762               client_commit_id TEXT NOT NULL UNIQUE,
5763               status TEXT NOT NULL,
5764               recorded_at_ms INTEGER NOT NULL,
5765               results_json TEXT NOT NULL,
5766               resolution TEXT NOT NULL DEFAULT 'active',
5767               resolved_at_ms INTEGER,
5768               replacement_client_commit_id TEXT);",
5769        )
5770        .expect("create legacy outcome journal");
5771        drop(conn);
5772        let schema = json!({
5773            "version": 1,
5774            "tables": [{
5775                "name": "tasks",
5776                "primaryKey": "id",
5777                "columns": [
5778                    { "name": "id", "type": "string", "nullable": false },
5779                    { "name": "project_id", "type": "string", "nullable": false }
5780                ],
5781                "scopes": [{ "pattern": "project:{project_id}" }]
5782            }]
5783        });
5784        let client = SyncClient::open_path(
5785            "migration-native".to_owned(),
5786            &schema,
5787            ClientLimits::default(),
5788            path.to_str().expect("UTF-8 temp path"),
5789        )
5790        .expect("migrate database");
5791        let has_operations = client
5792            .conn
5793            .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
5794            .expect("prepare table info")
5795            .query_map([], |row| row.get::<_, String>(1))
5796            .expect("query table info")
5797            .filter_map(Result::ok)
5798            .any(|column| column == "operations_json");
5799        assert!(has_operations);
5800        drop(client);
5801        std::fs::remove_file(path).expect("remove temp database");
5802    }
5803
5804    #[test]
5805    fn retained_rejection_survives_file_reopen_and_security_purge() {
5806        let path =
5807            std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
5808        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
5809            { "name": "id", "type": "string", "nullable": false },
5810            { "name": "project_id", "type": "string", "nullable": false },
5811            { "name": "title", "type": "string", "nullable": false }
5812        ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
5813        {
5814            let mut first = SyncClient::open_path(
5815                "retained-native".to_owned(),
5816                &schema,
5817                ClientLimits::default(),
5818                path.to_str().unwrap(),
5819            )
5820            .unwrap();
5821            first
5822                .write_base_row(
5823                    "tasks",
5824                    &vec![
5825                        Some(ColumnValue::String("t1".into())),
5826                        Some(ColumnValue::String("p1".into())),
5827                        Some(ColumnValue::String("server".into())),
5828                    ],
5829                    1,
5830                )
5831                .unwrap();
5832            first.rebuild_overlay().expect("rebuild overlay");
5833            let operation = OutboxOp {
5834                table: "tasks".into(),
5835                row_id: "t1".into(),
5836                upsert: true,
5837                base_version: Some(1),
5838                values: Some(Map::from_iter([
5839                    ("id".into(), json!("t1")),
5840                    ("title".into(), json!("mine")),
5841                ])),
5842            };
5843            first
5844                .retain_failed_commit("retained", std::slice::from_ref(&operation))
5845                .unwrap();
5846            first
5847                .persist_commit_outcome(
5848                    "retained",
5849                    CommitOutcomeStatus::Rejected,
5850                    &[CommitOperationOutcome::Error {
5851                        rejection: RejectionRecord {
5852                            client_commit_id: "retained".into(),
5853                            op_index: 0,
5854                            code: "validation.denied".into(),
5855                            message: "denied".into(),
5856                            retryable: false,
5857                            details: None,
5858                            operation: None,
5859                        },
5860                    }],
5861                    Some(&[operation]),
5862                )
5863                .unwrap();
5864            first.rebuild_overlay().expect("rebuild overlay");
5865        }
5866        {
5867            let mut reopened = SyncClient::open_path(
5868                "retained-native".to_owned(),
5869                &schema,
5870                ClientLimits::default(),
5871                path.to_str().unwrap(),
5872            )
5873            .unwrap();
5874            let row = reopened
5875                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
5876                .expect("read visible row")
5877                .unwrap();
5878            assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
5879            assert_eq!(
5880                reopened
5881                    .commit_outcome("retained")
5882                    .unwrap()
5883                    .unwrap()
5884                    .retained_rows
5885                    .unwrap()[0]
5886                    .server_row
5887                    .as_ref()
5888                    .unwrap()["title"],
5889                json!("server")
5890            );
5891            reopened
5892                .conn
5893                .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
5894                .unwrap();
5895            reopened.rebuild_overlay().expect("rebuild overlay");
5896            assert!(reopened
5897                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
5898                .expect("read visible row")
5899                .is_none());
5900            let retained = reopened.commit_outcome("retained").unwrap().unwrap();
5901            assert!(retained.retained_rows.is_some());
5902            assert_eq!(
5903                retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
5904                json!("mine")
5905            );
5906            reopened
5907                .purge_local_data(&LocalDataPurgeInput {
5908                    purge_id: "protected-purge".into(),
5909                    targets: vec![LocalDataPurgeTarget {
5910                        table: "tasks".into(),
5911                        selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
5912                    }],
5913                })
5914                .unwrap();
5915            assert!(reopened
5916                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
5917                .expect("read visible row")
5918                .is_none());
5919            assert!(reopened
5920                .commit_outcome("retained")
5921                .unwrap()
5922                .unwrap()
5923                .retained_rows
5924                .is_none());
5925        }
5926        std::fs::remove_file(path).unwrap();
5927    }
5928
5929    #[test]
5930    fn durable_conflict_outcome_and_resolution_survive_reopen() {
5931        let path = std::env::temp_dir().join(format!(
5932            "syncular-durable-outcome-{}.db",
5933            uuid::Uuid::new_v4()
5934        ));
5935        let schema = json!({
5936            "version": 1,
5937            "tables": [{
5938                "name": "tasks",
5939                "primaryKey": "id",
5940                "columns": [
5941                    { "name": "id", "type": "string", "nullable": false },
5942                    { "name": "project_id", "type": "string", "nullable": false }
5943                ],
5944                "scopes": [{ "pattern": "project:{project_id}" }]
5945            }]
5946        });
5947        let conflict = ConflictRecord {
5948            client_commit_id: "losing-commit".to_owned(),
5949            op_index: 0,
5950            table: "tasks".to_owned(),
5951            row_id: "t1".to_owned(),
5952            code: "sync.version_conflict".to_owned(),
5953            message: "stale base version".to_owned(),
5954            server_version: 2,
5955            server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
5956            conflict_columns: vec!["project_id".to_owned()],
5957            operation: Some(CommitOperation {
5958                table: "tasks".to_owned(),
5959                row_id: "t1".to_owned(),
5960                op: "upsert".to_owned(),
5961                base_version: Some(1),
5962                values: Some(Map::from_iter([
5963                    ("id".into(), json!("t1")),
5964                    ("project_id".into(), json!("p1")),
5965                ])),
5966            }),
5967        };
5968        let failed_operations = vec![
5969            OutboxOp {
5970                upsert: true,
5971                table: "tasks".to_owned(),
5972                row_id: "t1".to_owned(),
5973                base_version: Some(1),
5974                values: Some(Map::from_iter([
5975                    ("id".into(), json!("t1")),
5976                    ("project_id".into(), json!("p1")),
5977                ])),
5978            },
5979            OutboxOp {
5980                upsert: true,
5981                table: "tasks".to_owned(),
5982                row_id: "status-event-1".to_owned(),
5983                base_version: Some(0),
5984                values: Some(Map::from_iter([
5985                    ("id".into(), json!("status-event-1")),
5986                    ("project_id".into(), json!("p1")),
5987                ])),
5988            },
5989        ];
5990
5991        {
5992            let mut first = SyncClient::open_path(
5993                "durable-native".to_owned(),
5994                &schema,
5995                ClientLimits::default(),
5996                path.to_str().expect("UTF-8 temp path"),
5997            )
5998            .expect("first open");
5999            first
6000                .begin_observation("test_outcome")
6001                .expect("begin outcome");
6002            first
6003                .persist_commit_outcome(
6004                    "losing-commit",
6005                    CommitOutcomeStatus::Conflict,
6006                    &[CommitOperationOutcome::Conflict {
6007                        conflict: conflict.clone(),
6008                    }],
6009                    Some(&failed_operations),
6010                )
6011                .expect("persist outcome");
6012            first.conflicts.push(conflict);
6013            first
6014                .finish_observation(
6015                    "test_outcome",
6016                    ChangeAccumulator {
6017                        conflicts: true,
6018                        outcomes: true,
6019                        ..ChangeAccumulator::default()
6020                    },
6021                )
6022                .expect("commit outcome");
6023        }
6024
6025        {
6026            let mut reopened = SyncClient::open_path(
6027                "durable-native".to_owned(),
6028                &schema,
6029                ClientLimits::default(),
6030                path.to_str().expect("UTF-8 temp path"),
6031            )
6032            .expect("reopen");
6033            assert_eq!(reopened.conflicts().len(), 1);
6034            assert_eq!(
6035                reopened.conflicts()[0].conflict_columns,
6036                vec!["project_id".to_owned()],
6037                "conflictColumns survives the journal round trip"
6038            );
6039            let outcome = reopened
6040                .commit_outcome("losing-commit")
6041                .expect("read outcome")
6042                .expect("outcome");
6043            assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
6044            let operations = outcome.operations.expect("aggregate envelope");
6045            assert_eq!(operations.len(), 2);
6046            assert_eq!(operations[1].row_id, "status-event-1");
6047            let resolved = reopened
6048                .resolve_commit_outcome(ResolveCommitOutcomeInput {
6049                    client_commit_id: "losing-commit".to_owned(),
6050                    resolution: CommitOutcomeResolution::ResolvedKeepServer,
6051                    replacement_client_commit_id: None,
6052                })
6053                .expect("resolve");
6054            assert_eq!(
6055                resolved.resolution,
6056                CommitOutcomeResolution::ResolvedKeepServer
6057            );
6058            assert!(reopened.conflicts().is_empty());
6059        }
6060
6061        let reopened = SyncClient::open_path(
6062            "durable-native".to_owned(),
6063            &schema,
6064            ClientLimits::default(),
6065            path.to_str().expect("UTF-8 temp path"),
6066        )
6067        .expect("second reopen");
6068        assert!(reopened.conflicts().is_empty());
6069        assert_eq!(
6070            reopened
6071                .commit_outcome("losing-commit")
6072                .expect("read outcome")
6073                .expect("outcome")
6074                .resolution,
6075            CommitOutcomeResolution::ResolvedKeepServer
6076        );
6077        drop(reopened);
6078        std::fs::remove_file(path).expect("remove temp database");
6079    }
6080
6081    #[test]
6082    fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
6083        let path =
6084            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6085        let schema = json!({
6086            "version": 1,
6087            "tables": [{
6088                "name": "tasks",
6089                "primaryKey": "id",
6090                "columns": [
6091                    { "name": "id", "type": "string", "nullable": false },
6092                    { "name": "project_id", "type": "string", "nullable": false }
6093                ],
6094                "scopes": [{ "pattern": "project:{project_id}" }]
6095            }]
6096        });
6097        let mut client = SyncClient::open_path_with_identity(
6098            Some("sidecar-client".to_owned()),
6099            &schema,
6100            ClientLimits::default(),
6101            path.to_str().expect("UTF-8 temp path"),
6102        )
6103        .expect("open owner");
6104        let base = WindowBase {
6105            table: "tasks".to_owned(),
6106            variable: "project_id".to_owned(),
6107            fixed_scopes: Vec::new(),
6108            params: None,
6109        };
6110        client
6111            .set_window(&base, &["one".to_owned()])
6112            .expect("set window");
6113        client
6114            .mutate(vec![Mutation::Upsert {
6115                table: "tasks".to_owned(),
6116                values: Map::from_iter([
6117                    ("id".to_owned(), Value::from("t1")),
6118                    ("project_id".to_owned(), Value::from("one")),
6119                ]),
6120                base_version: None,
6121            }])
6122            .expect("local mutate");
6123
6124        let coverage = [WindowCoverage {
6125            base,
6126            units: vec!["one".to_owned(), "missing".to_owned()],
6127        }];
6128        let owner = client
6129            .query_snapshot(
6130                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
6131                &[],
6132                &coverage,
6133                None,
6134            )
6135            .expect("owner snapshot");
6136        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6137        let sidecar = reader
6138            .query_snapshot(
6139                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
6140                &[],
6141                &coverage,
6142            )
6143            .expect("sidecar snapshot");
6144
6145        assert_eq!(sidecar.revision, owner.revision);
6146        assert_eq!(sidecar.rows, owner.rows);
6147        assert_eq!(
6148            serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
6149            serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
6150        );
6151        assert_eq!(sidecar.revision, "2");
6152        assert_eq!(sidecar.rows[0]["id"], "t1");
6153        assert_eq!(sidecar.rows[0]["server_version"], -1);
6154        assert!(!sidecar.coverage.complete);
6155        assert_eq!(sidecar.coverage.pending.len(), 1);
6156        assert_eq!(sidecar.coverage.missing.len(), 1);
6157
6158        drop(reader);
6159        drop(client);
6160        std::fs::remove_file(path).expect("remove temp database");
6161    }
6162
6163    #[test]
6164    fn file_snapshot_reader_reports_catchup_delivery_and_multi_statement() {
6165        let path =
6166            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6167        let schema = json!({
6168            "version": 1,
6169            "tables": [{
6170                "name": "tasks",
6171                "primaryKey": "id",
6172                "columns": [
6173                    { "name": "id", "type": "string", "nullable": false },
6174                    { "name": "project_id", "type": "string", "nullable": false }
6175                ],
6176                "scopes": [{ "pattern": "project:{project_id}" }]
6177            }]
6178        });
6179        let mut client = SyncClient::open_path_with_identity(
6180            Some("sidecar-catchup".to_owned()),
6181            &schema,
6182            ClientLimits::default(),
6183            path.to_str().expect("UTF-8 temp path"),
6184        )
6185        .expect("open owner");
6186        client
6187            .subscribe(
6188                "s1".into(),
6189                "tasks".into(),
6190                vec![("project_id".into(), vec!["one".into()])],
6191                None,
6192            )
6193            .expect("subscribe");
6194        let commit_id = client
6195            .mutate(vec![Mutation::Upsert {
6196                table: "tasks".to_owned(),
6197                values: Map::from_iter([
6198                    ("id".to_owned(), Value::from("t1")),
6199                    ("project_id".to_owned(), Value::from("one")),
6200                ]),
6201                base_version: None,
6202            }])
6203            .expect("mutate");
6204        let revision = client.local_revision().to_string();
6205
6206        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6207        assert_eq!(reader.local_revision().expect("revision"), revision);
6208
6209        let read = reader
6210            .snapshot_read(&SnapshotReadRequest {
6211                statements: vec![
6212                    SnapshotStatement {
6213                        sql: "SELECT 1 AS n",
6214                        params: &[],
6215                    },
6216                    SnapshotStatement {
6217                        sql: "SELECT id FROM tasks ORDER BY id",
6218                        params: &[],
6219                    },
6220                ],
6221                coverage: &[],
6222                subscriptions: vec!["s1".to_owned(), "absent".to_owned()],
6223                commit_ids: vec![commit_id.clone(), "never-known".to_owned()],
6224            })
6225            .expect("snapshot read");
6226        assert_eq!(read.revision, revision);
6227        assert_eq!(read.queries.len(), 2);
6228        assert_eq!(read.queries[0][0]["n"], 1);
6229        assert_eq!(read.queries[1][0]["id"], "t1");
6230        match &read.subscriptions[0] {
6231            SubscriptionCatchup::Known {
6232                status,
6233                cursor,
6234                bootstrap_complete,
6235                known_pending_pages,
6236                ..
6237            } => {
6238                assert_eq!(status, "active");
6239                assert_eq!(*cursor, -1);
6240                assert!(!bootstrap_complete);
6241                assert!(known_pending_pages);
6242            }
6243            other => panic!("expected a known subscription: {other:?}"),
6244        }
6245        assert!(matches!(
6246            &read.subscriptions[1],
6247            SubscriptionCatchup::Unknown { id } if id.as_str() == "absent"
6248        ));
6249        assert!(matches!(
6250            &read.deliveries[0],
6251            CommitDelivery::Pending { client_commit_id } if client_commit_id == &commit_id
6252        ));
6253        assert!(matches!(
6254            &read.deliveries[1],
6255            CommitDelivery::Unknown { client_commit_id } if client_commit_id.as_str() == "never-known"
6256        ));
6257
6258        // A bad statement rolls the whole read back and releases the connection.
6259        assert!(reader
6260            .query_snapshot("SELECT * FROM does_not_exist", &[], &[])
6261            .is_err());
6262        assert_eq!(
6263            reader.local_revision().expect("revision after rollback"),
6264            revision
6265        );
6266        // The read-only guard refuses a write on any statement.
6267        assert!(reader
6268            .query_snapshot("DELETE FROM tasks", &[], &[])
6269            .is_err());
6270
6271        // A corrupt revision marker fails typed, not as an invented zero.
6272        client
6273            .conn
6274            .execute(
6275                "UPDATE _syncular_meta SET value = '+1' WHERE key = 'localRevision'",
6276                [],
6277            )
6278            .expect("corrupt revision");
6279        let failure = reader.local_revision().expect_err("corrupt revision fails");
6280        assert_eq!(failure.code, Some("sync.local_corrupt"));
6281
6282        drop(reader);
6283        drop(client);
6284        std::fs::remove_file(path).expect("remove temp database");
6285    }
6286
6287    #[test]
6288    fn snapshot_delivery_is_the_persisted_journal_view_without_retained_rows() {
6289        let path =
6290            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6291        let schema = json!({
6292            "version": 1,
6293            "tables": [{
6294                "name": "tasks",
6295                "primaryKey": "id",
6296                "columns": [
6297                    { "name": "id", "type": "string", "nullable": false },
6298                    { "name": "project_id", "type": "string", "nullable": false }
6299                ],
6300                "scopes": [{ "pattern": "project:{project_id}" }]
6301            }]
6302        });
6303        let mut client = SyncClient::open_path_with_identity(
6304            Some("sidecar-delivery".to_owned()),
6305            &schema,
6306            ClientLimits::default(),
6307            path.to_str().expect("UTF-8 temp path"),
6308        )
6309        .expect("open owner");
6310        client.begin_observation("test_delivery").expect("observe");
6311        let operations = [OutboxOp {
6312            upsert: true,
6313            table: "tasks".to_owned(),
6314            row_id: "t1".to_owned(),
6315            base_version: Some(1),
6316            values: Some(Map::from_iter([
6317                ("id".into(), json!("t1")),
6318                ("project_id".into(), json!("p1")),
6319            ])),
6320        }];
6321        client
6322            .persist_commit_outcome(
6323                "conflict-commit",
6324                CommitOutcomeStatus::Conflict,
6325                &[CommitOperationOutcome::Conflict {
6326                    conflict: ConflictRecord {
6327                        client_commit_id: "conflict-commit".to_owned(),
6328                        op_index: 0,
6329                        table: "tasks".to_owned(),
6330                        row_id: "t1".to_owned(),
6331                        code: "sync.version_conflict".to_owned(),
6332                        message: "stale base version".to_owned(),
6333                        server_version: 2,
6334                        server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
6335                        conflict_columns: vec!["project_id".to_owned()],
6336                        operation: None,
6337                    },
6338                }],
6339                Some(&operations),
6340            )
6341            .expect("persist conflict");
6342        client
6343            .persist_commit_outcome(
6344                "rejected-commit",
6345                CommitOutcomeStatus::Rejected,
6346                &[CommitOperationOutcome::Error {
6347                    rejection: RejectionRecord {
6348                        client_commit_id: "rejected-commit".to_owned(),
6349                        op_index: 0,
6350                        code: "sync.unknown_table".to_owned(),
6351                        message: "unknown table".to_owned(),
6352                        retryable: false,
6353                        details: None,
6354                        operation: None,
6355                    },
6356                }],
6357                Some(&operations),
6358            )
6359            .expect("persist rejection");
6360        client
6361            .finish_observation(
6362                "test_delivery",
6363                ChangeAccumulator {
6364                    conflicts: true,
6365                    outcomes: true,
6366                    ..ChangeAccumulator::default()
6367                },
6368            )
6369            .expect("commit outcomes");
6370
6371        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6372        match reader.commit_delivery("conflict-commit").expect("conflict") {
6373            CommitDelivery::Known { outcome, .. } => {
6374                assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
6375                assert!(matches!(
6376                    outcome.results.as_slice(),
6377                    [CommitOperationOutcome::Conflict { conflict }]
6378                        if conflict.code == "sync.version_conflict"
6379                            && conflict.server_version == 2
6380                ));
6381                assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
6382                assert!(
6383                    outcome.retained_rows.is_none(),
6384                    "the sidecar reports the persisted view, not owner-derived retained rows"
6385                );
6386            }
6387            other => panic!("expected a known conflict: {other:?}"),
6388        }
6389        match reader
6390            .commit_delivery("rejected-commit")
6391            .expect("rejection")
6392        {
6393            CommitDelivery::Known { outcome, .. } => {
6394                assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
6395                assert!(matches!(
6396                    outcome.results.as_slice(),
6397                    [CommitOperationOutcome::Error { rejection }]
6398                        if rejection.code == "sync.unknown_table"
6399                ));
6400                assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
6401                assert!(outcome.retained_rows.is_none());
6402            }
6403            other => panic!("expected a known rejection: {other:?}"),
6404        }
6405        drop(reader);
6406        drop(client);
6407        std::fs::remove_file(path).expect("remove temp database");
6408    }
6409
6410    #[test]
6411    fn snapshot_read_isolates_a_concurrent_writer_commit() {
6412        use std::sync::mpsc::channel;
6413        let path = std::env::temp_dir().join(format!(
6414            "syncular-read-concurrent-{}.db",
6415            uuid::Uuid::new_v4()
6416        ));
6417        let schema = json!({
6418            "version": 1,
6419            "tables": [{
6420                "name": "tasks",
6421                "primaryKey": "id",
6422                "columns": [
6423                    { "name": "id", "type": "string", "nullable": false },
6424                    { "name": "project_id", "type": "string", "nullable": false }
6425                ],
6426                "scopes": [{ "pattern": "project:{project_id}" }]
6427            }]
6428        });
6429        let mut client = SyncClient::open_path_with_identity(
6430            Some("sidecar-concurrent".to_owned()),
6431            &schema,
6432            ClientLimits::default(),
6433            path.to_str().expect("UTF-8 temp path"),
6434        )
6435        .expect("open owner");
6436        client
6437            .mutate(vec![Mutation::Upsert {
6438                table: "tasks".to_owned(),
6439                values: Map::from_iter([
6440                    ("id".to_owned(), Value::from("t1")),
6441                    ("project_id".to_owned(), Value::from("one")),
6442                ]),
6443                base_version: None,
6444            }])
6445            .expect("seed row");
6446        let revision_before = client.local_revision().to_string();
6447
6448        // A writer on a second connection commits while the reader is between
6449        // statements; the hook rendezvous is a barrier, not a sleep.
6450        let (ready_tx, ready_rx) = channel::<()>();
6451        let (done_tx, done_rx) = channel::<()>();
6452        let writer_path = path.to_string_lossy().into_owned();
6453        let writer_schema = schema.clone();
6454        let writer = std::thread::spawn(move || {
6455            let mut writer = SyncClient::open_path_with_identity(
6456                Some("sidecar-concurrent".to_owned()),
6457                &writer_schema,
6458                ClientLimits::default(),
6459                &writer_path,
6460            )
6461            .expect("open writer");
6462            ready_rx.recv().expect("reader signalled");
6463            writer
6464                .mutate(vec![Mutation::Upsert {
6465                    table: "tasks".to_owned(),
6466                    values: Map::from_iter([
6467                        ("id".to_owned(), Value::from("t2")),
6468                        ("project_id".to_owned(), Value::from("one")),
6469                    ]),
6470                    base_version: None,
6471                }])
6472                .expect("writer commit");
6473            let _ = done_tx.send(());
6474        });
6475        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6476        SNAPSHOT_READ_HOOK.with(|slot| {
6477            *slot.borrow_mut() = Some(Box::new(move || {
6478                ready_tx.send(()).expect("signal writer");
6479                done_rx.recv().expect("writer finished");
6480            }));
6481        });
6482        let read = reader
6483            .snapshot_read(&SnapshotReadRequest {
6484                statements: vec![
6485                    SnapshotStatement {
6486                        sql: "SELECT count(*) AS n FROM tasks",
6487                        params: &[],
6488                    },
6489                    SnapshotStatement {
6490                        sql: "SELECT count(*) AS n FROM tasks",
6491                        params: &[],
6492                    },
6493                ],
6494                coverage: &[],
6495                subscriptions: Vec::new(),
6496                commit_ids: Vec::new(),
6497            })
6498            .expect("snapshot read");
6499        assert_eq!(
6500            read.queries[0][0]["n"], 1,
6501            "first statement sees the pre-commit row"
6502        );
6503        assert_eq!(
6504            read.queries[1][0]["n"], 1,
6505            "a later statement keeps the one read snapshot"
6506        );
6507        assert_eq!(
6508            read.revision, revision_before,
6509            "the read reports the snapshot revision, not the writer's"
6510        );
6511        writer.join().expect("join writer");
6512
6513        assert_ne!(
6514            reader.local_revision().expect("revision after commit"),
6515            revision_before,
6516            "the next read observes the committed revision"
6517        );
6518        let after = reader
6519            .query_snapshot("SELECT count(*) AS n FROM tasks", &[], &[])
6520            .expect("count after commit");
6521        assert_eq!(
6522            after.rows[0]["n"], 2,
6523            "the committed row is visible afterwards"
6524        );
6525
6526        drop(reader);
6527        drop(client);
6528        std::fs::remove_file(path).expect("remove temp database");
6529    }
6530
6531    #[test]
6532    fn snapshot_read_rejects_every_non_canonical_revision() {
6533        let path = std::env::temp_dir().join(format!(
6534            "syncular-read-revision-{}.db",
6535            uuid::Uuid::new_v4()
6536        ));
6537        let schema = json!({
6538            "version": 1,
6539            "tables": [{
6540                "name": "tasks",
6541                "primaryKey": "id",
6542                "columns": [
6543                    { "name": "id", "type": "string", "nullable": false },
6544                    { "name": "project_id", "type": "string", "nullable": false }
6545                ],
6546                "scopes": [{ "pattern": "project:{project_id}" }]
6547            }]
6548        });
6549        let client = SyncClient::open_path_with_identity(
6550            Some("sidecar-revision".to_owned()),
6551            &schema,
6552            ClientLimits::default(),
6553            path.to_str().expect("UTF-8 temp path"),
6554        )
6555        .expect("open owner");
6556        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6557        for bad in [
6558            "+1",
6559            "01",
6560            "-1",
6561            " 1",
6562            "1 ",
6563            "18446744073709551616",
6564            "1.0",
6565            "abc",
6566            "",
6567        ] {
6568            client
6569                .conn
6570                .execute(
6571                    "UPDATE _syncular_meta SET value = ?1 WHERE key = 'localRevision'",
6572                    rusqlite::params![bad],
6573                )
6574                .expect("corrupt revision");
6575            let failure = reader.local_revision().expect_err("corrupt marker fails");
6576            assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad:?}");
6577        }
6578        for value in ["X'31'", "CAST(X'FF' AS TEXT)"] {
6579            client
6580                .conn
6581                .execute_batch(&format!(
6582                    "UPDATE _syncular_meta SET value={value} WHERE key='localRevision'"
6583                ))
6584                .expect("corrupt stored revision type");
6585            assert_eq!(
6586                reader.local_revision().expect_err("invalid SQL field").code,
6587                Some("sync.local_corrupt")
6588            );
6589        }
6590        // The absent marker is the legacy zero, not corruption.
6591        client
6592            .conn
6593            .execute("DELETE FROM _syncular_meta WHERE key = 'localRevision'", [])
6594            .expect("delete marker");
6595        assert_eq!(reader.local_revision().expect("legacy zero"), "0");
6596
6597        drop(reader);
6598        drop(client);
6599        std::fs::remove_file(path).expect("remove temp database");
6600    }
6601
6602    #[test]
6603    fn snapshot_read_rejects_corrupt_subscription_state() {
6604        let path = std::env::temp_dir().join(format!(
6605            "syncular-read-substate-{}.db",
6606            uuid::Uuid::new_v4()
6607        ));
6608        let schema = json!({
6609            "version": 1,
6610            "tables": [{
6611                "name": "tasks",
6612                "primaryKey": "id",
6613                "columns": [
6614                    { "name": "id", "type": "string", "nullable": false },
6615                    { "name": "project_id", "type": "string", "nullable": false }
6616                ],
6617                "scopes": [{ "pattern": "project:{project_id}" }]
6618            }]
6619        });
6620        let mut client = SyncClient::open_path_with_identity(
6621            Some("sidecar-substate".to_owned()),
6622            &schema,
6623            ClientLimits::default(),
6624            path.to_str().expect("UTF-8 temp path"),
6625        )
6626        .expect("open owner");
6627        client
6628            .subscribe(
6629                "s1".into(),
6630                "tasks".into(),
6631                vec![("project_id".into(), vec!["one".into()])],
6632                None,
6633            )
6634            .expect("subscribe");
6635        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6636        for bad in [
6637            "not json".to_owned(),
6638            "{}".to_owned(),
6639            json!({ "status": "bogus", "cursor": 0 }).to_string(),
6640            json!({ "status": "active", "cursor": "x" }).to_string(),
6641            json!({ "status": "active", "cursor": 0, "bootstrapState": 7 }).to_string(),
6642            json!({ "status": "active", "cursor": 0, "reasonCode": 7 }).to_string(),
6643        ] {
6644            client
6645                .conn
6646                .execute(
6647                    "UPDATE _syncular_subscriptions SET state_json = ?1 WHERE id = 's1'",
6648                    rusqlite::params![bad],
6649                )
6650                .expect("corrupt subscription state");
6651            let failure = reader
6652                .subscription_catchup("s1")
6653                .expect_err("corrupt state fails");
6654            assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad}");
6655        }
6656
6657        for assignment in [
6658            "tbl=X'7461736b73'",
6659            "state_json=X'7b7d'",
6660            "state_json=CAST(X'FF' AS TEXT)",
6661        ] {
6662            client.conn.execute_batch(&format!(
6663                "UPDATE _syncular_subscriptions SET tbl='tasks',state_json='{{\"status\":\"active\",\"cursor\":0}}'; UPDATE _syncular_subscriptions SET {assignment} WHERE id='s1'"
6664            )).expect("corrupt stored subscription type");
6665            assert_eq!(
6666                reader
6667                    .subscription_catchup("s1")
6668                    .expect_err("invalid SQL field")
6669                    .code,
6670                Some("sync.local_corrupt")
6671            );
6672        }
6673
6674        drop(reader);
6675        drop(client);
6676        std::fs::remove_file(path).expect("remove temp database");
6677    }
6678
6679    #[test]
6680    fn snapshot_read_rolls_back_a_later_invalid_statement_and_guards_every_write() {
6681        let path = std::env::temp_dir().join(format!(
6682            "syncular-read-rollback-{}.db",
6683            uuid::Uuid::new_v4()
6684        ));
6685        let schema = json!({
6686            "version": 1,
6687            "tables": [{
6688                "name": "tasks",
6689                "primaryKey": "id",
6690                "columns": [
6691                    { "name": "id", "type": "string", "nullable": false },
6692                    { "name": "project_id", "type": "string", "nullable": false }
6693                ],
6694                "scopes": [{ "pattern": "project:{project_id}" }]
6695            }]
6696        });
6697        let mut client = SyncClient::open_path_with_identity(
6698            Some("sidecar-rollback".to_owned()),
6699            &schema,
6700            ClientLimits::default(),
6701            path.to_str().expect("UTF-8 temp path"),
6702        )
6703        .expect("open owner");
6704        let commit_id = client
6705            .mutate(vec![Mutation::Upsert {
6706                table: "tasks".to_owned(),
6707                values: Map::from_iter([
6708                    ("id".to_owned(), Value::from("t1")),
6709                    ("project_id".to_owned(), Value::from("one")),
6710                ]),
6711                base_version: None,
6712            }])
6713            .expect("seed row");
6714        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6715        let revision = reader.local_revision().expect("revision");
6716
6717        // The second statement fails: the whole read rolls back and the
6718        // connection releases, so the same reader still works afterwards.
6719        let failure = reader
6720            .snapshot_read(&SnapshotReadRequest {
6721                statements: vec![
6722                    SnapshotStatement {
6723                        sql: "SELECT id FROM tasks",
6724                        params: &[],
6725                    },
6726                    SnapshotStatement {
6727                        sql: "SELECT * FROM does_not_exist",
6728                        params: &[],
6729                    },
6730                ],
6731                coverage: &[],
6732                subscriptions: Vec::new(),
6733                commit_ids: vec![commit_id.clone()],
6734            })
6735            .expect_err("second statement fails");
6736        assert_eq!(failure.code, None);
6737        assert_eq!(
6738            reader.local_revision().expect("revision after rollback"),
6739            revision,
6740            "a rolled-back read consumes no revision"
6741        );
6742        assert!(
6743            !matches!(
6744                reader
6745                    .commit_delivery(&commit_id)
6746                    .expect("delivery after rollback"),
6747                CommitDelivery::Unknown { .. }
6748            ),
6749            "the same reader serves the next read after releasing the connection"
6750        );
6751
6752        // The read-only guard rejects a write in the first position too.
6753        let guard = reader
6754            .snapshot_read(&SnapshotReadRequest {
6755                statements: vec![SnapshotStatement {
6756                    sql: "DELETE FROM tasks",
6757                    params: &[],
6758                }],
6759                coverage: &[],
6760                subscriptions: Vec::new(),
6761                commit_ids: Vec::new(),
6762            })
6763            .expect_err("write guard");
6764        assert!(guard.message.contains("read-only"), "{}", guard.message);
6765
6766        drop(reader);
6767        drop(client);
6768        std::fs::remove_file(path).expect("remove temp database");
6769    }
6770
6771    #[test]
6772    fn owned_query_failures_classify_order_bound_and_clear() {
6773        let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
6774            rusqlite::ffi::Error::new(266),
6775            Some("private SQLite prose".to_owned()),
6776        ));
6777        assert_eq!(io.code, Some("client.storage_io"));
6778        assert_eq!(io.sqlite_code, Some(266));
6779        assert_eq!(io.message, "local SQLite storage I/O failed");
6780        let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
6781            rusqlite::ffi::Error::new(26),
6782            None,
6783        ));
6784        assert_eq!(corrupt.code, Some("client.storage_corrupt"));
6785        assert!(!io.retryable());
6786        assert!(!corrupt.retryable());
6787
6788        // SQLITE_BUSY (5) and SQLITE_LOCKED (6), including extended codes, are
6789        // transient lock contention: classified busy and retryable.
6790        for (raw, extended) in [(5, 5), (5 | (1 << 8), 261), (6, 6), (6 | (1 << 8), 262)] {
6791            let busy = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
6792                rusqlite::ffi::Error::new(raw),
6793                Some("database is locked".to_owned()),
6794            ));
6795            assert_eq!(busy.code, Some("client.storage_busy"));
6796            assert_eq!(busy.sqlite_code, Some(extended));
6797            assert_eq!(busy.message, "local SQLite storage is busy");
6798            assert!(busy.retryable());
6799        }
6800        let unrelated = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
6801            rusqlite::ffi::Error::new(1),
6802            Some("generic SQLite prose".to_owned()),
6803        ));
6804        assert_eq!(unrelated.code, None);
6805        assert!(!unrelated.retryable());
6806
6807        let mut client = client();
6808        let generic = QueryReadFailure::from("private query prose".to_owned());
6809        client.record_query_read(
6810            QueryOwner {
6811                id: "first",
6812                tables: &["tasks", "tasks"],
6813            },
6814            Some(&generic),
6815        );
6816        client.record_query_read(
6817            QueryOwner {
6818                id: "second",
6819                tables: &["tasks"],
6820            },
6821            Some(&generic),
6822        );
6823        client.record_query_read(
6824            QueryOwner {
6825                id: "first",
6826                tables: &["docs"],
6827            },
6828            Some(&io),
6829        );
6830        let failures = &client
6831            .diagnostics_snapshot(&Default::default())
6832            .expect("diagnostics")
6833            .query_failures;
6834        assert_eq!(
6835            failures
6836                .iter()
6837                .map(|entry| entry.id.as_str())
6838                .collect::<Vec<_>>(),
6839            vec!["second", "first"]
6840        );
6841        assert_eq!(failures[1].tables, vec!["docs"]);
6842        assert_eq!(failures[1].code, "client.storage_io");
6843
6844        for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
6845            client.record_query_read(
6846                QueryOwner {
6847                    id: &format!("query-{index}"),
6848                    tables: &["tasks"],
6849                },
6850                Some(&generic),
6851            );
6852        }
6853        let failures = &client
6854            .diagnostics_snapshot(&Default::default())
6855            .expect("bounded diagnostics")
6856            .query_failures;
6857        assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
6858        assert_eq!(failures.last().expect("last failure").id, "query-256");
6859
6860        client
6861            .query_snapshot(
6862                "SELECT id FROM tasks",
6863                &[],
6864                &[],
6865                Some(QueryOwner {
6866                    id: "query-256",
6867                    tables: &["tasks"],
6868                }),
6869            )
6870            .expect("successful owned read");
6871        assert!(client
6872            .diagnostics_snapshot(&Default::default())
6873            .expect("cleared diagnostics")
6874            .query_failures
6875            .iter()
6876            .all(|entry| entry.id != "query-256"));
6877    }
6878
6879    #[test]
6880    fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
6881        let mut client = client();
6882        let image = Connection::open_in_memory().unwrap();
6883        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
6884            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
6885            INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
6886            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
6887            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
6888        let table = client.schema.table("tasks").unwrap().clone();
6889        let scopes = vec![("project_id".into(), vec!["p1".into()])];
6890        assert!(client
6891            .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
6892            .is_err());
6893        assert!(client.conn.is_autocommit());
6894        assert_eq!(
6895            client
6896                .query("SELECT count(*) AS n FROM tasks", &[])
6897                .unwrap()[0]["n"],
6898            1024
6899        );
6900        assert_eq!(client.drain_change_batches().len(), 1);
6901        image
6902            .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
6903            .unwrap();
6904        assert!(matches!(
6905            client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
6906            Ok(2050)
6907        ));
6908        assert_eq!(
6909            client
6910                .query("SELECT count(*) AS n FROM tasks", &[])
6911                .unwrap()[0]["n"],
6912            2050
6913        );
6914        assert_eq!(client.drain_change_batches().len(), 3);
6915    }
6916
6917    #[test]
6918    fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
6919        let mut client = client();
6920        let base = WindowBase {
6921            table: "tasks".into(),
6922            variable: "project_id".into(),
6923            fixed_scopes: Vec::new(),
6924            params: None,
6925        };
6926        client
6927            .set_window(&base, &["p1".into(), "p2".into()])
6928            .unwrap();
6929        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
6930            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
6931            INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
6932            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
6933            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
6934        client.drain_change_batches();
6935        assert!(client.set_window(&base, &["p2".into()]).is_err());
6936        assert!(client.conn.is_autocommit());
6937        assert_eq!(
6938            client
6939                .query("SELECT count(*) AS n FROM tasks", &[])
6940                .unwrap()[0]["n"],
6941            1027
6942        );
6943        assert_eq!(client.window_state(&base).units, vec!["p2"]);
6944        assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
6945        assert_eq!(client.drain_change_batches().len(), 1);
6946        client
6947            .conn
6948            .execute_batch("DROP TRIGGER interrupt_evict")
6949            .unwrap();
6950        client.drain_pending_evictions().unwrap();
6951        assert!(client.load_pending_evictions().unwrap().is_empty());
6952        assert_eq!(
6953            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
6954            "held"
6955        );
6956        assert_eq!(client.drain_change_batches().len(), 2);
6957    }
6958
6959    #[test]
6960    fn eviction_marker_failures_roll_back_window_changes() {
6961        let mut client = client();
6962        let base = WindowBase {
6963            table: "tasks".into(),
6964            variable: "project_id".into(),
6965            fixed_scopes: Vec::new(),
6966            params: None,
6967        };
6968        client.set_window(&base, &["p1".into()]).unwrap();
6969        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
6970            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
6971            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
6972            CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
6973        let revision = client.local_revision();
6974        assert!(client.set_window(&base, &[]).is_err());
6975        assert!(client.conn.is_autocommit());
6976        assert_eq!(client.local_revision(), revision);
6977        assert_eq!(
6978            client
6979                .query("SELECT count(*) AS n FROM tasks", &[])
6980                .unwrap()[0]["n"],
6981            2050
6982        );
6983        assert_eq!(client.window_state(&base).units, vec!["p1"]);
6984        client.conn.execute_batch("DROP TRIGGER fail_marker;
6985            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
6986        assert!(client.set_window(&base, &[]).is_err());
6987        client.conn.execute_batch("DROP TRIGGER interrupt_evict;
6988            CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
6989        assert!(client.drain_pending_evictions().is_err());
6990        assert!(client.conn.is_autocommit());
6991        assert_eq!(
6992            client
6993                .query("SELECT count(*) AS n FROM tasks", &[])
6994                .unwrap()[0]["n"],
6995            2
6996        );
6997        assert!(client.set_window(&base, &["p1".into()]).is_err());
6998        assert!(client.conn.is_autocommit());
6999        assert!(client.window_state(&base).units.is_empty());
7000        assert!(client.subs.is_empty());
7001        assert_eq!(
7002            client
7003                .query(
7004                    "SELECT count(*) AS n FROM _syncular_window_pending_evict",
7005                    &[]
7006                )
7007                .unwrap()[0]["n"],
7008            1
7009        );
7010        client
7011            .conn
7012            .execute_batch("DROP TRIGGER fail_marker")
7013            .unwrap();
7014        client.set_window(&base, &["p1".into()]).unwrap();
7015        assert_eq!(client.window_state(&base).units, vec!["p1"]);
7016    }
7017
7018    #[test]
7019    fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
7020        let mut client = client();
7021        client
7022            .conn
7023            .execute(
7024                "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
7025                [],
7026            )
7027            .unwrap();
7028        assert_eq!(
7029            client.drain_pending_evictions().unwrap_err(),
7030            "sync.local_corrupt: invalid pending eviction scopes"
7031        );
7032        assert!(client.conn.is_autocommit());
7033        assert!(client.enqueue_startup_sync_if_needed().is_err());
7034    }
7035
7036    #[test]
7037    fn image_chunks_reject_null_primary_keys() {
7038        let mut client = client();
7039        let image = Connection::open_in_memory().unwrap();
7040        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7041            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
7042            INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
7043            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7044            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
7045        let table = client.schema.table("tasks").unwrap().clone();
7046        assert!(client
7047            .apply_sqlite_image(
7048                &image,
7049                &table,
7050                true,
7051                &[("project_id".into(), vec!["p1".into()])],
7052                1025,
7053                7,
7054                "digest"
7055            )
7056            .is_err());
7057        assert!(client.conn.is_autocommit());
7058        assert_eq!(
7059            client
7060                .query("SELECT count(*) AS n FROM tasks", &[])
7061                .unwrap()[0]["n"],
7062            0
7063        );
7064    }
7065
7066    #[test]
7067    fn import_chunks_with_unique_constraints_match_full_pending_replay() {
7068        for images in [false, true] {
7069            let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
7070                "name":name,"primaryKey":"id","columns":[
7071                    {"name":"id","type":"string","nullable":false},
7072                    {"name":"project_id","type":"string","nullable":false},
7073                    {"name":"title","type":"string","nullable":false}],
7074                "scopes":[{"pattern":"project:{project_id}"}],
7075                "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
7076                "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
7077            })))});
7078            let mut actual =
7079                SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
7080            let mut reference =
7081                SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
7082            let scopes = vec![("project_id".into(), vec!["p1".into()])];
7083            for client in [&mut actual, &mut reference] {
7084                client
7085                    .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
7086                    .unwrap();
7087                client.subs[0].effective = Some(scopes.clone());
7088                for (table, id, title) in [
7089                    ("tasks", "a", "original"),
7090                    ("tasks", "b", "occupied"),
7091                    ("tasks", "c", "deleted"),
7092                    ("notes", "n", "untouched"),
7093                ] {
7094                    client
7095                        .write_base_row(
7096                            table,
7097                            &vec![
7098                                Some(ColumnValue::String(id.into())),
7099                                Some(ColumnValue::String("p1".into())),
7100                                Some(ColumnValue::String(title.into())),
7101                            ],
7102                            1,
7103                        )
7104                        .unwrap();
7105                }
7106                client.rebuild_overlay_if_dirty().expect("rebuild overlay");
7107                client
7108                    .mutate(vec![
7109                        Mutation::Upsert {
7110                            table: "tasks".into(),
7111                            values: Map::from_iter([
7112                                ("id".into(), Value::from("a")),
7113                                ("project_id".into(), Value::from("p1")),
7114                                ("title".into(), Value::from("available")),
7115                            ]),
7116                            base_version: None,
7117                        },
7118                        Mutation::Delete {
7119                            table: "tasks".into(),
7120                            row_id: "c".into(),
7121                            base_version: None,
7122                        },
7123                        Mutation::Upsert {
7124                            table: "tasks".into(),
7125                            values: Map::from_iter([
7126                                ("id".into(), Value::from("local")),
7127                                ("project_id".into(), Value::from("p1")),
7128                                ("title".into(), Value::from("local value")),
7129                            ]),
7130                            base_version: None,
7131                        },
7132                        Mutation::Upsert {
7133                            table: "notes".into(),
7134                            values: Map::from_iter([
7135                                ("id".into(), Value::from("n")),
7136                                ("project_id".into(), Value::from("p1")),
7137                                ("title".into(), Value::from("pending note")),
7138                            ]),
7139                            base_version: None,
7140                        },
7141                    ])
7142                    .unwrap();
7143            }
7144            let pending = actual.pending_commit_ids();
7145            actual.overlay_rebuild_count.set(0);
7146            for (id, title) in [
7147                ("b", "available"),
7148                ("b", "occupied"),
7149                ("a", "server original"),
7150                ("d", "local value"),
7151                ("d", "released"),
7152                ("c", "redelivered"),
7153            ] {
7154                let table = actual.schema.table("tasks").unwrap().clone();
7155                let values = vec![
7156                    Some(ColumnValue::String(id.into())),
7157                    Some(ColumnValue::String("p1".into())),
7158                    Some(ColumnValue::String(title.into())),
7159                ];
7160                if images {
7161                    let image = Connection::open_in_memory().unwrap();
7162                    image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7163                        CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7164                        INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
7165                    image
7166                        .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
7167                        .unwrap();
7168                    assert!(matches!(
7169                        actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
7170                        Ok(1)
7171                    ));
7172                } else {
7173                    let segment = RowsSegment {
7174                        table: "tasks".into(),
7175                        schema_version: 1,
7176                        columns: table.wire_columns.clone(),
7177                        blocks: vec![vec![ssp2::segment::SegmentRow {
7178                            server_version: 2,
7179                            values: values.clone(),
7180                        }]],
7181                    };
7182                    assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
7183                }
7184                reference.write_base_row("tasks", &values, 2).unwrap();
7185                reference.rebuild_overlay().expect("rebuild overlay");
7186                for table in ["tasks", "notes"] {
7187                    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")] {
7188                        assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
7189                    }
7190                }
7191                assert_eq!(actual.pending_commit_ids(), pending);
7192            }
7193            assert_eq!(
7194                actual.overlay_rebuild_count.get(),
7195                0,
7196                "each chunk must leave unrelated tables alone"
7197            );
7198            let before = actual
7199                .query("SELECT * FROM tasks ORDER BY id", &[])
7200                .unwrap();
7201            let fts_before = actual
7202                .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
7203                .unwrap();
7204            let revision = actual.local_revision();
7205            actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
7206            let table = actual.schema.table("tasks").unwrap().clone();
7207            let segment = RowsSegment {
7208                table: "tasks".into(),
7209                schema_version: 1,
7210                columns: table.wire_columns.clone(),
7211                blocks: vec![vec![ssp2::segment::SegmentRow {
7212                    server_version: 3,
7213                    values: vec![
7214                        Some(ColumnValue::String("b".into())),
7215                        Some(ColumnValue::String("p1".into())),
7216                        Some(ColumnValue::String("fresh".into())),
7217                    ],
7218                }]],
7219            };
7220            assert!(actual.apply_segment(0, &segment, false).is_err());
7221            assert!(actual.conn.is_autocommit());
7222            assert_eq!(
7223                actual
7224                    .query("SELECT * FROM tasks ORDER BY id", &[])
7225                    .unwrap(),
7226                before
7227            );
7228            assert_eq!(
7229                actual
7230                    .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
7231                    .unwrap(),
7232                fts_before
7233            );
7234            assert_eq!(actual.local_revision(), revision);
7235            assert_eq!(actual.pending_commit_ids(), pending);
7236        }
7237    }
7238
7239    #[test]
7240    fn import_reconciliation_preserves_typed_pending_primary_keys() {
7241        for (kind, a, b) in [
7242            ("string", json!("a"), json!("b")),
7243            ("integer", json!(1), json!(2)),
7244            ("boolean", json!(true), json!(false)),
7245            ("json", json!("1"), json!("2")),
7246        ] {
7247            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}]}]});
7248            for images in [false, true] {
7249                let mut client =
7250                    SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
7251                let table = client.schema.table("tasks").unwrap().clone();
7252                client
7253                    .subscribe("tasks".into(), "tasks".into(), vec![], None)
7254                    .unwrap();
7255                for (id, title) in [(&a, "original"), (&b, "occupied")] {
7256                    client
7257                        .write_base_row(
7258                            "tasks",
7259                            &vec![
7260                                json_to_column_value(&table.columns[0], Some(id)).unwrap(),
7261                                Some(ColumnValue::String(title.into())),
7262                            ],
7263                            1,
7264                        )
7265                        .unwrap();
7266                }
7267                client.rebuild_overlay_if_dirty().expect("rebuild overlay");
7268                client
7269                    .mutate(vec![Mutation::Upsert {
7270                        table: "tasks".into(),
7271                        values: Map::from_iter([
7272                            ("id".into(), a.clone()),
7273                            ("title".into(), json!("available")),
7274                        ]),
7275                        base_version: None,
7276                    }])
7277                    .unwrap();
7278                client.overlay_rebuild_count.set(0);
7279                for (title, expected) in [("available", "original"), ("occupied", "available")] {
7280                    let values = vec![
7281                        json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
7282                        Some(ColumnValue::String(title.into())),
7283                    ];
7284                    if images {
7285                        let image = Connection::open_in_memory().unwrap();
7286                        image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7287                            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7288                            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
7289                        image
7290                            .execute(
7291                                "INSERT INTO tasks VALUES (?1,?2,2)",
7292                                rusqlite::params![RowParam::Cell(&values[0]), title],
7293                            )
7294                            .unwrap();
7295                        assert!(
7296                            matches!(
7297                                client.apply_sqlite_image(
7298                                    &image,
7299                                    &table,
7300                                    false,
7301                                    &[],
7302                                    1,
7303                                    7,
7304                                    "digest"
7305                                ),
7306                                Ok(1)
7307                            ),
7308                            "{kind}, images={images}"
7309                        );
7310                    } else {
7311                        let segment = RowsSegment {
7312                            table: "tasks".into(),
7313                            schema_version: 1,
7314                            columns: table.wire_columns.clone(),
7315                            blocks: vec![vec![ssp2::segment::SegmentRow {
7316                                server_version: 2,
7317                                values,
7318                            }]],
7319                        };
7320                        assert!(
7321                            matches!(client.apply_segment(0, &segment, false), Ok(1)),
7322                            "{kind}"
7323                        );
7324                    }
7325                    let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
7326                    let actual: String = client
7327                        .conn
7328                        .query_row(
7329                            "SELECT title FROM tasks WHERE id=?1",
7330                            [RowParam::Cell(&key)],
7331                            |row| row.get(0),
7332                        )
7333                        .unwrap();
7334                    assert_eq!(actual, expected, "{kind}, images={images}");
7335                }
7336                assert_eq!(client.overlay_rebuild_count.get(), 0);
7337            }
7338        }
7339    }
7340
7341    fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
7342        let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
7343            "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
7344            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
7345            "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
7346        })))});
7347        let mut client =
7348            SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
7349        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();
7350        client.rebuild_overlay().expect("rebuild overlay");
7351        client
7352            .mutate(vec![Mutation::Upsert {
7353                table: "tasks".into(),
7354                values: Map::from_iter([
7355                    ("id".into(), json!("offline")),
7356                    ("project_id".into(), json!("p1")),
7357                    ("title".into(), json!("pending edit")),
7358                ]),
7359                base_version: None,
7360            }])
7361            .unwrap();
7362        let image = Connection::open_in_memory().unwrap();
7363        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7364            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
7365        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();
7366        image
7367            .execute(
7368                "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
7369                [rows],
7370            )
7371            .unwrap();
7372        client.overlay_rebuild_count.set(0);
7373        let table = client.schema.table("tasks").unwrap().clone();
7374        (client, image, table)
7375    }
7376
7377    #[test]
7378    fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
7379        let (mut client, image, table) = unique_import_fixture(1);
7380        image
7381            .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
7382            .unwrap();
7383        client
7384            .conn
7385            .execute_batch("PRAGMA max_page_count = 1")
7386            .unwrap();
7387        let effective = [("project_id".into(), vec!["p1".into()])];
7388        assert!(client
7389            .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
7390            .is_err());
7391        let failure = client.storage_failure.borrow().clone().unwrap();
7392        assert_eq!(failure.code, Some("client.storage_full"));
7393        assert_eq!(failure.sqlite_code, Some(13));
7394        assert!(!failure.message.contains("rollback"));
7395        assert!(client.conn.is_autocommit());
7396        client
7397            .conn
7398            .execute_batch("PRAGMA max_page_count = 1073741823")
7399            .unwrap();
7400        assert!(matches!(
7401            client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
7402            Ok(1)
7403        ));
7404        assert!(client.conn.is_autocommit());
7405    }
7406
7407    #[test]
7408    fn authoring_storage_failures_are_structured_and_scoped() {
7409        let path = std::env::temp_dir().join(format!(
7410            "syncular-authoring-failure-{}.db",
7411            uuid::Uuid::new_v4()
7412        ));
7413        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
7414            {"name":"id","type":"string","nullable":false},
7415            {"name":"project_id","type":"string","nullable":false},
7416            {"name":"title","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
7417        let mut client = SyncClient::open_path(
7418            "authoring".into(),
7419            &schema,
7420            ClientLimits::default(),
7421            path.to_str().unwrap(),
7422        )
7423        .expect("open client");
7424        client.create_synced_tables().expect("create synced tables");
7425        // Return BUSY immediately rather than waiting out the file timeout.
7426        client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
7427
7428        let upsert = |id: &str| {
7429            vec![Mutation::Upsert {
7430                table: "tasks".into(),
7431                values: Map::from_iter([
7432                    ("id".into(), json!(id)),
7433                    ("project_id".into(), json!("p1")),
7434                    ("title".into(), json!("first")),
7435                ]),
7436                base_version: None,
7437            }]
7438        };
7439
7440        let locker = Connection::open(&path).expect("second connection");
7441        locker.execute_batch("BEGIN IMMEDIATE").unwrap();
7442        let busy = client.mutate(upsert("t1")).expect_err("busy write");
7443        assert_eq!(busy.code, "client.storage_busy");
7444        assert!(busy.retryable);
7445        assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
7446        assert!(client.pending_commit_ids().is_empty());
7447        assert_eq!(client.local_revision(), 0);
7448
7449        // A stale classified failure must not classify a later unrelated one.
7450        let invalid = client.mutate(Vec::new()).expect_err("empty commit");
7451        assert_eq!(invalid.code, "sync.invalid_request");
7452        assert_eq!(invalid.message, "the authoring request is invalid");
7453        assert!(
7454            invalid.details.as_ref().unwrap()["legacyCause"]
7455                .as_str()
7456                .unwrap()
7457                .contains("at least one operation"),
7458            "{invalid:?}"
7459        );
7460
7461        locker.execute_batch("ROLLBACK").unwrap();
7462        let id = client.mutate(upsert("t1")).expect("retry after busy");
7463        assert_eq!(client.pending_commit_ids(), vec![id]);
7464        assert_eq!(client.local_revision(), 1);
7465
7466        // A later storage failure reports its own code and details.
7467        locker.execute_batch("BEGIN IMMEDIATE").unwrap();
7468        let busy_again = client.mutate(upsert("t2")).expect_err("busy write again");
7469        assert_eq!(busy_again.code, "client.storage_busy");
7470        assert!(busy_again.retryable);
7471        locker.execute_batch("ROLLBACK").unwrap();
7472
7473        // Non-retryable classified failures keep their sqlite evidence too.
7474        let full = ClientError::from(QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7475            rusqlite::ffi::Error::new(13),
7476            Some("database or disk is full".to_owned()),
7477        )));
7478        assert_eq!(full.code, "client.storage_full");
7479        assert!(!full.retryable);
7480        assert_eq!(full.details.as_ref().unwrap()["sqliteCode"], 13);
7481
7482        drop(locker);
7483        drop(client);
7484        let _ = std::fs::remove_file(&path);
7485    }
7486
7487    #[test]
7488    fn caller_value_failures_are_invalid_request_and_leave_no_outbox_entry() {
7489        let path = std::env::temp_dir().join(format!(
7490            "syncular-caller-values-{}.db",
7491            uuid::Uuid::new_v4()
7492        ));
7493        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
7494            {"name":"id","type":"string","nullable":false},
7495            {"name":"project_id","type":"string","nullable":false},
7496            {"name":"title","type":"string","nullable":false},
7497            {"name":"priority","type":"integer","nullable":false},
7498            {"name":"payload","type":"bytes","nullable":false},
7499            {"name":"extra","type":"bytes","nullable":true}],"scopes":[{"pattern":"project:{project_id}"}]}]});
7500        let mut client = SyncClient::open_path(
7501            "caller-values".into(),
7502            &schema,
7503            ClientLimits::default(),
7504            path.to_str().unwrap(),
7505        )
7506        .expect("open client");
7507        client.create_synced_tables().expect("create synced tables");
7508
7509        let values = |id: &str| {
7510            Map::from_iter([
7511                ("id".to_owned(), json!(id)),
7512                ("project_id".to_owned(), json!("p1")),
7513                ("title".to_owned(), json!("first")),
7514                ("priority".to_owned(), json!(1)),
7515                ("payload".to_owned(), json!({"$bytes": "0a"})),
7516            ])
7517        };
7518        let upsert = |values: Map<String, Value>| {
7519            vec![Mutation::Upsert {
7520                table: "tasks".into(),
7521                values,
7522                base_version: None,
7523            }]
7524        };
7525        let seed = client.mutate(upsert(values("t1"))).expect("seed row");
7526        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
7527
7528        // §6.1/§7.1: every caller value the authoring seam rejects reports the
7529        // same stable identity instead of the internal-failure default, keeps
7530        // the dynamic cause in `details.legacyCause`, and leaves the durable
7531        // outbox, the local revision and the projection untouched.
7532        for (label, override_value, cause) in [
7533            ("unknown column", ("nope", json!(1)), "unknown column"),
7534            (
7535                "internal sync column",
7536                ("_sync_version", json!(1)),
7537                "internal sync column",
7538            ),
7539            ("wrong value type", ("title", json!(7)), "expected a string"),
7540            (
7541                "malformed bytes envelope",
7542                ("payload", json!({"$bytes": "zz"})),
7543                "expected exactly",
7544            ),
7545            (
7546                "odd-length bytes envelope",
7547                ("payload", json!({"$bytes": "abc"})),
7548                "expected exactly",
7549            ),
7550            (
7551                "plus-sign bytes envelope",
7552                ("payload", json!({"$bytes": "+a"})),
7553                "expected exactly",
7554            ),
7555            (
7556                "bytes envelope with a second key",
7557                ("payload", json!({"$bytes": "0a", "extra": 1})),
7558                "expected exactly",
7559            ),
7560            (
7561                "no bytes envelope",
7562                ("payload", json!({"nested": true})),
7563                "expected exactly",
7564            ),
7565        ] {
7566            let mut full = values("t2");
7567            full.insert(override_value.0.to_owned(), override_value.1);
7568            let error = client.mutate(upsert(full)).expect_err(label);
7569            assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
7570            assert_eq!(error.message, "the authoring request is invalid", "{label}");
7571            assert!(!error.retryable, "{label}");
7572            let legacy = error.details.as_ref().unwrap()["legacyCause"]
7573                .as_str()
7574                .unwrap();
7575            assert!(
7576                legacy.starts_with("sync.invalid_request: ") && legacy.contains(cause),
7577                "{label}: {legacy}"
7578            );
7579            assert_eq!(client.pending_commit_ids(), vec![seed.clone()], "{label}");
7580            assert_eq!(client.local_revision(), 1, "{label}");
7581            assert_eq!(
7582                client.query("SELECT * FROM tasks", &[]).unwrap(),
7583                rows,
7584                "{label}"
7585            );
7586        }
7587
7588        // A full-row upsert presents every column, so a missing required one is
7589        // the caller's failure too.
7590        let mut missing = values("t3");
7591        missing.remove("payload");
7592        let missing_error = client.mutate(upsert(missing)).expect_err("missing column");
7593        assert_eq!(missing_error.code, "sync.invalid_request");
7594        assert!(missing_error.details.as_ref().unwrap()["legacyCause"]
7595            .as_str()
7596            .unwrap()
7597            .contains("is not nullable"));
7598
7599        // A primary key the wire cannot render is rejected before any op is
7600        // recorded.
7601        let mut bad_row_id = values("t4");
7602        bad_row_id.insert("id".to_owned(), json!({"nested": true}));
7603        let row_id_error = client.mutate(upsert(bad_row_id)).expect_err("bad rowId");
7604        assert_eq!(row_id_error.code, "sync.invalid_request");
7605
7606        // The sparse route classifies the same value failures.
7607        for (label, partial, cause) in [
7608            (
7609                "patch wrong value type",
7610                ("priority", json!("high")),
7611                "expected an integer",
7612            ),
7613            ("patch unknown column", ("nope", json!(1)), "unknown column"),
7614        ] {
7615            let error = client
7616                .patch(
7617                    "tasks",
7618                    "t1",
7619                    Map::from_iter([(partial.0.to_owned(), partial.1)]),
7620                    None,
7621                )
7622                .expect_err(label);
7623            assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
7624            assert_eq!(error.message, "the authoring request is invalid", "{label}");
7625            assert!(
7626                error.details.as_ref().unwrap()["legacyCause"]
7627                    .as_str()
7628                    .unwrap()
7629                    .contains(cause),
7630                "{label}"
7631            );
7632        }
7633        assert_eq!(client.pending_commit_ids(), vec![seed.clone()]);
7634        assert_eq!(client.local_revision(), 1);
7635        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
7636
7637        // Nullable explicit null, an empty envelope, and uppercase digits stay
7638        // accepted; the stored payload is the decoded byte sequence.
7639        for (label, payload) in [
7640            ("explicit null", json!(null)),
7641            ("empty envelope", json!({"$bytes": ""})),
7642            ("uppercase envelope", json!({"$bytes": "0AFF"})),
7643        ] {
7644            let mut accepted = values("t-ok");
7645            accepted.insert("extra".to_owned(), payload);
7646            let encoded = client.mutate(upsert(accepted)).unwrap_or_else(|error| {
7647                panic!("{label} must be accepted: {error:?}");
7648            });
7649            assert!(!encoded.is_empty(), "{label}");
7650        }
7651
7652        // Nothing is poisoned: the next valid authoring call succeeds.
7653        let next = client
7654            .mutate(upsert(values("t5")))
7655            .expect("valid after failures");
7656        assert_ne!(next, seed);
7657
7658        drop(client);
7659        let _ = std::fs::remove_file(&path);
7660    }
7661
7662    #[test]
7663    fn a_legacy_outbox_value_the_codec_refuses_is_dropped_instead_of_wedging() {
7664        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
7665            {"name":"id","type":"string","nullable":false},
7666            {"name":"project_id","type":"string","nullable":false},
7667            {"name":"payload","type":"bytes","nullable":true}],
7668            "scopes":[{"pattern":"project:{project_id}"}]}]});
7669        let open = |path: &std::path::Path| {
7670            SyncClient::open_path(
7671                "legacy-values".into(),
7672                &schema,
7673                ClientLimits::default(),
7674                path.to_str().unwrap(),
7675            )
7676            .expect("open client")
7677        };
7678        let row = |id: &str| {
7679            Map::from_iter([
7680                ("id".to_owned(), json!(id)),
7681                ("project_id".to_owned(), json!("p1")),
7682                ("payload".to_owned(), json!({"$bytes": "0a"})),
7683            ])
7684        };
7685        let upsert = |values| {
7686            vec![Mutation::Upsert {
7687                table: "tasks".into(),
7688                values,
7689                base_version: None,
7690            }]
7691        };
7692        for (label, stored_cell, representable) in [
7693            ("plus-sign envelope", json!({"$bytes": "+a"}), false),
7694            ("non-hex envelope", json!({"$bytes": "zz"}), false),
7695            ("odd-length envelope", json!({"$bytes": "abc"}), false),
7696            ("no envelope", json!({"nested": true}), false),
7697            // A wrong declared type and a stored null for a non-nullable column
7698            // are journalable as stored, so their rejection keeps the operation
7699            // envelope.
7700            ("wrong declared type", json!("high"), true),
7701            ("stored null for a non-nullable column", json!(null), true),
7702        ] {
7703            let path = std::env::temp_dir().join(format!(
7704                "syncular-legacy-values-{}-{}.db",
7705                label.replace(' ', "-"),
7706                uuid::Uuid::new_v4()
7707            ));
7708            let mut client = open(&path);
7709            client.create_synced_tables().expect("create synced tables");
7710            let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
7711            let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
7712            drop(client);
7713
7714            // The stored intent an earlier version accepted.
7715            let connection = Connection::open(&path).expect("raw connection");
7716            let (column, cell) = if stored_cell.is_null() {
7717                ("project_id", stored_cell.clone())
7718            } else {
7719                ("payload", stored_cell.clone())
7720            };
7721            let mut planted_values = json!({
7722                "id": "t1", "project_id": "p1", "payload": {"$bytes": "0a"}
7723            });
7724            planted_values[column] = cell;
7725            let planted = json!([{
7726                "op": "upsert",
7727                "table": "tasks",
7728                "rowId": "t1",
7729                "baseVersion": null,
7730                "values": planted_values
7731            }]);
7732            connection
7733                .execute(
7734                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
7735                    rusqlite::params![planted.to_string(), stale],
7736                )
7737                .expect("plant legacy intent");
7738            drop(connection);
7739
7740            // Reopen: the startup reconciliation recovers the commit before any
7741            // replay, so the observable phase matches the browser core.
7742            let reopened = open(&path);
7743            assert_eq!(
7744                reopened.pending_commit_ids(),
7745                vec![successor.clone()],
7746                "{label}"
7747            );
7748            let rejection = reopened
7749                .rejections()
7750                .iter()
7751                .find(|rejection| rejection.client_commit_id == stale)
7752                .unwrap_or_else(|| panic!("{label}: rejection"));
7753            assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
7754            assert_eq!(
7755                rejection.message, "the persisted commit carries values the current codec refuses",
7756                "{label}"
7757            );
7758            assert_eq!(rejection.operation.is_some(), representable, "{label}");
7759            assert_eq!(
7760                rejection
7761                    .details
7762                    .as_ref()
7763                    .and_then(|details| details.reason.as_deref()),
7764                Some(INVALID_STORED_VALUES_REASON),
7765                "{label}"
7766            );
7767            // The rejection stays readable after a further reopen.
7768            let journal = reopened
7769                .commit_outcome(&stale)
7770                .expect("read outcome")
7771                .expect("journal entry");
7772            assert!(matches!(journal.status, CommitOutcomeStatus::Rejected));
7773            drop(reopened);
7774            let mut reopened = open(&path);
7775            let reread = reopened
7776                .commit_outcome(&stale)
7777                .expect("read outcome")
7778                .expect("journal entry");
7779            assert!(matches!(reread.status, CommitOutcomeStatus::Rejected));
7780
7781            // The legacy row's optimistic projection is gone, the successor
7782            // survives and still encodes, and the round path keeps the queue.
7783            assert_eq!(
7784                reopened
7785                    .query("SELECT id FROM tasks ORDER BY id", &[])
7786                    .unwrap(),
7787                vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
7788                "{label}"
7789            );
7790            assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
7791            assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
7792            assert!(!reopened
7793                .drop_unencodable_outbox(false, None)
7794                .expect("idempotent prepass"));
7795            let mut transport = CountingRealtimeTransport::default();
7796            let outcome = reopened.sync(&mut transport);
7797            assert!(matches!(outcome, SyncOutcome::Failed { .. }), "{outcome:?}");
7798            assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
7799            let next = reopened.mutate(upsert(row("t3"))).expect("after the drop");
7800            assert_eq!(reopened.pending_commit_ids(), vec![successor, next]);
7801            assert_eq!(reopened.pending_payloads().len(), 2, "{label}");
7802            drop(reopened);
7803            let _ = std::fs::remove_file(&path);
7804        }
7805    }
7806
7807    #[test]
7808    fn two_legacy_values_on_the_same_row_recover_in_fifo_order() {
7809        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
7810            {"name":"id","type":"string","nullable":false},
7811            {"name":"project_id","type":"string","nullable":false},
7812            {"name":"payload","type":"bytes","nullable":true}],
7813            "scopes":[{"pattern":"project:{project_id}"}]}]});
7814        for (label, column, planted) in [
7815            ("malformed envelope", "payload", json!({"$bytes": "zz"})),
7816            (
7817                "stored null for a non-nullable column",
7818                "project_id",
7819                json!(null),
7820            ),
7821            ("wrong declared type", "payload", json!(7)),
7822        ] {
7823            let path = std::env::temp_dir().join(format!(
7824                "syncular-shared-row-{}-{}.db",
7825                label.replace(' ', "-"),
7826                uuid::Uuid::new_v4()
7827            ));
7828            let open = |path: &std::path::Path| {
7829                SyncClient::open_path(
7830                    "shared-row".into(),
7831                    &schema,
7832                    ClientLimits::default(),
7833                    path.to_str().unwrap(),
7834                )
7835                .expect("open client")
7836            };
7837            let row = |id: &str| {
7838                Map::from_iter([
7839                    ("id".to_owned(), json!(id)),
7840                    ("project_id".to_owned(), json!("p1")),
7841                    ("payload".to_owned(), json!({"$bytes": "0a"})),
7842                ])
7843            };
7844            let upsert = |values| {
7845                vec![Mutation::Upsert {
7846                    table: "tasks".into(),
7847                    values,
7848                    base_version: None,
7849                }]
7850            };
7851            let mut client = open(&path);
7852            client.create_synced_tables().expect("create synced tables");
7853            let older = client.mutate(upsert(row("t1"))).expect("older commit");
7854            let newer = client.mutate(upsert(row("t1"))).expect("newer commit");
7855            let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
7856            drop(client);
7857
7858            let connection = Connection::open(&path).expect("raw connection");
7859            for commit_id in [&older, &newer] {
7860                let mut values = row("t1");
7861                values.insert(column.to_owned(), planted.clone());
7862                let planted_ops = json!([{
7863                    "op": "upsert",
7864                    "table": "tasks",
7865                    "rowId": "t1",
7866                    "baseVersion": null,
7867                    "values": values
7868                }]);
7869                connection
7870                    .execute(
7871                        "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
7872                        rusqlite::params![planted_ops.to_string(), commit_id],
7873                    )
7874                    .expect("plant legacy intent");
7875            }
7876            drop(connection);
7877
7878            // Startup recovery drops both, FIFO, and publishes one batch.
7879            let mut reopened = open(&path);
7880            assert_eq!(
7881                reopened.pending_commit_ids(),
7882                vec![successor.clone()],
7883                "{label}"
7884            );
7885            assert_eq!(
7886                reopened
7887                    .rejections()
7888                    .iter()
7889                    .map(|rejection| rejection.client_commit_id.clone())
7890                    .collect::<Vec<_>>(),
7891                vec![older.clone(), newer.clone()],
7892                "{label}"
7893            );
7894            assert!(
7895                reopened
7896                    .rejections()
7897                    .iter()
7898                    .all(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE
7899                        && rejection.details.as_ref().and_then(|d| d.reason.as_deref())
7900                            == Some(INVALID_STORED_VALUES_REASON)),
7901                "{label}"
7902            );
7903            for commit_id in [&older, &newer] {
7904                assert!(
7905                    matches!(
7906                        reopened
7907                            .commit_outcome(commit_id)
7908                            .expect("outcome")
7909                            .map(|o| o.status),
7910                        Some(CommitOutcomeStatus::Rejected)
7911                    ),
7912                    "{label}"
7913                );
7914            }
7915            assert_eq!(
7916                reopened
7917                    .query("SELECT id FROM tasks ORDER BY id", &[])
7918                    .unwrap(),
7919                vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
7920                "{label}"
7921            );
7922            let revision = reopened.local_revision();
7923            let batches = reopened.drain_change_batches();
7924            assert!(
7925                batches.iter().any(|batch| batch.rejections_changed
7926                    && batch.outcomes_changed
7927                    && batch.tables.iter().any(|change| change.table == "tasks")
7928                    && batch.revision == revision.to_string()),
7929                "{label}: {batches:?}"
7930            );
7931            drop(reopened);
7932
7933            // An ordinary reopen with nothing to recover publishes nothing and
7934            // leaves the revision alone.
7935            let mut stable = open(&path);
7936            assert_eq!(stable.local_revision(), revision, "{label}");
7937            assert!(stable.drain_change_batches().is_empty(), "{label}");
7938            assert_eq!(stable.pending_commit_ids(), vec![successor], "{label}");
7939            drop(stable);
7940            let _ = std::fs::remove_file(&path);
7941        }
7942    }
7943
7944    #[test]
7945    fn recovery_omits_a_legacy_operation_the_journal_reader_rejects() {
7946        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
7947            {"name":"id","type":"string","nullable":false},
7948            {"name":"project_id","type":"string","nullable":false},
7949            {"name":"payload","type":"bytes","nullable":true}],
7950            "scopes":[{"pattern":"project:{project_id}"}]}]});
7951        for (label, second_op) in [
7952            (
7953                "delete carrying values",
7954                json!({"op": "delete", "table": "tasks", "rowId": "t9", "values": {}}),
7955            ),
7956            (
7957                "baseVersion beyond the safe bound",
7958                json!({"op": "upsert", "table": "tasks", "rowId": "t9",
7959                    "baseVersion": MAX_JS_SAFE_INTEGER + 1,
7960                    "values": {"id": "t9", "project_id": "p1", "payload": {"$bytes": "0a"}}}),
7961            ),
7962        ] {
7963            let path = std::env::temp_dir().join(format!(
7964                "syncular-journal-shape-{}-{}.db",
7965                label.replace(' ', "-"),
7966                uuid::Uuid::new_v4()
7967            ));
7968            let open = |path: &std::path::Path| {
7969                SyncClient::open_path(
7970                    "journal-shape".into(),
7971                    &schema,
7972                    ClientLimits::default(),
7973                    path.to_str().unwrap(),
7974                )
7975                .expect("open client")
7976            };
7977            let values = |id: &str| {
7978                Map::from_iter([
7979                    ("id".to_owned(), json!(id)),
7980                    ("project_id".to_owned(), json!("p1")),
7981                    ("payload".to_owned(), json!({"$bytes": "0a"})),
7982                ])
7983            };
7984            let upsert = |values| {
7985                vec![Mutation::Upsert {
7986                    table: "tasks".into(),
7987                    values,
7988                    base_version: None,
7989                }]
7990            };
7991            let mut client = open(&path);
7992            client.create_synced_tables().expect("create synced tables");
7993            let stale = client.mutate(upsert(values("t1"))).expect("stale commit");
7994            drop(client);
7995
7996            let connection = Connection::open(&path).expect("raw connection");
7997            // The first operation triggers the drop with canonical cells (a
7998            // stored null for a non-nullable column); the second one is the
7999            // operation shape the journal reader refuses.
8000            let planted = json!([
8001                {
8002                    "op": "upsert",
8003                    "table": "tasks",
8004                    "rowId": "t1",
8005                    "baseVersion": null,
8006                    "values": {"id": "t1", "project_id": null,
8007                        "payload": {"$bytes": "0a"}}
8008                },
8009                second_op
8010            ]);
8011            connection
8012                .execute(
8013                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8014                    rusqlite::params![planted.to_string(), stale],
8015                )
8016                .expect("plant legacy intent");
8017            drop(connection);
8018
8019            let reopened = open(&path);
8020            let journal = reopened
8021                .commit_outcome(&stale)
8022                .unwrap_or_else(|error| panic!("{label}: journal read: {error}"))
8023                .unwrap_or_else(|| panic!("{label}: journal entry"));
8024            assert!(
8025                matches!(journal.status, CommitOutcomeStatus::Rejected),
8026                "{label}"
8027            );
8028            assert!(journal.operations.is_none(), "{label}");
8029            assert!(
8030                reopened
8031                    .commit_outcomes(CommitOutcomeQuery::default())
8032                    .is_ok(),
8033                "{label}: outcome list query"
8034            );
8035            assert!(reopened.pending_commit_ids().is_empty(), "{label}");
8036            drop(reopened);
8037            let _ = std::fs::remove_file(&path);
8038        }
8039    }
8040
8041    #[test]
8042    fn legacy_values_recover_at_the_recreate_and_bump_reset_boundaries() {
8043        let schema = |version: i32, extra: bool| {
8044            let mut columns = vec![
8045                json!({"name":"id","type":"string","nullable":false}),
8046                json!({"name":"project_id","type":"string","nullable":false}),
8047                json!({"name":"payload","type":"bytes","nullable":true}),
8048            ];
8049            if extra {
8050                columns.push(json!({"name":"later","type":"string","nullable":true}));
8051            }
8052            json!({"version":version,"tables":[{"name":"tasks","primaryKey":"id","columns":columns,
8053                "scopes":[{"pattern":"project:{project_id}"}]}]})
8054        };
8055        for (label, bump) in [
8056            ("same-version recreation", false),
8057            ("version-bump reset", true),
8058            ("version-only bump reset", true),
8059        ] {
8060            let path = std::env::temp_dir().join(format!(
8061                "syncular-legacy-reset-{}-{}.db",
8062                label.replace(' ', "-"),
8063                uuid::Uuid::new_v4()
8064            ));
8065            let v1 = schema(1, false);
8066            let open = |schema: &Value, path: &std::path::Path| {
8067                SyncClient::open_path(
8068                    "legacy-reset".into(),
8069                    schema,
8070                    ClientLimits::default(),
8071                    path.to_str().unwrap(),
8072                )
8073                .expect("open client")
8074            };
8075            let row = |id: &str| {
8076                Map::from_iter([
8077                    ("id".to_owned(), json!(id)),
8078                    ("project_id".to_owned(), json!("p1")),
8079                    ("payload".to_owned(), json!({"$bytes": "0a"})),
8080                ])
8081            };
8082            let upsert = |values| {
8083                vec![Mutation::Upsert {
8084                    table: "tasks".into(),
8085                    values,
8086                    base_version: None,
8087                }]
8088            };
8089            let mut client = open(&v1, &path);
8090            client.create_synced_tables().expect("create synced tables");
8091            let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
8092            let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8093            drop(client);
8094
8095            let connection = Connection::open(&path).expect("raw connection");
8096            let planted = json!([{
8097                "op": "upsert",
8098                "table": "tasks",
8099                "rowId": "t1",
8100                "baseVersion": null,
8101                "values": {"id": "t1", "project_id": 7, "payload": {"$bytes": "0a"}}
8102            }]);
8103            connection
8104                .execute(
8105                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8106                    rusqlite::params![planted.to_string(), stale],
8107                )
8108                .expect("plant legacy intent");
8109            drop(connection);
8110
8111            let target = if bump {
8112                schema(2, label == "version-bump reset")
8113            } else {
8114                v1.clone()
8115            };
8116            let reopened = open(&target, &path);
8117            let rejection = reopened
8118                .rejections()
8119                .iter()
8120                .find(|rejection| rejection.client_commit_id == stale)
8121                .unwrap_or_else(|| panic!("{label}: rejection"));
8122            assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8123            assert_eq!(
8124                rejection
8125                    .details
8126                    .as_ref()
8127                    .and_then(|details| details.reason.as_deref()),
8128                Some(INVALID_STORED_VALUES_REASON),
8129                "{label}"
8130            );
8131            assert!(rejection.operation.is_some(), "{label}");
8132            assert_eq!(
8133                reopened.pending_commit_ids(),
8134                vec![successor.clone()],
8135                "{label}"
8136            );
8137            // The successor survives and still encodes. A bump that adds a
8138            // column leaves the older full-row upsert partial over the empty
8139            // base, so its visible row appears when the server answers it
8140            // (§7.1); the same-version recreation replays it locally.
8141            assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
8142            if label == "version-bump reset" {
8143                assert_eq!(
8144                    reopened
8145                        .query("SELECT id FROM tasks ORDER BY id", &[])
8146                        .unwrap(),
8147                    Vec::<Map<String, Value>>::new(),
8148                    "{label}"
8149                );
8150                // The guarded bump reset owns the publication, so its change
8151                // batch carries the recovery's rejection, outcome, and table
8152                // domains at the revision it advanced to.
8153                let mut next = reopened;
8154                let revision = next.local_revision();
8155                let batches = next.drain_change_batches();
8156                assert!(
8157                    batches.iter().any(|batch| batch.rejections_changed
8158                        && batch.outcomes_changed
8159                        && batch.tables.iter().any(|change| change.table == "tasks")
8160                        && batch.revision == revision.to_string()),
8161                    "{label}: {batches:?}"
8162                );
8163                next.mutate(upsert(Map::from_iter([
8164                    ("id".to_owned(), json!("t3")),
8165                    ("project_id".to_owned(), json!("p1")),
8166                    ("payload".to_owned(), json!({"$bytes": "0b"})),
8167                    ("later".to_owned(), json!("v2")),
8168                ])))
8169                .expect("v2 authoring after the reset");
8170                assert_eq!(next.pending_commit_ids().len(), 2, "{label}");
8171                assert_eq!(next.pending_payloads().len(), 2, "{label}");
8172                drop(next);
8173            } else {
8174                assert_eq!(
8175                    reopened
8176                        .query("SELECT id FROM tasks ORDER BY id", &[])
8177                        .unwrap(),
8178                    vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8179                    "{label}"
8180                );
8181                drop(reopened);
8182            }
8183
8184            // Queue and corrupt a new commit while the client stays open so only
8185            // recreation can recover it.
8186            if !bump {
8187                let mut recreated = open(&v1, &path);
8188                let fresh = recreated
8189                    .mutate(upsert(row("t3")))
8190                    .expect("fresh commit while open");
8191                recreated.drain_change_batches();
8192                let planted_fresh = json!([{
8193                    "op": "upsert",
8194                    "table": "tasks",
8195                    "rowId": "t3",
8196                    "baseVersion": null,
8197                    "values": {"id": "t3", "project_id": "p1",
8198                        "payload": {"$bytes": "zz"}}
8199                }]);
8200                let connection = Connection::open(&path).expect("raw connection");
8201                let affected = connection
8202                    .execute(
8203                        "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8204                        rusqlite::params![planted_fresh.to_string(), fresh],
8205                    )
8206                    .expect("plant fresh legacy intent");
8207                assert_eq!(
8208                    affected, 1,
8209                    "{label}: the in-place plant must hit the queued commit"
8210                );
8211                drop(connection);
8212                let revision_before = recreated.local_revision();
8213                recreated
8214                    .recreate_with_schema(&v1)
8215                    .unwrap_or_else(|error| panic!("{label}: recreation failed: {error}"));
8216                assert_eq!(recreated.pending_commit_ids(), vec![successor], "{label}");
8217                let fresh_rejection = recreated
8218                    .rejections()
8219                    .iter()
8220                    .find(|rejection| rejection.client_commit_id == fresh)
8221                    .expect("the in-place recovery rejection");
8222                assert_eq!(fresh_rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8223                assert_eq!(
8224                    fresh_rejection
8225                        .details
8226                        .as_ref()
8227                        .and_then(|details| details.reason.as_deref()),
8228                    Some(INVALID_STORED_VALUES_REASON),
8229                    "{label}"
8230                );
8231                assert!(
8232                    matches!(
8233                        recreated
8234                            .commit_outcome(&fresh)
8235                            .expect("outcome read")
8236                            .map(|outcome| outcome.status),
8237                        Some(CommitOutcomeStatus::Rejected)
8238                    ),
8239                    "{label}"
8240                );
8241                assert!(recreated.local_revision() > revision_before, "{label}");
8242                let revision = recreated.local_revision();
8243                let batches = recreated.drain_change_batches();
8244                assert_eq!(batches.len(), 1, "{label}: one recovery batch");
8245                assert!(
8246                    batches.iter().any(|batch| batch.rejections_changed
8247                        && batch.outcomes_changed
8248                        && batch.tables.iter().any(|change| change.table == "tasks")
8249                        && batch.revision == revision.to_string()),
8250                    "{label}: {batches:?}"
8251                );
8252                assert_eq!(recreated.pending_payloads().len(), 1, "{label}");
8253                assert_eq!(
8254                    recreated
8255                        .query("SELECT id FROM tasks ORDER BY id", &[])
8256                        .unwrap(),
8257                    vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8258                    "{label}: the recovered commit leaves no projection"
8259                );
8260                drop(recreated);
8261            }
8262            let _ = std::fs::remove_file(&path);
8263        }
8264    }
8265
8266    #[test]
8267    fn legacy_values_recover_over_a_synced_authoritative_row() {
8268        let path = std::env::temp_dir().join(format!(
8269            "syncular-authoritative-row-{}.db",
8270            uuid::Uuid::new_v4()
8271        ));
8272        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8273            {"name":"id","type":"string","nullable":false},
8274            {"name":"project_id","type":"string","nullable":false},
8275            {"name":"title","type":"string","nullable":false},
8276            {"name":"payload","type":"bytes","nullable":true}],
8277            "scopes":[{"pattern":"project:{project_id}"}]}]});
8278        let open = |path: &std::path::Path| {
8279            SyncClient::open_path(
8280                "authoritative-row".into(),
8281                &schema,
8282                ClientLimits::default(),
8283                path.to_str().unwrap(),
8284            )
8285            .expect("open client")
8286        };
8287        let row = |title: &str| {
8288            Map::from_iter([
8289                ("id".to_owned(), json!("t1")),
8290                ("project_id".to_owned(), json!("p1")),
8291                ("title".to_owned(), json!(title)),
8292                ("payload".to_owned(), json!({"$bytes": "0a"})),
8293            ])
8294        };
8295        let upsert = |values| {
8296            vec![Mutation::Upsert {
8297                table: "tasks".into(),
8298                values,
8299                base_version: None,
8300            }]
8301        };
8302        let mut client = open(&path);
8303        client.create_synced_tables().expect("create synced tables");
8304        // A server-confirmed row: base and visible carry the authoritative
8305        // values at version 1.
8306        client
8307            .conn
8308            .execute_batch(
8309                "INSERT INTO _syncular_base_tasks VALUES ('t1','p1','authoritative',x'0a',1);
8310                 INSERT INTO tasks VALUES ('t1','p1','authoritative',x'0a',1);",
8311            )
8312            .expect("seed authoritative row");
8313        let older = client.mutate(upsert(row("older"))).expect("older");
8314        let newer = client.mutate(upsert(row("newer"))).expect("newer");
8315        let successor = client.mutate(upsert(row("successor"))).expect("successor");
8316        drop(client);
8317
8318        let connection = Connection::open(&path).expect("raw connection");
8319        for commit_id in [&older, &newer] {
8320            let planted = json!([{
8321                "op": "upsert",
8322                "table": "tasks",
8323                "rowId": "t1",
8324                "baseVersion": null,
8325                "values": {"id": "t1", "project_id": "p1", "title": "legacy",
8326                    "payload": {"$bytes": "zz"}}
8327            }]);
8328            connection
8329                .execute(
8330                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8331                    rusqlite::params![planted.to_string(), commit_id],
8332                )
8333                .expect("plant legacy intent");
8334        }
8335        drop(connection);
8336
8337        let reopened = open(&path);
8338        assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8339        assert_eq!(
8340            reopened
8341                .rejections()
8342                .iter()
8343                .map(|rejection| rejection.client_commit_id.clone())
8344                .collect::<Vec<_>>(),
8345            vec![older.clone(), newer.clone()]
8346        );
8347        // The pending successor overlays the restored authoritative row, which
8348        // keeps its server version.
8349        assert_eq!(
8350            reopened.query("SELECT id,title,_syncular_version FROM tasks", &[]),
8351            Ok(vec![Map::from_iter([
8352                ("id".to_owned(), json!("t1")),
8353                ("title".to_owned(), json!("successor")),
8354                ("_syncular_version".to_owned(), json!(1)),
8355            ])])
8356        );
8357
8358        // A later rejection of the surviving successor restores the
8359        // authoritative values and version exactly.
8360        drop(reopened);
8361        let connection = Connection::open(&path).expect("raw connection");
8362        connection
8363            .execute(
8364                "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8365                rusqlite::params![
8366                    json!([{
8367                        "op": "upsert",
8368                        "table": "tasks",
8369                        "rowId": "t1",
8370                        "baseVersion": null,
8371                        "values": {"id": "t1", "project_id": "p1", "title": "legacy",
8372                            "payload": {"$bytes": "zz"}}
8373                    }])
8374                    .to_string(),
8375                    successor
8376                ],
8377            )
8378            .expect("plant successor intent");
8379        drop(connection);
8380        let late = open(&path);
8381        assert!(late.pending_commit_ids().is_empty());
8382        assert_eq!(
8383            late.query("SELECT id,title,_syncular_version FROM tasks", &[]),
8384            Ok(vec![Map::from_iter([
8385                ("id".to_owned(), json!("t1")),
8386                ("title".to_owned(), json!("authoritative")),
8387                ("_syncular_version".to_owned(), json!(1)),
8388            ])])
8389        );
8390        drop(late);
8391        let _ = std::fs::remove_file(&path);
8392    }
8393
8394    #[test]
8395    fn a_failed_value_recovery_commit_restores_every_side_effect() {
8396        let path = std::env::temp_dir().join(format!(
8397            "syncular-value-recovery-commit-{}.db",
8398            uuid::Uuid::new_v4()
8399        ));
8400        let path_str = path.to_str().expect("path");
8401        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8402            {"name":"id","type":"string","nullable":false},
8403            {"name":"project_id","type":"string","nullable":false},
8404            {"name":"payload","type":"bytes","nullable":true}],
8405            "scopes":[{"pattern":"project:{project_id}"}]}]});
8406        // A rollback-journal replica, so a second read connection can hold
8407        // SHARED across the recovery transaction's COMMIT.
8408        let conn = Connection::open(path_str).expect("replica connection");
8409        let mut client = SyncClient::with_connection(
8410            "value-recovery-commit".into(),
8411            &schema,
8412            ClientLimits::default(),
8413            conn,
8414        )
8415        .expect("replica");
8416        assert_eq!(
8417            client
8418                .conn
8419                .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
8420                .expect("journal mode"),
8421            "delete"
8422        );
8423        client.create_synced_tables().expect("create synced tables");
8424        let stale = client
8425            .mutate(vec![Mutation::Upsert {
8426                table: "tasks".into(),
8427                values: Map::from_iter([
8428                    ("id".to_owned(), json!("t1")),
8429                    ("project_id".to_owned(), json!("p1")),
8430                    ("payload".to_owned(), json!({"$bytes": "0a"})),
8431                ]),
8432                base_version: None,
8433            }])
8434            .expect("queued write");
8435        client.drain_change_batches();
8436        let affected = client
8437            .conn
8438            .execute(
8439                "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8440                rusqlite::params![
8441                    json!([{
8442                        "op": "upsert",
8443                        "table": "tasks",
8444                        "rowId": "t1",
8445                        "baseVersion": null,
8446                        "values": {"id": "t1", "project_id": "p1",
8447                            "payload": {"$bytes": "zz"}}
8448                    }])
8449                    .to_string(),
8450                    stale
8451                ],
8452            )
8453            .expect("plant legacy intent");
8454        assert_eq!(affected, 1, "the planted row must exist");
8455        let revision = client.local_revision();
8456        let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
8457        let prior_outbox = client.pending_commit_ids();
8458        let prior_rejections = client.rejections().len();
8459        let prior_changes = client.change_queue.len();
8460        let prior_intents = client.sync_intent_queue.len();
8461        let prior_failed = client.failed_commits.len();
8462        // The reader holds SHARED for the whole attempt: the recovery runs to
8463        // its publication and the COMMIT fails.
8464        let reader = Connection::open(path_str).expect("reader");
8465        reader
8466            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
8467            .expect("reader snapshot");
8468        client
8469            .conn
8470            .busy_timeout(std::time::Duration::ZERO)
8471            .expect("busy timeout");
8472        let error = client
8473            .recreate_with_schema(&schema)
8474            .expect_err("a blocked COMMIT must refuse the recovery");
8475        assert!(error.starts_with("client.storage_busy:"), "{error}");
8476        assert!(client.conn.is_autocommit(), "the transaction is released");
8477        assert_eq!(client.local_revision(), revision);
8478        assert_eq!(client.pending_commit_ids(), prior_outbox);
8479        assert_eq!(client.rejections().len(), prior_rejections);
8480        assert_eq!(client.change_queue.len(), prior_changes);
8481        assert_eq!(client.sync_intent_queue.len(), prior_intents);
8482        assert_eq!(client.failed_commits.len(), prior_failed);
8483        assert_eq!(
8484            client.query("SELECT * FROM tasks", &[]).expect("rows"),
8485            rows
8486        );
8487        assert!(
8488            client
8489                .commit_outcome(&stale)
8490                .expect("outcome read")
8491                .is_none(),
8492            "the rolled-back recovery kept no outcome"
8493        );
8494        // The retry runs the same publication branch to completion.
8495        reader.execute_batch("ROLLBACK").expect("release reader");
8496        client
8497            .recreate_with_schema(&schema)
8498            .expect("retry after the blocked commit");
8499        assert!(client.pending_commit_ids().is_empty());
8500        assert_eq!(client.rejections().len(), prior_rejections + 1);
8501        assert!(client.local_revision() > revision);
8502        let batches = client.drain_change_batches();
8503        assert!(
8504            batches.iter().any(|batch| batch.rejections_changed
8505                && batch.outcomes_changed
8506                && batch.tables.iter().any(|change| change.table == "tasks")),
8507            "{batches:?}"
8508        );
8509        drop(client);
8510        let _ = std::fs::remove_file(&path);
8511    }
8512
8513    #[cfg(feature = "e2ee")]
8514    #[test]
8515    fn a_failed_recovery_restores_retained_failed_commits() {
8516        let path = std::env::temp_dir().join(format!(
8517            "syncular-value-recovery-retention-{}.db",
8518            uuid::Uuid::new_v4()
8519        ));
8520        let path_str = path.to_str().expect("path");
8521        let schema = json!({"version":1,"tables":[{"name":"secrets","primaryKey":"id","columns":[
8522            {"name":"id","type":"string","nullable":false},
8523            {"name":"project_id","type":"string","nullable":false},
8524            {"name":"note","type":"bytes","nullable":true,"encrypted":true,"declaredType":"string"}],
8525            "scopes":[{"pattern":"project:{project_id}"}]}]});
8526        let conn = Connection::open(path_str).expect("replica connection");
8527        let mut client = SyncClient::with_connection(
8528            "value-recovery-retention".into(),
8529            &schema,
8530            ClientLimits::default(),
8531            conn,
8532        )
8533        .expect("replica");
8534        client.create_synced_tables().expect("create synced tables");
8535        client.set_retain_failed_commits(true).expect("retention");
8536        // No keys configured: the §5.11 seal fails, so retention is exercised.
8537        client.set_encryption(crate::values::EncryptionConfig::default());
8538        client
8539            .mutate(vec![Mutation::Upsert {
8540                table: "secrets".into(),
8541                values: Map::from_iter([
8542                    ("id".to_owned(), json!("s1")),
8543                    ("project_id".to_owned(), json!("p1")),
8544                    ("note".to_owned(), json!("secret")),
8545                ]),
8546                base_version: None,
8547            }])
8548            .expect("queued write");
8549        client.drain_change_batches();
8550        let revision = client.local_revision();
8551        let prior_outbox = client.pending_commit_ids();
8552        let prior_rejections = client.rejections().len();
8553        let prior_failed = client.failed_commits.len();
8554        let reader = Connection::open(path_str).expect("reader");
8555        reader
8556            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
8557            .expect("reader snapshot");
8558        client
8559            .conn
8560            .busy_timeout(std::time::Duration::ZERO)
8561            .expect("busy timeout");
8562        let error = client
8563            .drop_unencodable_outbox(true, None)
8564            .expect_err("a blocked COMMIT must refuse the recovery");
8565        assert!(error.starts_with("client.storage_busy:"), "{error}");
8566        assert_eq!(client.failed_commits.len(), prior_failed);
8567        assert_eq!(client.pending_commit_ids(), prior_outbox);
8568        assert_eq!(client.rejections().len(), prior_rejections);
8569        assert_eq!(client.local_revision(), revision);
8570        // The retry retains the failed commit and drops it from the outbox.
8571        reader.execute_batch("ROLLBACK").expect("release reader");
8572        client
8573            .drop_unencodable_outbox(true, None)
8574            .expect("retry after the blocked commit");
8575        assert_eq!(client.failed_commits.len(), prior_failed + 1);
8576        assert!(client.pending_commit_ids().is_empty());
8577        assert_eq!(client.rejections().len(), prior_rejections + 1);
8578        drop(client);
8579        let _ = std::fs::remove_file(&path);
8580    }
8581
8582    #[test]
8583    fn client_error_maps_legacy_codes_and_static_messages() {
8584        let code_only = ClientError::from("sync.unknown_table".to_owned());
8585        assert_eq!(code_only.code, "sync.unknown_table");
8586        assert_eq!(code_only.message, "the commit targets an unknown table");
8587        assert!(code_only.details.is_none());
8588
8589        let dynamic = ClientError::from(
8590            "sync.invalid_request: table \"tasks\": patch cannot write scope column".to_owned(),
8591        );
8592        assert_eq!(dynamic.code, "sync.invalid_request");
8593        assert_eq!(dynamic.message, "the authoring request is invalid");
8594        assert!(dynamic.details.as_ref().unwrap()["legacyCause"]
8595            .as_str()
8596            .unwrap()
8597            .contains("patch cannot write scope column"));
8598
8599        let unclassified =
8600            ClientError::from("a commit must contain at least one operation".to_owned());
8601        assert_eq!(unclassified.code, "client.failed");
8602        assert_eq!(unclassified.message, "the local authoring operation failed");
8603        assert!(unclassified.details.as_ref().unwrap()["legacyCause"]
8604            .as_str()
8605            .unwrap()
8606            .contains("at least one operation"));
8607
8608        // An unclassified QueryReadFailure keeps its numeric SQLite evidence.
8609        let metadata = ClientError::from(QueryReadFailure {
8610            code: None,
8611            sqlite_code: Some(1),
8612            sqlite_message: Some("generic SQLite prose".to_owned()),
8613            rollback_failure: Some(Box::new(QueryReadFailure::from(
8614                rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(1), None),
8615            ))),
8616            message: "unclassified read".to_owned(),
8617        });
8618        assert_eq!(metadata.code, "client.failed");
8619        assert_eq!(metadata.message, "the local authoring operation failed");
8620        let details = metadata.details.as_ref().unwrap();
8621        assert_eq!(details["sqliteCode"], 1);
8622        assert_eq!(details["sqliteMessage"], "generic SQLite prose");
8623        assert_eq!(details["rollbackFailure"]["sqliteCode"], 1);
8624        assert_eq!(details["legacyCause"], "unclassified read");
8625    }
8626
8627    #[test]
8628    fn schema_marker_read_keeps_storage_classification() {
8629        let busy = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
8630            rusqlite::ffi::Error::new(5),
8631            Some("database is locked".to_owned()),
8632        ));
8633        assert!(busy.starts_with("client.storage_busy:"), "{busy}");
8634        let invalid = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
8635            rusqlite::ffi::Error::new(1),
8636            Some("unreadable".to_owned()),
8637        ));
8638        assert_eq!(
8639            invalid,
8640            "sync.local_corrupt: persisted local schema marker is unreadable or invalid"
8641        );
8642
8643        // A real exclusive lock on the marker read surfaces BUSY, not corrupt,
8644        // and the guard writes nothing.
8645        let path =
8646            std::env::temp_dir().join(format!("syncular-marker-busy-{}.db", uuid::Uuid::new_v4()));
8647        let setup = Connection::open(&path).expect("setup connection");
8648        setup
8649            .execute_batch(
8650                "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value TEXT);\n                 INSERT INTO _syncular_meta VALUES ('localSchemaVersion','1');",
8651            )
8652            .unwrap();
8653        let locker = Connection::open(&path).expect("locker connection");
8654        locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
8655        let reader = Connection::open(&path).expect("reader connection");
8656        reader.busy_timeout(std::time::Duration::ZERO).unwrap();
8657        let locked = read_local_schema_version(&reader, 1).expect_err("locked marker read");
8658        assert!(locked.starts_with("client.storage_busy:"), "{locked}");
8659        locker.execute_batch("ROLLBACK").unwrap();
8660        assert_eq!(read_local_schema_version(&reader, 1).unwrap(), Some(1));
8661        let rows = setup
8662            .query_row("SELECT count(*) FROM _syncular_meta", [], |row| {
8663                row.get::<_, i64>(0)
8664            })
8665            .unwrap();
8666        assert_eq!(rows, 1, "the guard performed no writes");
8667        drop(reader);
8668        drop(locker);
8669        drop(setup);
8670        let _ = std::fs::remove_file(&path);
8671    }
8672
8673    #[cfg(feature = "crdt-yjs")]
8674    #[test]
8675    fn crdt_authoring_pre_read_preserves_storage_classification() {
8676        let path =
8677            std::env::temp_dir().join(format!("syncular-crdt-busy-{}.db", uuid::Uuid::new_v4()));
8678        let schema = json!({"version":1,"tables":[{"name":"notes","primaryKey":"id","columns":[
8679            {"name":"id","type":"string","nullable":false},
8680            {"name":"project_id","type":"string","nullable":false},
8681            {"name":"doc","type":"crdt","nullable":true,"crdtType":"yjs-doc"}],
8682            "scopes":[{"pattern":"project:{project_id}"}]}]});
8683        let mut client = SyncClient::open_path(
8684            "crdt-busy".into(),
8685            &schema,
8686            ClientLimits::default(),
8687            path.to_str().unwrap(),
8688        )
8689        .expect("open client");
8690        client.create_synced_tables().expect("create synced tables");
8691        client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
8692
8693        let locker = Connection::open(&path).expect("locker connection");
8694        locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
8695        let busy = client
8696            .crdt_apply_update("notes", "n1", "doc", &[])
8697            .expect_err("busy crdt pre-read");
8698        assert_eq!(busy.code, "client.storage_busy");
8699        assert!(busy.retryable);
8700        assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
8701        assert!(client.pending_commit_ids().is_empty());
8702        assert_eq!(client.local_revision(), 0);
8703
8704        locker.execute_batch("ROLLBACK").unwrap();
8705        drop(locker);
8706        drop(client);
8707        let _ = std::fs::remove_file(&path);
8708    }
8709
8710    #[test]
8711    fn large_unique_import_copies_no_unrelated_tables() {
8712        let (mut client, image, table) = unique_import_fixture(10240);
8713        let pending = client.pending_commit_ids();
8714        client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
8715            CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
8716        assert!(matches!(
8717            client.apply_sqlite_image(
8718                &image,
8719                &table,
8720                true,
8721                &[("project_id".into(), vec!["p1".into()])],
8722                10240,
8723                7,
8724                "digest"
8725            ),
8726            Ok(10240)
8727        ));
8728        assert_eq!(client.overlay_rebuild_count.get(), 0);
8729        assert_eq!(client.pending_commit_ids(), pending);
8730        assert_eq!(
8731            client
8732                .query("SELECT count(*) AS n FROM tasks", &[])
8733                .unwrap()[0]["n"],
8734            10241
8735        );
8736        assert_eq!(
8737            client
8738                .query("SELECT count(*) AS n FROM tasks_fts", &[])
8739                .unwrap()[0]["n"],
8740            10241
8741        );
8742        assert_eq!(
8743            client
8744                .query("SELECT count(*) AS n FROM notes_fts", &[])
8745                .unwrap()[0]["n"],
8746            8192
8747        );
8748    }
8749
8750    #[test]
8751    #[ignore = "manual scaling benchmark; see bench/README.md"]
8752    fn benchmark_unique_image_import() {
8753        let mut samples = Vec::new();
8754        for rows in [10240, 51200, 102400] {
8755            for trial in 0..4 {
8756                let (mut client, image, table) = unique_import_fixture(rows);
8757                let start = std::time::Instant::now();
8758                assert!(
8759                    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)
8760                );
8761                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
8762                assert_eq!(
8763                    client
8764                        .query("SELECT count(*) AS n FROM tasks", &[])
8765                        .unwrap()[0]["n"],
8766                    rows + 1
8767                );
8768                assert_eq!(
8769                    client
8770                        .query("SELECT count(*) AS n FROM tasks_fts", &[])
8771                        .unwrap()[0]["n"],
8772                    rows + 1
8773                );
8774                assert_eq!(
8775                    client
8776                        .query("SELECT count(*) AS n FROM notes_fts", &[])
8777                        .unwrap()[0]["n"],
8778                    8192
8779                );
8780                assert_eq!(client.pending_commit_ids().len(), 1);
8781                if trial > 0 {
8782                    samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
8783                }
8784            }
8785        }
8786        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});
8787        let output = serde_json::to_string_pretty(&artifact).unwrap();
8788        if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
8789            use std::io::Write;
8790            let mut file = std::fs::OpenOptions::new()
8791                .write(true)
8792                .create_new(true)
8793                .open(path)
8794                .unwrap();
8795            file.write_all(output.as_bytes()).unwrap();
8796        } else {
8797            println!("{output}");
8798        }
8799    }
8800
8801    #[test]
8802    fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
8803        let schema = json!({
8804            "version": 1,
8805            "tables": [{
8806                "name": "catalogue_codes",
8807                "primaryKey": "id",
8808                "columns": [
8809                    { "name": "id", "type": "string", "nullable": false },
8810                    { "name": "release_id", "type": "string", "nullable": false },
8811                    { "name": "code", "type": "string", "nullable": false },
8812                    { "name": "title", "type": "string", "nullable": false }
8813                ],
8814                "scopes": [{ "pattern": "release:{release_id}" }],
8815                "ftsIndexes": [{
8816                    "name": "catalogue_codes_fts",
8817                    "columns": ["code", "title"],
8818                    "tokenize": "unicode61 remove_diacritics 2"
8819                }]
8820            }]
8821        });
8822        let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
8823            .expect("FTS5 client");
8824        let insert_trigger: String = client
8825            .conn
8826            .query_row(
8827                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
8828                [],
8829                |row| row.get(0),
8830            )
8831            .expect("insert trigger");
8832        let replace_guard: String = client
8833            .conn
8834            .query_row(
8835                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
8836                [],
8837                |row| row.get(0),
8838            )
8839            .expect("replace guard");
8840        assert!(!insert_trigger.contains("DELETE FROM"));
8841        assert!(replace_guard.contains("BEFORE INSERT"));
8842        assert!(replace_guard.contains("WHEN EXISTS"));
8843        let search = |client: &SyncClient, query: &str| {
8844            client
8845                .query(
8846                    "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",
8847                    &[Value::from(query)],
8848                )
8849                .expect("FTS query")
8850        };
8851
8852        client
8853            .mutate(vec![Mutation::Upsert {
8854                table: "catalogue_codes".to_owned(),
8855                values: Map::from_iter([
8856                    ("id".to_owned(), Value::from("c1")),
8857                    ("release_id".to_owned(), Value::from("r1")),
8858                    ("code".to_owned(), Value::from("A01")),
8859                    ("title".to_owned(), Value::from("Cholera")),
8860                ]),
8861                base_version: None,
8862            }])
8863            .expect("insert code");
8864        assert_eq!(search(&client, "cholera").len(), 1);
8865        let prior = client
8866            .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
8867            .unwrap();
8868        client
8869            .conn
8870            .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
8871            .unwrap();
8872        client.create_synced_tables().unwrap();
8873        assert_eq!(
8874            client
8875                .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
8876                .unwrap(),
8877            prior
8878        );
8879        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();
8880        let rendered = serde_json::to_string(&plan).unwrap();
8881        assert!(rendered.contains("COVERING INDEX"));
8882        assert!(rendered.contains("INDEX 0:="));
8883        client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
8884        assert_eq!(search(&client, "cholera").len(), 1);
8885
8886        client
8887            .mutate(vec![Mutation::Upsert {
8888                table: "catalogue_codes".to_owned(),
8889                values: Map::from_iter([
8890                    ("id".to_owned(), Value::from("c1")),
8891                    ("release_id".to_owned(), Value::from("r1")),
8892                    ("code".to_owned(), Value::from("A01")),
8893                    ("title".to_owned(), Value::from("Enteric infection")),
8894                ]),
8895                base_version: None,
8896            }])
8897            .expect("update code");
8898        assert!(search(&client, "cholera").is_empty());
8899        assert_eq!(search(&client, "enteric").len(), 1);
8900
8901        client
8902            .mutate(vec![Mutation::Delete {
8903                table: "catalogue_codes".to_owned(),
8904                row_id: "c1".to_owned(),
8905                base_version: None,
8906            }])
8907            .expect("delete code");
8908        assert!(search(&client, "enteric").is_empty());
8909    }
8910
8911    #[test]
8912    fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
8913        let schema = json!({
8914            "version": 1,
8915            "tables": [{
8916                "name": "patient_notes",
8917                "primaryKey": "id",
8918                "columns": [
8919                    { "name": "id", "type": "string", "nullable": false },
8920                    { "name": "practice_id", "type": "string", "nullable": false },
8921                    { "name": "encryption_key_id", "type": "string", "nullable": false },
8922                    { "name": "title", "type": "string", "nullable": false }
8923                ],
8924                "scopes": [{ "pattern": "practice:{practice_id}" }],
8925                "ftsIndexes": [{
8926                    "name": "patient_notes_fts",
8927                    "columns": ["title"],
8928                    "tokenize": "unicode61 remove_diacritics 2"
8929                }]
8930            }]
8931        });
8932        let mut client = SyncClient::new(
8933            "local-purge-test".to_owned(),
8934            &schema,
8935            ClientLimits::default(),
8936        )
8937        .expect("local purge client");
8938        client
8939            .conn
8940            .execute(
8941                "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
8942                 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
8943                 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
8944                [],
8945            )
8946            .expect("seed base rows");
8947        client.overlay_dirty.set(true);
8948        client.rebuild_overlay_if_dirty().expect("rebuild overlay");
8949
8950        let note = |id: &str, key_id: &str, title: &str| {
8951            Map::from_iter([
8952                ("id".to_owned(), Value::from(id)),
8953                ("practice_id".to_owned(), Value::from("practice-1")),
8954                ("encryption_key_id".to_owned(), Value::from(key_id)),
8955                ("title".to_owned(), Value::from(title)),
8956            ])
8957        };
8958        let doomed = client
8959            .mutate(vec![
8960                Mutation::Upsert {
8961                    table: "patient_notes".to_owned(),
8962                    values: note("target", "key-revoked", "Target changed"),
8963                    base_version: None,
8964                },
8965                Mutation::Upsert {
8966                    table: "patient_notes".to_owned(),
8967                    values: note("unrelated", "key-held", "Unrelated changed"),
8968                    base_version: None,
8969                },
8970            ])
8971            .expect("doomed commit");
8972        let kept = client
8973            .mutate(vec![Mutation::Upsert {
8974                table: "patient_notes".to_owned(),
8975                values: note("kept", "key-held", "Kept optimistic"),
8976                base_version: None,
8977            }])
8978            .expect("kept commit");
8979        client.drain_change_batches();
8980
8981        let input = LocalDataPurgeInput {
8982            purge_id: "purge-001".to_owned(),
8983            targets: vec![LocalDataPurgeTarget {
8984                table: "patient_notes".to_owned(),
8985                selectors: BTreeMap::from([(
8986                    "encryption_key_id".to_owned(),
8987                    vec!["key-revoked".to_owned()],
8988                )]),
8989            }],
8990        };
8991        assert_eq!(
8992            client.purge_local_data(&input).expect("apply purge"),
8993            LocalDataPurgeResult {
8994                already_applied: false,
8995                purged_rows: 1,
8996                dropped_commits: 1,
8997            }
8998        );
8999        let rows = client
9000            .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
9001            .expect("visible rows");
9002        assert_eq!(rows.len(), 2);
9003        assert_eq!(rows[0]["id"], "kept");
9004        assert_eq!(rows[1]["id"], "unrelated");
9005        assert_eq!(rows[1]["title"], "Unrelated original");
9006        let fts = client
9007            .query(
9008                "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'",
9009                &[],
9010            )
9011            .expect("fts query");
9012        assert!(fts.is_empty());
9013        assert_eq!(client.outbox.len(), 1);
9014        assert_eq!(client.outbox[0].client_commit_id, kept);
9015        let outcome = client
9016            .commit_outcome(&doomed)
9017            .expect("read doomed outcome")
9018            .expect("doomed outcome");
9019        assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
9020        match &outcome.results[0] {
9021            CommitOperationOutcome::Error { rejection } => {
9022                assert_eq!(rejection.code, "client.local_data_purged");
9023                assert_eq!(rejection.client_commit_id, doomed);
9024            }
9025            other => panic!("expected local purge rejection, got {other:?}"),
9026        }
9027        assert_eq!(client.drain_change_batches().len(), 1);
9028        assert_eq!(
9029            client.purge_local_data(&input).expect("retry purge"),
9030            LocalDataPurgeResult {
9031                already_applied: true,
9032                purged_rows: 0,
9033                dropped_commits: 0,
9034            }
9035        );
9036        let conflicting = LocalDataPurgeInput {
9037            purge_id: input.purge_id.clone(),
9038            targets: vec![LocalDataPurgeTarget {
9039                table: "patient_notes".to_owned(),
9040                selectors: BTreeMap::from([(
9041                    "encryption_key_id".to_owned(),
9042                    vec!["key-held".to_owned()],
9043                )]),
9044            }],
9045        };
9046        assert!(client
9047            .purge_local_data(&conflicting)
9048            .expect_err("id collision must fail")
9049            .contains("already used with a different plan"));
9050    }
9051}
9052
9053impl SubState {
9054    fn name(self) -> &'static str {
9055        match self {
9056            SubState::Active => "active",
9057            SubState::Revoked => "revoked",
9058            SubState::Failed => "failed",
9059        }
9060    }
9061
9062    fn parse(value: &str) -> Self {
9063        match value {
9064            "revoked" => Self::Revoked,
9065            "failed" => Self::Failed,
9066            _ => Self::Active,
9067        }
9068    }
9069}
9070
9071#[derive(Debug, Clone)]
9072struct Subscription {
9073    id: String,
9074    table: String,
9075    requested: Vec<(String, Vec<String>)>,
9076    params: Option<String>,
9077    cursor: i64,
9078    /// §4.7 resume token, round-tripped opaquely.
9079    bootstrap_state: Option<String>,
9080    state: SubState,
9081    reason_code: Option<String>,
9082    /// Last effective scopes echoed while active (§3.3: persisted for the
9083    /// purge contract; each active echo replaces it).
9084    effective: Option<Vec<(String, Vec<String>)>>,
9085    synced_once: bool,
9086}
9087
9088#[cfg(test)]
9089mod subscription_rearm_tests {
9090    use super::*;
9091    use serde_json::json;
9092
9093    fn client() -> SyncClient {
9094        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9095            {"name":"id","type":"string","nullable":false},
9096            {"name":"project_id","type":"string","nullable":false}],
9097            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[],"ftsIndexes":[]}]});
9098        SyncClient::new("rearm".into(), &schema, ClientLimits::default()).expect("client")
9099    }
9100
9101    fn scopes() -> Vec<(String, Vec<String>)> {
9102        vec![("project_id".to_owned(), vec!["p1".to_owned()])]
9103    }
9104
9105    /// §3.3: the stored registration carries progress the re-arm must drop.
9106    fn store_revoked(client: &mut SyncClient) {
9107        client
9108            .subscribe("sub".into(), "tasks".into(), scopes(), None)
9109            .expect("subscribe");
9110        let sub = &mut client.subs[0];
9111        sub.cursor = 41;
9112        sub.bootstrap_state = Some("resume".to_owned());
9113        sub.effective = Some(scopes());
9114        sub.state = SubState::Revoked;
9115        sub.reason_code = Some("sync.scope_revoked".to_owned());
9116        client.persist_sub(&client.subs[0]).expect("persist");
9117        client.sync_needed = false;
9118        client.sync_intent_queue.clear();
9119    }
9120
9121    #[test]
9122    fn resubscribing_the_same_intent_rearms_a_revoked_registration() {
9123        let mut client = client();
9124        store_revoked(&mut client);
9125
9126        client
9127            .subscribe("sub".into(), "tasks".into(), scopes(), None)
9128            .expect("re-arm");
9129
9130        let view = client.subscription_state("sub").expect("state");
9131        assert_eq!(view.status, "active");
9132        assert_eq!(view.cursor, -1);
9133        assert!(!view.has_resume_token);
9134        assert_eq!(view.effective_scopes, None);
9135        assert_eq!(view.reason_code, None);
9136        assert!(client.sync_needed, "a re-arm asks for a pull");
9137        assert!(matches!(
9138            client.drain_sync_intents().as_slice(),
9139            [SyncIntent::Interactive]
9140        ));
9141        let stored: String = client
9142            .conn
9143            .query_row(
9144                "SELECT state_json FROM _syncular_subscriptions WHERE id = 'sub'",
9145                [],
9146                |row| row.get(0),
9147            )
9148            .expect("persisted row");
9149        let stored: Value = serde_json::from_str(&stored).expect("stored json");
9150        assert_eq!(stored["status"], json!("active"));
9151        assert_eq!(stored["cursor"], json!(-1));
9152        assert_eq!(stored["bootstrapState"], Value::Null);
9153        assert_eq!(stored["effectiveScopes"], Value::Null);
9154        assert_eq!(stored["reasonCode"], Value::Null);
9155    }
9156
9157    #[test]
9158    fn a_failed_registration_is_not_rearmed() {
9159        let mut client = client();
9160        client
9161            .subscribe("sub".into(), "tasks".into(), scopes(), None)
9162            .expect("subscribe");
9163        client.subs[0].state = SubState::Failed;
9164        client.subs[0].reason_code = Some("sync.scope_revoked".to_owned());
9165        client.persist_sub(&client.subs[0]).expect("persist");
9166        client.sync_needed = false;
9167        client.sync_intent_queue.clear();
9168
9169        client
9170            .subscribe("sub".into(), "tasks".into(), scopes(), None)
9171            .expect("subscribe");
9172
9173        let view = client.subscription_state("sub").expect("state");
9174        assert_eq!(view.status, "failed");
9175        assert_eq!(view.reason_code.as_deref(), Some("sync.scope_revoked"));
9176        assert!(!client.sync_needed);
9177        assert!(client.drain_sync_intents().is_empty());
9178    }
9179}
9180
9181#[derive(Debug, Clone)]
9182struct OutboxOp {
9183    upsert: bool,
9184    table: String,
9185    row_id: String,
9186    base_version: Option<i64>,
9187    /// Schema-agnostic local form (§0): driver JSON values, encoded with
9188    /// the current codec at send time. A key's presence IS the §6.1 sparse
9189    /// presence set — both the overlay and the wire payload derive from it.
9190    values: Option<Map<String, Value>>,
9191}
9192
9193impl From<&OutboxOp> for CommitOperation {
9194    fn from(operation: &OutboxOp) -> Self {
9195        Self {
9196            table: operation.table.clone(),
9197            row_id: operation.row_id.clone(),
9198            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
9199            base_version: operation.base_version,
9200            values: operation.values.clone(),
9201        }
9202    }
9203}
9204
9205#[derive(Debug, Clone)]
9206struct OutboxCommit {
9207    client_commit_id: String,
9208    ops: Vec<OutboxOp>,
9209}
9210
9211/// One commit's `(table, value keys)` per upsert operation — the classification
9212/// shape the §7.4.4 send-time drop and the RFC 0005 D6 audit share, so the two
9213/// can never disagree about which commits cannot re-encode.
9214fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
9215    commit
9216        .ops
9217        .iter()
9218        .filter(|operation| operation.upsert)
9219        .map(|operation| {
9220            (
9221                operation.table.as_str(),
9222                operation
9223                    .values
9224                    .as_ref()
9225                    .map(|values| values.keys().map(String::as_str).collect())
9226                    .unwrap_or_default(),
9227            )
9228        })
9229        .collect()
9230}
9231
9232type FailedCommit = (OutboxCommit, Map<String, Value>);
9233
9234#[derive(Debug, Clone)]
9235struct CompiledLocalDataPurgeTarget {
9236    table: String,
9237    selectors: Vec<(String, Vec<String>)>,
9238}
9239
9240struct StoredCommitOutcomeRow {
9241    sequence: i64,
9242    client_commit_id: String,
9243    status: String,
9244    recorded_at_ms: i64,
9245    results_json: String,
9246    operations_json: Option<String>,
9247    resolution: String,
9248    resolved_at_ms: Option<i64>,
9249    replacement_client_commit_id: Option<String>,
9250}
9251
9252impl StoredCommitOutcomeRow {
9253    fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
9254        Ok(Self {
9255            sequence: row.get(0)?,
9256            client_commit_id: row.get(1)?,
9257            status: row.get(2)?,
9258            recorded_at_ms: row.get(3)?,
9259            results_json: row.get(4)?,
9260            operations_json: row.get(5)?,
9261            resolution: row.get(6)?,
9262            resolved_at_ms: row.get(7)?,
9263            replacement_client_commit_id: row.get(8)?,
9264        })
9265    }
9266}
9267
9268/// Section outcome distinguishing the §5.6 subscription-local fail-closed
9269/// path from a round-aborting failure (§1.4 rule 5).
9270enum SectionError {
9271    FailClosed,
9272    Abort(String, String),
9273}
9274
9275#[derive(Debug)]
9276pub(crate) struct RequestMeta {
9277    pushed_ids: Vec<String>,
9278    /// Subscription id → the request carried `cursor < 0` and no resume
9279    /// token (§5.6 first-page detection: a *fresh* bootstrap).
9280    fresh: Vec<(String, bool)>,
9281    pub(crate) accept: u8,
9282    /// §6.1 splitBatch: outbox commits held back from THIS request because
9283    /// the running operation count reached the push cap — the next round
9284    /// pushes them (`sync_needed` stays set while any remain).
9285    deferred_commits: usize,
9286}
9287
9288#[derive(Default)]
9289struct ChangeAccumulator {
9290    /// None means table-wide; Some is the exact scoped domain.
9291    tables: BTreeMap<String, Option<BTreeSet<String>>>,
9292    windows: BTreeMap<(String, String), BTreeSet<String>>,
9293    status: bool,
9294    conflicts: bool,
9295    rejections: bool,
9296    outcomes: bool,
9297}
9298
9299impl ChangeAccumulator {
9300    fn table(&mut self, table: &str) {
9301        self.tables.insert(table.to_owned(), None);
9302    }
9303
9304    fn scope(&mut self, table: &str, key: String) {
9305        match self.tables.get_mut(table) {
9306            Some(None) => {}
9307            Some(Some(keys)) => {
9308                keys.insert(key);
9309            }
9310            None => {
9311                self.tables
9312                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
9313            }
9314        }
9315    }
9316
9317    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
9318        self.windows
9319            .entry((base_key.to_owned(), table.to_owned()))
9320            .or_default()
9321            .insert(unit.to_owned());
9322    }
9323
9324    fn touched(&self) -> bool {
9325        !self.tables.is_empty()
9326            || !self.windows.is_empty()
9327            || self.status
9328            || self.conflicts
9329            || self.rejections
9330            || self.outcomes
9331    }
9332}
9333
9334/// §6.1: the server caps total operations per request (reference default
9335/// 500) and rejects the whole batch with `sync.too_many_operations`; the
9336/// client "splits and retries". Splitting happens at build time: commits are
9337/// included IN ORDER until the operation budget is spent, the rest wait for
9338/// the next round.
9339const PUSH_OPS_PER_REQUEST: usize = 500;
9340const MAX_LOCAL_PURGE_TARGETS: usize = 64;
9341const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
9342const MAX_LOCAL_PURGE_VALUES: usize = 128;
9343const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
9344pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
9345
9346/// Dirty visible tables; `None` means a whole-replica recovery/reset.
9347struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
9348
9349impl OverlayDirty {
9350    fn clean() -> Self {
9351        Self(RefCell::new(Some(BTreeSet::new())))
9352    }
9353    fn get(&self) -> bool {
9354        self.0
9355            .borrow()
9356            .as_ref()
9357            .is_none_or(|tables| !tables.is_empty())
9358    }
9359    fn set(&self, dirty: bool) {
9360        *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
9361    }
9362    fn table(&self, table: &str) {
9363        if let Some(tables) = self.0.borrow_mut().as_mut() {
9364            tables.insert(table.to_owned());
9365        }
9366    }
9367    fn snapshot(&self) -> Option<BTreeSet<String>> {
9368        self.0.borrow().clone()
9369    }
9370    fn restore(&self, tables: Option<BTreeSet<String>>) {
9371        *self.0.borrow_mut() = tables;
9372    }
9373}
9374
9375pub struct SyncClient {
9376    #[cfg(feature = "bench-internals")]
9377    benchmark_phases: Recorder,
9378    conn: Connection,
9379    schema: ClientSchema,
9380    client_id: String,
9381    limits: ClientLimits,
9382    subs: Vec<Subscription>,
9383    outbox: Vec<OutboxCommit>,
9384    failed_commits: Vec<FailedCommit>,
9385    retain_failed_commits: bool,
9386    conflicts: Vec<ConflictRecord>,
9387    rejections: Vec<RejectionRecord>,
9388    schema_floor: Option<SchemaFloor>,
9389    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
9390    lease_state: Option<LeaseState>,
9391    /// §1.6: the schema-floor response stops syncing until an upgrade.
9392    stopped: bool,
9393    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
9394    upgrading: bool,
9395    /// §8.4 coalesced sync-needed signal.
9396    sync_needed: bool,
9397    /// §8.8: how sync rounds treat the realtime binding, and its exact
9398    /// availability state with the evidence behind a lost or refused one.
9399    realtime_policy: RealtimePolicy,
9400    realtime_state: RealtimeState,
9401    realtime_reason_code: Option<String>,
9402    realtime_retry_delay_ms: Option<u64>,
9403    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
9404    presence: HashMap<String, HashMap<String, PresencePeer>>,
9405    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
9406    /// host may pin it (conformance runs on a virtual clock).
9407    now_ms: Option<i64>,
9408    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
9409    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
9410    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
9411    encryption: crate::values::EncryptionConfig,
9412    /// Fail-closed host bootstrap gate. While set, command hosts permit only
9413    /// status/lifecycle inspection and an exact authorized local purge.
9414    security_preflight: bool,
9415    transport_enabled: bool,
9416    /// RFC 0005 D2/D8: the previous-version capture config. `None` (default)
9417    /// means the feature is off; the descriptor write and the container orphan
9418    /// sweep still happen.
9419    previous_version: Option<PreviousVersionContextConfig>,
9420    /// Per-table primary-key upsert SQL, built once per (full table name) —
9421    /// the row write path runs per row during bootstrap (§5.6), so the SQL
9422    /// string (and, via `prepare_cached`, its compiled statement) is reused
9423    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
9424    /// schema reset (the column lists may have changed).
9425    insert_sql: RefCell<HashMap<String, String>>,
9426    /// §7.1: reconcile only tables whose base/outbox changed. Whole-replica
9427    /// reset and restart explicitly invalidate every table.
9428    overlay_dirty: OverlayDirty,
9429    // First SQLite failure of the current operation, before savepoint cleanup.
9430    storage_failure: RefCell<Option<QueryReadFailure>>,
9431    /// Test-only structural performance signal: response processing must not
9432    /// turn a batch of acknowledgements into one full overlay rebuild each.
9433    #[cfg(test)]
9434    overlay_rebuild_count: Cell<usize>,
9435    #[cfg(test)]
9436    acknowledged_replay_count: Cell<usize>,
9437    /// Test-only structural performance signal: outcome retention is enforced
9438    /// once per response rather than once per acknowledgement.
9439    #[cfg(test)]
9440    outcome_prune_count: Cell<usize>,
9441    /// Exact observer-transaction output drained by command/FFI hosts.
9442    progress: ProgressObserver,
9443    change_queue: VecDeque<ClientChangeBatch>,
9444    sync_intent_queue: VecDeque<SyncIntent>,
9445    /// Explicit exponential retry policy for transient transport failures.
9446    retry_delay_ms: u64,
9447    /// §7.6: the background retry the current round scheduled, if any.
9448    round_retry_delay_ms: Option<u64>,
9449    active_round: Option<uuid::Uuid>,
9450    last_round: Option<DiagnosticLastRound>,
9451    last_change: Option<DiagnosticLastChange>,
9452    /// §7.6: latest failed owned snapshot read per owner id, oldest first.
9453    query_failures: Vec<DiagnosticQueryFailure>,
9454}
9455
9456pub(crate) fn quote_ident(name: &str) -> String {
9457    format!("\"{}\"", name.replace('"', "\"\""))
9458}
9459/// §7.4.1 `_syncular_meta` helpers. Free functions so RFC 0005's
9460/// previous-version module reads and writes the SAME records as the client
9461/// instead of growing a parallel meta layer.
9462pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
9463    conn.query_row(
9464        "SELECT value FROM _syncular_meta WHERE key = ?1",
9465        rusqlite::params![key],
9466        |row| row.get::<_, String>(0),
9467    )
9468    .ok()
9469}
9470
9471pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
9472    let _ = conn.execute(
9473        "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
9474        rusqlite::params![key, value],
9475    );
9476}
9477
9478pub(crate) fn meta_delete(conn: &Connection, key: &str) {
9479    let _ = conn.execute(
9480        "DELETE FROM _syncular_meta WHERE key = ?1",
9481        rusqlite::params![key],
9482    );
9483}
9484
9485/// Seek by the existing key while retaining the exact legacy text match.
9486fn row_id_predicate(table: &TableSchema) -> String {
9487    let key = quote_ident(&table.primary_key);
9488    let text_match = format!("CAST({key} AS TEXT) = ?1");
9489    match table.columns[table.pk_index].ty {
9490        ColumnType::String | ColumnType::Json => {
9491            format!("{key} = ?1 AND {text_match}")
9492        }
9493        // Native columns have no declared SQLite affinity. Match the stored
9494        // integer bind type explicitly. Unary + removes expression affinity so
9495        // SQLite can seek the typeless key; the text check preserves exact IDs.
9496        ColumnType::Integer | ColumnType::Boolean => {
9497            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
9498        }
9499        // REAL-to-text conversion can round distinct values to the same text.
9500        // Narrowing those matches by numeric equality would change behavior.
9501        _ => text_match,
9502    }
9503}
9504
9505fn is_local_operation_code_like(value: &str) -> bool {
9506    let bytes = value.as_bytes();
9507    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
9508        && bytes
9509            .iter()
9510            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
9511}
9512
9513fn base_table(name: &str) -> String {
9514    quote_ident(&format!("_syncular_base_{name}"))
9515}
9516
9517/// §4.8: a stable, server-opaque key for a window base — table + variable +
9518/// canonical fixed scopes. Two `set_window` calls with the same base
9519/// address the same registry rows.
9520/// §4.8 deferred eviction record: (sub id, table, effective scope map).
9521type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
9522
9523fn window_base_key(base: &WindowBase) -> String {
9524    format!(
9525        "{}\0{}\0{}",
9526        base.table,
9527        base.variable,
9528        canonical_scope_json(&base.fixed_scopes)
9529    )
9530}
9531
9532/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
9533fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
9534    let mut scopes = base.fixed_scopes.clone();
9535    scopes.retain(|(k, _)| k != &base.variable);
9536    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
9537    scopes
9538}
9539
9540/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
9541/// echoed not interpreted by the server, so the exact hash is client
9542/// convention; SHA-256 matches the SPEC's worked example.
9543fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
9544    let canonical = canonical_scope_json(&unit_scopes(base, unit));
9545    let digest = Sha256::digest(canonical.as_bytes());
9546    let hex = bytes_to_hex(&digest);
9547    format!("w:{}:{}", base.table, &hex[..16])
9548}
9549
9550fn visible_table(name: &str) -> String {
9551    quote_ident(name)
9552}
9553
9554const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
9555
9556/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
9557/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
9558fn is_synced_table_name(name: &str) -> bool {
9559    if name.starts_with("sqlite_") {
9560        return false;
9561    }
9562    if name.starts_with("_syncular_base_") {
9563        return true; // the base half of a synced table pair
9564    }
9565    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
9566    !name.starts_with("_syncular_")
9567}
9568
9569/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
9570fn blob_id_for(bytes: &[u8]) -> String {
9571    let digest = Sha256::digest(bytes);
9572    format!("sha256:{}", bytes_to_hex(&digest))
9573}
9574
9575/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
9576/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
9577/// per row on the §5.6 bootstrap path), scalars bind owned.
9578enum RowParam<'a> {
9579    Cell(&'a Option<ColumnValue>),
9580    Version(i64),
9581}
9582
9583impl rusqlite::ToSql for RowParam<'_> {
9584    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
9585        Ok(match self {
9586            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
9587            RowParam::Cell(cell) => match cell {
9588                None => ToSqlOutput::Owned(SqlValue::Null),
9589                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
9590                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
9591                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
9592                Some(ColumnValue::Boolean(b)) => {
9593                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
9594                }
9595                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
9596                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
9597                }
9598                // §5.10: crdt bytes store as BLOB, like bytes.
9599                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
9600                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
9601                }
9602            },
9603        })
9604    }
9605}
9606
9607// Retain a typed primary key across the current import chunk without copying row payloads.
9608fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
9609    match param.to_sql().map_err(|error| error.to_string())? {
9610        ToSqlOutput::Owned(value) => Ok(value),
9611        ToSqlOutput::Borrowed(value) => {
9612            SqlValue::try_from(value).map_err(|error| error.to_string())
9613        }
9614        _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
9615    }
9616}
9617
9618/// §5.3 image cell → bind parameter, strict per the declared column type
9619/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
9620/// nullable). Mismatches are image-producer violations. Returns the cell
9621/// borrowed when the stored representation already matches what the row
9622/// codec would write, or the normalized scalar (boolean → 0/1, float from
9623/// INTEGER → REAL) otherwise — the local write is byte-identical to the
9624/// old convert-then-insert path without allocating per cell.
9625fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
9626    use ssp2::segment::ColumnType;
9627    let mismatch = || {
9628        Err(format!(
9629            "image column {:?} holds a value of the wrong type",
9630            column.name
9631        ))
9632    };
9633    match value {
9634        ValueRef::Null => {
9635            if !column.nullable {
9636                return Err(format!(
9637                    "image column {:?} is NULL but not nullable",
9638                    column.name
9639                ));
9640            }
9641            Ok(ToSqlOutput::Owned(SqlValue::Null))
9642        }
9643        ValueRef::Integer(i) => match column.ty {
9644            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
9645            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
9646            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
9647            _ => mismatch(),
9648        },
9649        ValueRef::Real(_) => match column.ty {
9650            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
9651            _ => mismatch(),
9652        },
9653        ValueRef::Text(t) => {
9654            std::str::from_utf8(t)
9655                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
9656            match column.ty {
9657                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
9658                    Ok(ToSqlOutput::Borrowed(value))
9659                }
9660                _ => mismatch(),
9661            }
9662        }
9663        ValueRef::Blob(_) => match column.ty {
9664            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
9665            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
9666            _ => mismatch(),
9667        },
9668    }
9669}
9670
9671pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
9672    use rusqlite::types::ValueRef;
9673    match value {
9674        ValueRef::Null => Value::Null,
9675        ValueRef::Integer(i) => match column.ty {
9676            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
9677            ssp2::segment::ColumnType::Float => {
9678                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
9679            }
9680            _ => Value::from(i),
9681        },
9682        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
9683        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
9684        ValueRef::Blob(b) => {
9685            let mut map = Map::new();
9686            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
9687            Value::Object(map)
9688        }
9689    }
9690}
9691
9692/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
9693/// Objects are accepted in lossless `{"$bytes": hex}` and
9694/// `{"$bigint": decimal}` envelope forms.
9695fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
9696    Ok(match value {
9697        Value::Null => SqlValue::Null,
9698        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
9699        Value::Number(n) => {
9700            if let Some(i) = n.as_i64() {
9701                SqlValue::Integer(i)
9702            } else if let Some(f) = n.as_f64() {
9703                SqlValue::Real(f)
9704            } else {
9705                return Err(format!("query param number {n} is out of range"));
9706            }
9707        }
9708        Value::String(s) => SqlValue::Text(s.clone()),
9709        Value::Object(_) => {
9710            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
9711                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
9712            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
9713                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
9714                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
9715                })?)
9716            } else {
9717                return Err(
9718                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
9719                        .to_owned(),
9720                );
9721            }
9722        }
9723        Value::Array(_) => return Err("query array params are not supported".to_owned()),
9724    })
9725}
9726
9727/// Map a rusqlite value with no schema column to consult (arbitrary query
9728/// output): integers/reals/text pass through by stored affinity, blobs ride
9729/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
9730/// schema column type to recover booleans/floats/json.
9731fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
9732    use rusqlite::types::ValueRef;
9733    match value {
9734        ValueRef::Null => Value::Null,
9735        ValueRef::Integer(i) => {
9736            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
9737            // ordinary UI-sized integers ergonomic and envelope only values
9738            // beyond JavaScript's safe range.
9739            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
9740            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
9741                Value::from(i)
9742            } else {
9743                let mut map = Map::new();
9744                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
9745                Value::Object(map)
9746            }
9747        }
9748        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
9749        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
9750        ValueRef::Blob(b) => {
9751            let mut map = Map::new();
9752            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
9753            Value::Object(map)
9754        }
9755    }
9756}
9757
9758/// §7.5: SQLite failures classify by result code into [`QueryReadFailure`].
9759fn query_connection(
9760    conn: &Connection,
9761    sql: &str,
9762    params: &[QueryValue],
9763) -> Result<Vec<QueryRow>, QueryReadFailure> {
9764    crate::query_guard::assert_read_only_query(sql)?;
9765    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
9766    let bound: Vec<SqlValue> = params
9767        .iter()
9768        .map(json_param_to_sql)
9769        .collect::<Result<_, _>>()?;
9770    let mut stmt = conn.prepare(&lowered_sql)?;
9771    let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
9772    let bound_refs: Vec<&dyn rusqlite::ToSql> =
9773        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
9774    let mut sql_rows = stmt.query(bound_refs.as_slice())?;
9775    let mut out = Vec::new();
9776    while let Some(row) = sql_rows.next()? {
9777        let mut record = Map::new();
9778        for (i, name) in column_names.iter().enumerate() {
9779            let value = row.get_ref(i)?;
9780            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
9781        }
9782        out.push(record);
9783    }
9784    Ok(out)
9785}
9786
9787fn persisted_window_state(
9788    conn: &Connection,
9789    base: &WindowBase,
9790) -> Result<WindowState, QueryReadFailure> {
9791    let mut stmt = conn.prepare(
9792        "SELECT windows.unit, subscriptions.state_json
9793           FROM _syncular_windows AS windows
9794           JOIN _syncular_subscriptions AS subscriptions
9795             ON subscriptions.id = windows.sub_id
9796          WHERE windows.base = ?1
9797          ORDER BY windows.unit ASC",
9798    )?;
9799    let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
9800        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
9801    })?;
9802    let mut units = Vec::new();
9803    let mut pending = Vec::new();
9804    for row in rows {
9805        let (unit, raw) = row?;
9806        let state: Value = serde_json::from_str(&raw)
9807            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
9808        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
9809            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
9810            || state
9811                .get("bootstrapState")
9812                .is_some_and(|value| !value.is_null());
9813        if is_pending {
9814            pending.push(unit.clone());
9815        }
9816        units.push(unit);
9817    }
9818    Ok(WindowState { units, pending })
9819}
9820
9821/// One read-only statement in a §7.5 snapshot read.
9822#[derive(Debug, Clone, Copy)]
9823pub struct SnapshotStatement<'a> {
9824    pub sql: &'a str,
9825    pub params: &'a [Value],
9826}
9827
9828/// A §7.5 atomic read request: read-only statements, window coverage,
9829/// subscription catch-up states, and commit deliveries, all resolved from one
9830/// SQLite read transaction and one revision.
9831#[derive(Debug, Clone, Default)]
9832pub struct SnapshotReadRequest<'a> {
9833    pub statements: Vec<SnapshotStatement<'a>>,
9834    pub coverage: &'a [WindowCoverage],
9835    pub subscriptions: Vec<String>,
9836    pub commit_ids: Vec<String>,
9837}
9838
9839#[cfg(test)]
9840thread_local! {
9841    /// Test-only rendezvous that runs between the statements of one snapshot
9842    /// read. A test uses it to commit on another connection mid-read and prove
9843    /// the read transaction's snapshot and revision stay stable.
9844    static SNAPSHOT_READ_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
9845        std::cell::RefCell::new(None);
9846}
9847
9848#[cfg(test)]
9849fn run_snapshot_read_hook() {
9850    SNAPSHOT_READ_HOOK.with(|slot| {
9851        if let Some(hook) = slot.borrow_mut().take() {
9852            hook();
9853        }
9854    });
9855}
9856
9857/// §7.5: resolve a request over one SQLite read transaction. The revision,
9858/// every statement's rows, coverage, catch-up states, and deliveries are
9859/// captured under one `SAVEPOINT`; any statement's failure rolls the whole
9860/// read back and releases the connection.
9861fn snapshot_read_connection(
9862    conn: &Connection,
9863    request: &SnapshotReadRequest<'_>,
9864) -> Result<SnapshotRead, QueryReadFailure> {
9865    conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
9866    let corrupt = |message: &str| QueryReadFailure {
9867        code: Some("sync.local_corrupt"),
9868        sqlite_code: None,
9869        sqlite_message: None,
9870        rollback_failure: None,
9871        message: message.to_owned(),
9872    };
9873    let stored_failure = |error: rusqlite::Error, message: &str| match error {
9874        rusqlite::Error::Utf8Error(..)
9875        | rusqlite::Error::InvalidColumnType(..)
9876        | rusqlite::Error::FromSqlConversionFailure(..)
9877        | rusqlite::Error::IntegralValueOutOfRange(..) => corrupt(message),
9878        _ => QueryReadFailure::from(error),
9879    };
9880    let result: Result<SnapshotRead, QueryReadFailure> = (|| {
9881        // The durable revision as a canonical decimal `u64`; a missing marker
9882        // is the legacy zero, and any other non-canonical value is corrupt.
9883        let revision: Option<String> = conn
9884            .query_row(
9885                "SELECT value FROM _syncular_meta WHERE key = ?1",
9886                rusqlite::params![LOCAL_REVISION_KEY],
9887                |row| row.get(0),
9888            )
9889            .optional()
9890            .map_err(|error| stored_failure(error, "persisted local revision is invalid"))?;
9891        let revision = match revision {
9892            None => "0".to_owned(),
9893            Some(value)
9894                if value == "0"
9895                    || (matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
9896                        && value.bytes().all(|b| b.is_ascii_digit())
9897                        && value.parse::<u64>().is_ok()) =>
9898            {
9899                value
9900            }
9901            Some(_) => return Err(corrupt("persisted local revision is invalid")),
9902        };
9903        let mut queries = Vec::with_capacity(request.statements.len());
9904        for statement in &request.statements {
9905            queries.push(query_connection(conn, statement.sql, statement.params)?);
9906            #[cfg(test)]
9907            run_snapshot_read_hook();
9908        }
9909        let mut pending = Vec::new();
9910        let mut missing = Vec::new();
9911        for requested in request.coverage {
9912            let base_key = window_base_key(&requested.base);
9913            let state = persisted_window_state(conn, &requested.base)?;
9914            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
9915                let reference = WindowUnitRef {
9916                    base_key: base_key.clone(),
9917                    unit: unit.clone(),
9918                };
9919                if !state.units.iter().any(|held| held == &unit) {
9920                    missing.push(reference);
9921                } else if state.pending.iter().any(|held| held == &unit) {
9922                    pending.push(reference);
9923                }
9924            }
9925        }
9926        let mut subscriptions = Vec::with_capacity(request.subscriptions.len());
9927        for id in &request.subscriptions {
9928            let row: Option<(String, String)> = conn
9929                .query_row(
9930                    "SELECT tbl, state_json FROM _syncular_subscriptions WHERE id = ?1",
9931                    rusqlite::params![id],
9932                    |row| Ok((row.get(0)?, row.get(1)?)),
9933                )
9934                .optional()
9935                .map_err(|error| {
9936                    stored_failure(error, "persisted subscription state is invalid")
9937                })?;
9938            let Some((table, raw)) = row else {
9939                subscriptions.push(SubscriptionCatchup::Unknown { id: id.clone() });
9940                continue;
9941            };
9942            // Every persisted field is required to be well typed; a corrupt
9943            // record fails typed instead of inventing a state.
9944            let state: Value = serde_json::from_str(&raw)
9945                .map_err(|_| corrupt("persisted subscription state is invalid"))?;
9946            let status = match state.get("status").and_then(Value::as_str) {
9947                Some(value @ ("active" | "revoked" | "failed")) => value,
9948                _ => return Err(corrupt("persisted subscription state is invalid")),
9949            };
9950            let Some(cursor) = state.get("cursor").and_then(Value::as_i64) else {
9951                return Err(corrupt("persisted subscription state is invalid"));
9952            };
9953            if cursor.unsigned_abs() > MAX_JS_SAFE_INTEGER {
9954                return Err(corrupt("persisted subscription state is invalid"));
9955            }
9956            let effective_scopes = state
9957                .get("effectiveScopes")
9958                .filter(|value| !value.is_null());
9959            if let Some(scopes) = effective_scopes {
9960                json_to_scope_map(scopes)
9961                    .map_err(|_| corrupt("persisted subscription state is invalid"))?;
9962            }
9963            let has_resume_token = match state.get("bootstrapState") {
9964                None | Some(Value::Null) => false,
9965                Some(Value::String(_)) => true,
9966                Some(_) => return Err(corrupt("persisted subscription state is invalid")),
9967            };
9968            // A present reasonCode must be a string; a non-string value is a
9969            // corrupt record, never silently dropped.
9970            let reason_code = match state.get("reasonCode") {
9971                None | Some(Value::Null) => None,
9972                Some(Value::String(value)) => Some(value.clone()),
9973                Some(_) => return Err(corrupt("persisted subscription state is invalid")),
9974            };
9975            let active = status == "active";
9976            subscriptions.push(SubscriptionCatchup::Known {
9977                id: id.clone(),
9978                table,
9979                status: status.to_owned(),
9980                cursor,
9981                has_resume_token,
9982                bootstrap_complete: active && cursor >= 0 && !has_resume_token,
9983                known_pending_pages: active && (has_resume_token || cursor < 0),
9984                effective_scopes: effective_scopes.cloned(),
9985                reason_code,
9986            });
9987        }
9988        let mut deliveries = Vec::with_capacity(request.commit_ids.len());
9989        for id in &request.commit_ids {
9990            let pending: bool = conn.query_row(
9991                "SELECT EXISTS(SELECT 1 FROM _syncular_outbox WHERE commit_id = ?1)",
9992                rusqlite::params![id],
9993                |row| row.get(0),
9994            )?;
9995            if pending {
9996                deliveries.push(CommitDelivery::Pending {
9997                    client_commit_id: id.clone(),
9998                });
9999                continue;
10000            }
10001            let row = conn
10002                .query_row(
10003                    "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
10004                            resolution, resolved_at_ms, replacement_client_commit_id
10005                       FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
10006                    rusqlite::params![id],
10007                    StoredCommitOutcomeRow::from_row,
10008                )
10009                .optional()
10010                .map_err(|error| stored_failure(error, "persisted commit outcome is invalid"))?;
10011            deliveries.push(match row {
10012                Some(row) => CommitDelivery::Known {
10013                    client_commit_id: id.clone(),
10014                    outcome: SyncClient::outcome_from_row(row)
10015                        .map_err(|_| corrupt("persisted commit outcome is invalid"))?,
10016                },
10017                None => CommitDelivery::Unknown {
10018                    client_commit_id: id.clone(),
10019                },
10020            });
10021        }
10022        Ok(SnapshotRead {
10023            revision,
10024            queries,
10025            coverage: CoverageSnapshot {
10026                complete: pending.is_empty() && missing.is_empty(),
10027                pending,
10028                missing,
10029            },
10030            subscriptions,
10031            deliveries,
10032        })
10033    })();
10034    match result {
10035        Ok(read) => {
10036            conn.execute_batch("RELEASE syncular_snapshot_read")?;
10037            Ok(read)
10038        }
10039        Err(mut error) => {
10040            if let Err(rollback) = conn
10041                .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
10042            {
10043                error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
10044            }
10045            Err(error)
10046        }
10047    }
10048}
10049
10050fn snapshot_connection(
10051    conn: &Connection,
10052    sql: &str,
10053    params: &[Value],
10054    coverage: &[WindowCoverage],
10055) -> Result<QuerySnapshot, QueryReadFailure> {
10056    let read = snapshot_read_connection(
10057        conn,
10058        &SnapshotReadRequest {
10059            statements: vec![SnapshotStatement { sql, params }],
10060            coverage,
10061            subscriptions: Vec::new(),
10062            commit_ids: Vec::new(),
10063        },
10064    )?;
10065    Ok(QuerySnapshot {
10066        revision: read.revision,
10067        rows: read
10068            .queries
10069            .into_iter()
10070            .next()
10071            .expect("single-query snapshot returns one result"),
10072        coverage: read.coverage,
10073    })
10074}
10075
10076/// A long-lived read-only SQLite sidecar for latency-critical native views.
10077/// Network rounds stay serialized on the mutable core owner, while atomic
10078/// query snapshots use this independent connection and therefore never queue
10079/// behind HTTP/WebSocket latency.
10080pub struct FileQuerySnapshotReader {
10081    path: String,
10082    conn: Option<Connection>,
10083}
10084
10085impl FileQuerySnapshotReader {
10086    #[must_use]
10087    pub fn new(path: impl Into<String>) -> Self {
10088        Self {
10089            path: path.into(),
10090            conn: None,
10091        }
10092    }
10093
10094    fn connection(&mut self) -> Result<&Connection, QueryReadFailure> {
10095        if self.conn.is_none() {
10096            let conn = Connection::open_with_flags(
10097                &self.path,
10098                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
10099            )
10100            .map_err(QueryReadFailure::from)?;
10101            conn.busy_timeout(std::time::Duration::from_millis(250))
10102                .map_err(QueryReadFailure::from)?;
10103            self.conn = Some(conn);
10104        }
10105        self.conn
10106            .as_ref()
10107            .ok_or_else(|| QueryReadFailure::from("read sidecar connection missing".to_owned()))
10108    }
10109
10110    /// One atomic read over the shared primitive: every statement, coverage,
10111    /// catch-up, and delivery under one `SAVEPOINT` and one revision.
10112    pub fn snapshot_read(
10113        &mut self,
10114        request: &SnapshotReadRequest<'_>,
10115    ) -> Result<SnapshotRead, QueryReadFailure> {
10116        snapshot_read_connection(self.connection()?, request)
10117    }
10118
10119    /// The current durable local revision. A host can skip a read when it is
10120    /// unchanged; a corrupt or unreadable marker fails typed.
10121    pub fn local_revision(&mut self) -> Result<String, QueryReadFailure> {
10122        Ok(self
10123            .snapshot_read(&SnapshotReadRequest::default())?
10124            .revision)
10125    }
10126
10127    /// One subscription's persisted catch-up state (see the §7.5 contract).
10128    pub fn subscription_catchup(
10129        &mut self,
10130        id: &str,
10131    ) -> Result<SubscriptionCatchup, QueryReadFailure> {
10132        let read = self.snapshot_read(&SnapshotReadRequest {
10133            subscriptions: vec![id.to_owned()],
10134            ..Default::default()
10135        })?;
10136        Ok(read
10137            .subscriptions
10138            .into_iter()
10139            .next()
10140            .expect("one requested catch-up"))
10141    }
10142
10143    /// One client commit's delivery status: pending, its retained outcome, or
10144    /// an explicit unknown (never held, or pruned by retention).
10145    pub fn commit_delivery(&mut self, id: &str) -> Result<CommitDelivery, QueryReadFailure> {
10146        let read = self.snapshot_read(&SnapshotReadRequest {
10147            commit_ids: vec![id.to_owned()],
10148            ..Default::default()
10149        })?;
10150        Ok(read
10151            .deliveries
10152            .into_iter()
10153            .next()
10154            .expect("one requested delivery"))
10155    }
10156
10157    /// Hosts report an owned failure, and the first success after it, to the
10158    /// owning core through [`SyncClient::record_query_read`] (§7.6).
10159    pub fn query_snapshot(
10160        &mut self,
10161        sql: &str,
10162        params: &[Value],
10163        coverage: &[WindowCoverage],
10164    ) -> Result<QuerySnapshot, QueryReadFailure> {
10165        snapshot_connection(self.connection()?, sql, params, coverage)
10166    }
10167}
10168
10169fn validate_authority_reads(
10170    schema: &ClientSchema,
10171    declarations: &[crate::api::AuthorityReadDeclaration],
10172) -> Result<(), String> {
10173    let mut seen = BTreeSet::new();
10174    for read in declarations {
10175        let table = schema
10176            .table(&read.table)
10177            .ok_or("client.authority_read_forbidden: authority table is not declared")?;
10178        let plain = |name: &str| {
10179            table.columns.iter().enumerate().any(|(i, c)| {
10180                c.name == name
10181                    && name != "authority_version"
10182                    && !name.starts_with("_sync")
10183                    && !table.encrypted_columns.iter().any(|e| e.index == i)
10184                    && matches!(
10185                        c.ty,
10186                        ssp2::segment::ColumnType::String
10187                            | ssp2::segment::ColumnType::Integer
10188                            | ssp2::segment::ColumnType::Float
10189                            | ssp2::segment::ColumnType::Boolean
10190                            | ssp2::segment::ColumnType::Json
10191                    )
10192            })
10193        };
10194        if !seen.insert(&read.table)
10195            || read.columns.is_empty()
10196            || read.columns.iter().collect::<BTreeSet<_>>().len() != read.columns.len()
10197            || !read.columns.contains(&table.primary_key)
10198            || !read.columns.iter().all(|c| plain(c))
10199            || read.scopes.is_empty()
10200            || !read.scopes.iter().all(|(v, values)| {
10201                table
10202                    .scope_column(v)
10203                    .is_some_and(|c| read.columns.iter().any(|column| column == c) && plain(c))
10204                    && !values.is_empty()
10205                    && values.iter().all(|v| !v.is_empty() && v != "*")
10206            })
10207        {
10208            return Err("client.authority_read_forbidden: authority reads require plain columns, a primary key and nonempty plain scope selectors".into());
10209        }
10210    }
10211    Ok(())
10212}
10213
10214// §7.4.2: every open and recreation checks before any persistent writes.
10215/// A marker read that hit a recognized SQLite storage failure keeps that
10216/// classification (BUSY/LOCKED stay retryable); only an unclassified read
10217/// failure is the invalid-marker `sync.local_corrupt`.
10218fn schema_marker_read_failure(error: rusqlite::Error) -> String {
10219    let failure = QueryReadFailure::from(error);
10220    if failure.code.is_some() {
10221        failure.to_string()
10222    } else {
10223        "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned()
10224    }
10225}
10226
10227/// §7.1: reject an invalid configured client limit before any storage is
10228/// opened or written. Zero would wedge the push queue and an out-of-range
10229/// value would differ per target; the TS core enforces the same bounds.
10230fn validate_client_limits(limits: &crate::api::ClientLimits) -> Result<(), String> {
10231    if limits.outcome_retention_max_entries == Some(0) {
10232        return Err("sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned());
10233    }
10234    if [
10235        limits.max_push_commits_per_request,
10236        limits.max_push_operations_per_request,
10237        limits.max_push_request_bytes,
10238    ]
10239    .into_iter()
10240    .flatten()
10241    .any(|value| value == 0 || value > u32::MAX as usize)
10242    {
10243        return Err(
10244            "sync.invalid_request: push limit must be an integer in 1..=4294967295".to_owned(),
10245        );
10246    }
10247    Ok(())
10248}
10249
10250fn read_local_schema_version(conn: &Connection, requested: i32) -> Result<Option<i32>, String> {
10251    let corrupt =
10252        || "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned();
10253    let table_type: Option<String> = conn
10254        .query_row(
10255            "SELECT type FROM sqlite_master WHERE name = '_syncular_meta'",
10256            [],
10257            |row| row.get(0),
10258        )
10259        .optional()
10260        .map_err(schema_marker_read_failure)?;
10261    let Some(table_type) = table_type else {
10262        return Ok(None);
10263    };
10264    if table_type != "table" {
10265        return Err(corrupt());
10266    }
10267    // §7.4.2: a duplicated marker cannot be resolved. `query_row` would take
10268    // whichever row SQLite returns first, and an older one would drive a
10269    // destructive reset over a newer replica.
10270    let mut statement = conn
10271        .prepare("SELECT value FROM _syncular_meta WHERE key = ?1")
10272        .map_err(schema_marker_read_failure)?;
10273    let mut rows = statement
10274        .query([LOCAL_SCHEMA_VERSION_KEY])
10275        .map_err(schema_marker_read_failure)?;
10276    let value: Option<String> = rows
10277        .next()
10278        .map_err(schema_marker_read_failure)?
10279        .map(|row| row.get(0))
10280        .transpose()
10281        .map_err(schema_marker_read_failure)?;
10282    if rows.next().map_err(schema_marker_read_failure)?.is_some() {
10283        return Err(corrupt());
10284    }
10285    let Some(value) = value else {
10286        let has_descriptor = conn
10287            .query_row(
10288                "SELECT 1 FROM _syncular_meta WHERE key = ?1 LIMIT 1",
10289                [crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY],
10290                |_| Ok(()),
10291            )
10292            .optional()
10293            .map_err(schema_marker_read_failure)?
10294            .is_some();
10295        if has_descriptor {
10296            return Err(corrupt());
10297        }
10298        return Ok(None);
10299    };
10300    if !matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
10301        || !value.bytes().all(|b| b.is_ascii_digit())
10302    {
10303        return Err(corrupt());
10304    }
10305    let version = value.parse::<i32>().map_err(|_| corrupt())?;
10306    if version > requested {
10307        return Err(
10308            "client.schema_downgrade: persisted local schema is newer than the requested schema"
10309                .to_owned(),
10310        );
10311    }
10312    Ok(Some(version))
10313}
10314
10315impl SyncClient {
10316    pub fn new_with_identity(
10317        client_id: Option<String>,
10318        schema_json: &Value,
10319        limits: ClientLimits,
10320    ) -> Result<Self, String> {
10321        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
10322        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
10323        Self::with_connection(resolved, schema_json, limits, conn)
10324    }
10325
10326    pub fn new(
10327        client_id: String,
10328        schema_json: &Value,
10329        limits: ClientLimits,
10330    ) -> Result<Self, String> {
10331        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
10332        Self::with_connection(client_id, schema_json, limits, conn)
10333    }
10334
10335    /// Build a client backed by an on-disk SQLite database at `path` — the
10336    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
10337    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
10338    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
10339    /// of the command router's dependency set (the router only holds a path).
10340    pub fn open_path(
10341        client_id: String,
10342        schema_json: &Value,
10343        limits: ClientLimits,
10344        path: &str,
10345    ) -> Result<Self, String> {
10346        validate_client_limits(&limits)?;
10347        parse_schema_json(schema_json)?;
10348        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
10349        Self::with_connection(client_id, schema_json, limits, conn)
10350    }
10351
10352    pub fn open_path_with_identity(
10353        client_id: Option<String>,
10354        schema_json: &Value,
10355        limits: ClientLimits,
10356        path: &str,
10357    ) -> Result<Self, String> {
10358        validate_client_limits(&limits)?;
10359        // §7.4.2: an invalid requested version is refused before the replica is
10360        // even created, so a rejected constructor leaves no file behind.
10361        parse_schema_json(schema_json)?;
10362        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
10363        // A file-backed native client uses an independent read connection for
10364        // latency-critical snapshots. WAL is SQLite's intended reader/writer
10365        // concurrency mode: a view read never holds a rollback-journal lock
10366        // that delays the client's next commit. The busy timeout must precede
10367        // this replica's own startup transaction; WAL is applied only after
10368        // startup succeeded, because a refused open must leave the replica's
10369        // journal mode and contents untouched (§7.4.2).
10370        conn.busy_timeout(std::time::Duration::from_millis(250))
10371            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
10372        let client = Self::with_connection_identity(client_id, schema_json, limits, conn)?;
10373        let journal_mode: String = client
10374            .conn
10375            .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
10376            .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
10377        if journal_mode != "wal" {
10378            return Err(
10379                "sync.invalid_request: file-backed client requires WAL journal mode".to_owned(),
10380            );
10381        }
10382        Ok(client)
10383    }
10384
10385    #[must_use]
10386    pub fn client_id(&self) -> &str {
10387        &self.client_id
10388    }
10389
10390    /// Build a client over a caller-supplied rusqlite connection — the seam a
10391    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
10392    /// with an on-disk database (`Connection::open(path)`) rather than the
10393    /// default `:memory:`. The connection MUST be fresh (no pre-existing
10394    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
10395    /// the same file across process restarts reuses the persisted rows.
10396    pub fn with_connection(
10397        client_id: String,
10398        schema_json: &Value,
10399        limits: ClientLimits,
10400        conn: Connection,
10401    ) -> Result<Self, String> {
10402        Self::with_connection_identity(Some(client_id), schema_json, limits, conn)
10403    }
10404
10405    /// [`Self::with_connection`] for `open_path_with_identity`: no requested id
10406    /// means a durable replica's persisted `clientId` wins over a fresh one.
10407    fn with_connection_identity(
10408        client_id: Option<String>,
10409        schema_json: &Value,
10410        limits: ClientLimits,
10411        conn: Connection,
10412    ) -> Result<Self, String> {
10413        validate_client_limits(&limits)?;
10414        let schema = parse_schema_json(schema_json)?;
10415        validate_authority_reads(&schema, &limits.authority_reads)?;
10416        // RFC 0005 D8: resolve the previous-version config before the opening
10417        // reset runs — the capture happens inside it. Bad bounds fail loud.
10418        let previous_version = match limits.previous_version_context {
10419            Some(config) => {
10420                config.validate()?;
10421                Some(config)
10422            }
10423            None => None,
10424        };
10425        let mut client = SyncClient {
10426            #[cfg(feature = "bench-internals")]
10427            benchmark_phases: Recorder::default(),
10428            conn,
10429            schema,
10430            client_id: client_id
10431                .clone()
10432                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
10433            limits,
10434            subs: Vec::new(),
10435            outbox: Vec::new(),
10436            failed_commits: Vec::new(),
10437            retain_failed_commits: false,
10438            conflicts: Vec::new(),
10439            rejections: Vec::new(),
10440            schema_floor: None,
10441            lease_state: None,
10442            stopped: false,
10443            upgrading: false,
10444            sync_needed: false,
10445            realtime_policy: RealtimePolicy::Optional,
10446            realtime_state: RealtimeState::Disconnected,
10447            realtime_reason_code: None,
10448            realtime_retry_delay_ms: None,
10449            presence: HashMap::new(),
10450            now_ms: None,
10451            encryption: crate::values::EncryptionConfig::default(),
10452            security_preflight: false,
10453            transport_enabled: true,
10454            previous_version,
10455            insert_sql: RefCell::new(HashMap::new()),
10456            overlay_dirty: OverlayDirty::clean(),
10457            storage_failure: RefCell::new(None),
10458            #[cfg(test)]
10459            overlay_rebuild_count: Cell::new(0),
10460            #[cfg(test)]
10461            acknowledged_replay_count: Cell::new(0),
10462            #[cfg(test)]
10463            outcome_prune_count: Cell::new(0),
10464            progress: ProgressObserver::default(),
10465            change_queue: VecDeque::new(),
10466            sync_intent_queue: VecDeque::new(),
10467            retry_delay_ms: 250,
10468            round_retry_delay_ms: None,
10469            active_round: None,
10470            last_round: None,
10471            last_change: None,
10472            query_failures: Vec::new(),
10473        };
10474        // The row write path leans on the prepared-statement cache (two
10475        // insert statements per synced table, plus the bookkeeping
10476        // statements); size it so a multi-table schema never thrashes.
10477        client
10478            .conn
10479            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
10480        // §7.4.2: one write transaction (SQLite `BEGIN IMMEDIATE`, a reserved
10481        // writer) covers the marker read, the identity check, and every
10482        // bookkeeping/schema write, so a concurrent open that upgraded the
10483        // replica cannot interleave between the guard and the writes, and a
10484        // refusal leaves the replica unchanged.
10485        client
10486            .conn
10487            .execute_batch("BEGIN IMMEDIATE")
10488            .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
10489        let startup = (|| -> Result<(), String> {
10490            // The marker guard runs inside the write transaction, so the version
10491            // it validates is the version every write below commits under.
10492            let marker = read_local_schema_version(&client.conn, client.schema.version)?;
10493            // New app indexes may reference columns that only exist after a
10494            // version-bump reset.
10495            client.create_bookkeeping_tables()?;
10496            match client.get_meta(CLIENT_ID_KEY) {
10497                Some(existing) => match client_id {
10498                    Some(requested) if existing != requested => {
10499                        return Err(format!(
10500                            "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
10501                        ))
10502                    }
10503                    _ => client.client_id = existing,
10504                },
10505                None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
10506            }
10507            client.restore_persisted_state()?;
10508            client.failed_commits = client.load_failed_commits()?;
10509            match marker {
10510                None => {
10511                    client.create_synced_tables()?;
10512                    client.persist_schema_version()?;
10513                    client.save_subscription_scope_schema();
10514                }
10515                Some(version) if version == client.schema.version => {
10516                    client.create_synced_tables()?;
10517                    // RFC 0005 D1: the same-version open backfills the descriptor
10518                    // for a database first opened by an unaware binary, so the
10519                    // NEXT bump can capture without needing a schema bump first.
10520                    set_local_schema_descriptor(&client.conn, &client.schema)
10521                        .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
10522                    client.save_subscription_scope_schema();
10523                }
10524                Some(_) => client.run_schema_reset()?,
10525            }
10526            // RFC 0005 D9: the reset sweep cannot see a container whose capture
10527            // committed in its OWN file while the replica's savepoint never
10528            // released — a crash at that seam leaves a container beside a matching
10529            // OLD marker, and the next open at that version runs no reset. Discard
10530            // it before anything can read it. No-op when no container exists.
10531            client.reconcile_previous_version_at_boot()?;
10532            client.clear_satisfied_persisted_schema_floor();
10533            client.prune_unknown_subscriptions(false)?;
10534            // §7.1/§7.4.4: recover intrinsically codec-invalid persisted values at
10535            // the full startup/reset reconciliation boundary, inside this open
10536            // transaction and after the schema and identity guards, so a commit
10537            // an earlier version accepted is dropped with durable rejection
10538            // evidence before any replay. A table the current schema removed
10539            // keeps the §7.4.4 send-time classification, and an unresolved
10540            // encryption key stays with the send seam because hosts configure
10541            // keys after open.
10542            client.drop_unencodable_outbox(false, None)?;
10543            if marker == Some(client.schema.version)
10544                && (!client.outbox.is_empty()
10545                    || !client.failed_commits.is_empty()
10546                    || client.has_acknowledged_rows())
10547            {
10548                // Reconstruct the visible optimistic overlay from the durable
10549                // base plus outbox instead of trusting a process-interrupted
10550                // mirror.
10551                client.overlay_dirty.set(true);
10552                client.rebuild_overlay()?;
10553            }
10554            // Every persisted active subscription needs one catch-up pull on
10555            // open: realtime only covers changes after connection, while an
10556            // idempotent setWindow correctly creates no fresh command effect.
10557            // Pending outbox work has the same restart requirement. The core
10558            // owns this intent so native hosts never poll or require an
10559            // application-issued sync().
10560            client.enqueue_startup_sync_if_needed()?;
10561            Ok(())
10562        })();
10563        match startup {
10564            Ok(()) => match client.conn.execute_batch("COMMIT") {
10565                Ok(()) => Ok(client),
10566                Err(error) => {
10567                    let failure = Self::sqlite_failure(&client.storage_failure, error);
10568                    if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
10569                        // Keep a failed rollback's typed storage classification.
10570                        let _ = Self::sqlite_failure(&client.storage_failure, rollback);
10571                    }
10572                    Err(failure)
10573                }
10574            },
10575            Err(error) => {
10576                if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
10577                    // Keep a failed rollback's typed storage classification.
10578                    let _ = Self::sqlite_failure(&client.storage_failure, rollback);
10579                }
10580                Err(error)
10581            }
10582        }
10583    }
10584
10585    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
10586    /// against this instead of system time (conformance virtual clock).
10587    pub fn set_now_ms(&mut self, now_ms: i64) {
10588        self.now_ms = Some(now_ms);
10589    }
10590
10591    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
10592    /// The command router parses these from the `create` command's
10593    /// `encryption` config (keys as `{$bytes: hex}`).
10594    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
10595        self.encryption = encryption;
10596    }
10597
10598    #[must_use]
10599    pub fn security_lifecycle(&self) -> &'static str {
10600        if self.security_preflight {
10601            "preflight"
10602        } else {
10603            "active"
10604        }
10605    }
10606
10607    #[must_use]
10608    pub fn security_preflight(&self) -> bool {
10609        self.security_preflight
10610    }
10611
10612    /// Host-owned, non-persisted network gate. Local operations remain available.
10613    pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
10614        if self.transport_enabled != enabled {
10615            self.transport_enabled = enabled;
10616            self.sync_intent_queue.clear();
10617            self.sync_intent_queue
10618                .push_back(if enabled && !self.security_preflight {
10619                    SyncIntent::Interactive
10620                } else {
10621                    SyncIntent::None
10622                });
10623        }
10624        // A captured reply keeps its normal apply/revocation semantics.
10625        if !enabled && self.active_round.is_none() {
10626            self.disconnect_realtime(transport);
10627        }
10628    }
10629
10630    pub fn transport_enabled(&self) -> bool {
10631        self.transport_enabled
10632    }
10633
10634    /// Quarantine this replica: enter the fail-closed gate, release all
10635    /// core-owned key material, and record the gate in the database so
10636    /// reopening re-enters preflight until `activate_security` clears it.
10637    ///
10638    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
10639    /// which must NOT leave that durable mark.
10640    pub fn begin_security_preflight(&mut self) {
10641        self.seal_security_on_teardown();
10642        // Persist the quarantine so it survives handle teardown and restart:
10643        // reopening this replica re-enters preflight until activation clears it.
10644        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
10645    }
10646
10647    /// Release core-owned key material as a host tears the client down,
10648    /// WITHOUT recording a quarantine.
10649    ///
10650    /// Shutting an activated client down is not a quarantine event. Persisting
10651    /// the gate here marks every cleanly closed replica as pending, and the
10652    /// next plain `create` is then refused permanently: the reopen path
10653    /// restores the flag from the marker and the create guard rejects it. The
10654    /// in-memory flag still closes the gate for anything still holding this
10655    /// instance, which is all a teardown barrier needs.
10656    pub fn seal_security_on_teardown(&mut self) {
10657        self.cancel_sync_round();
10658        self.security_preflight = true;
10659        self.encryption = crate::values::EncryptionConfig::default();
10660        self.sync_intent_queue.clear();
10661    }
10662
10663    /// Install the post-authentication keyring and release the host loop.
10664    pub fn activate_security(
10665        &mut self,
10666        encryption: crate::values::EncryptionConfig,
10667    ) -> Result<(), String> {
10668        if !self.security_preflight {
10669            return Err(
10670                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
10671            );
10672        }
10673        self.enqueue_startup_sync_if_needed()?;
10674        self.encryption = encryption;
10675        self.security_preflight = false;
10676        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
10677        Ok(())
10678    }
10679
10680    fn clock_now_ms(&self) -> i64 {
10681        self.now_ms.unwrap_or_else(crate::wall_clock_ms)
10682    }
10683
10684    fn create_bookkeeping_tables(&self) -> Result<(), String> {
10685        // Durable client bookkeeping (outbox + subscription + meta).
10686        self.conn
10687            .execute_batch(
10688                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
10689                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
10690                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
10691                 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
10692                   tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
10693                 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
10694                   commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
10695                   commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
10696                   PRIMARY KEY(commit_id, idx));
10697                 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
10698                   seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
10699                   operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
10700                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
10701                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
10702                   client_commit_id TEXT NOT NULL UNIQUE,
10703                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
10704                   recorded_at_ms INTEGER NOT NULL,
10705                   results_json TEXT NOT NULL,
10706                   operations_json TEXT,
10707                   resolution TEXT NOT NULL DEFAULT 'active'
10708                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
10709                   resolved_at_ms INTEGER,
10710                   replacement_client_commit_id TEXT);
10711                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
10712                   ON _syncular_commit_outcomes(resolution, seq);
10713                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
10714                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
10715                 CREATE TABLE IF NOT EXISTS _syncular_meta (
10716                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
10717                 CREATE TABLE IF NOT EXISTS _syncular_windows (
10718                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
10719                   PRIMARY KEY (base, unit));
10720                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
10721                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
10722                   effective_scopes TEXT NOT NULL);
10723                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
10724                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
10725                   PRIMARY KEY(commit_id, blob_id));
10726                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
10727                   ON _syncular_blob_commit_refs(blob_id);",
10728            )
10729            .map_err(|e| e.to_string())?;
10730        // Migrate an outcome journal created before failed aggregate
10731        // envelopes were retained. Historical rows intentionally stay NULL.
10732        let _ = self
10733            .conn
10734            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
10735        if self.get_meta(LOCAL_REVISION_KEY).is_none() {
10736            self.set_meta(LOCAL_REVISION_KEY, "0");
10737        }
10738        // §5.9.7 blob cache. Mutable upload state stays off the body row so
10739        // completing a large upload does not rewrite its BLOB record.
10740        if self.schema_has_blobs() {
10741            let exists = self
10742                .conn
10743                .query_row(
10744                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
10745                    [],
10746                    |row| row.get::<_, bool>(0),
10747                )
10748                .map_err(|e| e.to_string())?;
10749            if exists {
10750                let mut statement = self
10751                    .conn
10752                    .prepare("PRAGMA table_info(_syncular_blobs)")
10753                    .map_err(|e| e.to_string())?;
10754                let mut columns = statement
10755                    .query_map([], |row| row.get::<_, String>(1))
10756                    .map_err(|e| e.to_string())?
10757                    .collect::<Result<Vec<_>, _>>()
10758                    .map_err(|e| e.to_string())?;
10759                columns.sort();
10760                let mut expected = vec![
10761                    "blob_id",
10762                    "byte_length",
10763                    "bytes",
10764                    "created_at_ms",
10765                    "media_type",
10766                ];
10767                expected.sort_unstable();
10768                if columns != expected {
10769                    return Err(
10770                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
10771                    );
10772                }
10773            } else {
10774                self.conn
10775                    .execute_batch(
10776                        "CREATE TABLE _syncular_blobs(
10777                           blob_id TEXT PRIMARY KEY,
10778                           bytes BLOB NOT NULL,
10779                           byte_length INTEGER NOT NULL,
10780                           media_type TEXT,
10781                           created_at_ms INTEGER NOT NULL);",
10782                    )
10783                    .map_err(|e| e.to_string())?;
10784            }
10785            self.conn
10786                .execute_batch(
10787                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
10788                       blob_id TEXT PRIMARY KEY,
10789                       media_type TEXT,
10790                       created_at_ms INTEGER NOT NULL);",
10791                )
10792                .map_err(|e| e.to_string())?;
10793        }
10794        Ok(())
10795    }
10796
10797    /// True iff any synced table declares a `blob_ref` column (§5.9).
10798    fn schema_has_blobs(&self) -> bool {
10799        self.schema
10800            .tables
10801            .iter()
10802            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
10803    }
10804
10805    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
10806
10807    fn get_meta(&self, key: &str) -> Option<String> {
10808        meta_get(&self.conn, key)
10809    }
10810
10811    fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
10812        self.conn
10813            .query_row(
10814                "SELECT value FROM _syncular_meta WHERE key = ?1",
10815                rusqlite::params![key],
10816                |row| row.get::<_, String>(0),
10817            )
10818            .optional()
10819            .map_err(|_| {
10820                "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
10821            })
10822    }
10823
10824    // The caller holds the startup/reset transaction. Either both schema
10825    // records persist or the enclosing operation fails and rolls back.
10826    fn persist_schema_version(&self) -> Result<(), String> {
10827        self.conn
10828            .execute(
10829                "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
10830                rusqlite::params![LOCAL_SCHEMA_VERSION_KEY, self.schema.version.to_string()],
10831            )
10832            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10833        set_local_schema_descriptor(&self.conn, &self.schema)
10834            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
10835    }
10836
10837    fn set_meta(&self, key: &str, value: &str) {
10838        meta_set(&self.conn, key, value);
10839    }
10840
10841    fn delete_meta(&self, key: &str) {
10842        meta_delete(&self.conn, key);
10843    }
10844
10845    /// §7.1: the durable outbox in FIFO order, in schema-agnostic local form.
10846    fn load_outbox(&self) -> Result<Vec<OutboxCommit>, String> {
10847        let mut stmt = self
10848            .conn
10849            .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
10850            .map_err(|error| error.to_string())?;
10851        let rows = stmt
10852            .query_map([], |row| {
10853                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
10854            })
10855            .map_err(|error| error.to_string())?;
10856        let mut commits = Vec::new();
10857        for row in rows {
10858            let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
10859            let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
10860                format!("invalid persisted outbox {client_commit_id:?}: {error}")
10861            })?;
10862            let mut ops = Vec::with_capacity(entries.len());
10863            for entry in entries {
10864                let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
10865                ops.push(OutboxOp {
10866                    upsert: op == "upsert",
10867                    table: entry
10868                        .get("table")
10869                        .and_then(Value::as_str)
10870                        .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
10871                        .to_owned(),
10872                    row_id: entry
10873                        .get("rowId")
10874                        .and_then(Value::as_str)
10875                        .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
10876                        .to_owned(),
10877                    base_version: entry.get("baseVersion").and_then(Value::as_i64),
10878                    // A legacy entry's `changedFields` key is ignored:
10879                    // the values map is the presence set (§6.1).
10880                    values: entry.get("values").and_then(Value::as_object).cloned(),
10881                });
10882            }
10883            commits.push(OutboxCommit {
10884                client_commit_id,
10885                ops,
10886            });
10887        }
10888        Ok(commits)
10889    }
10890
10891    fn restore_persisted_state(&mut self) -> Result<(), String> {
10892        self.subs = {
10893            let mut stmt = self
10894                .conn
10895                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
10896                .map_err(|error| error.to_string())?;
10897            let rows = stmt
10898                .query_map([], |row| {
10899                    Ok((
10900                        row.get::<_, String>(0)?,
10901                        row.get::<_, String>(1)?,
10902                        row.get::<_, String>(2)?,
10903                    ))
10904                })
10905                .map_err(|error| error.to_string())?;
10906            let mut subscriptions = Vec::new();
10907            for row in rows {
10908                let (id, table, raw) = row.map_err(|error| error.to_string())?;
10909                let state: Value = serde_json::from_str(&raw)
10910                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
10911                let requested = json_to_scope_map(
10912                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
10913                )?;
10914                let effective = state
10915                    .get("effectiveScopes")
10916                    .filter(|value| !value.is_null())
10917                    .map(json_to_scope_map)
10918                    .transpose()?;
10919                subscriptions.push(Subscription {
10920                    id,
10921                    table,
10922                    requested,
10923                    params: state
10924                        .get("params")
10925                        .and_then(Value::as_str)
10926                        .map(str::to_owned),
10927                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
10928                    bootstrap_state: state
10929                        .get("bootstrapState")
10930                        .and_then(Value::as_str)
10931                        .map(str::to_owned),
10932                    state: SubState::parse(
10933                        state
10934                            .get("status")
10935                            .and_then(Value::as_str)
10936                            .unwrap_or("active"),
10937                    ),
10938                    reason_code: state
10939                        .get("reasonCode")
10940                        .and_then(Value::as_str)
10941                        .map(str::to_owned),
10942                    effective,
10943                    synced_once: state
10944                        .get("syncedOnce")
10945                        .and_then(Value::as_bool)
10946                        .unwrap_or(false),
10947                });
10948            }
10949            subscriptions
10950        };
10951
10952        self.outbox = self.load_outbox()?;
10953
10954        self.prune_commit_outcomes()?;
10955        let active = self.commit_outcomes(CommitOutcomeQuery {
10956            active_only: true,
10957            ..CommitOutcomeQuery::default()
10958        })?;
10959        self.conflicts = active
10960            .iter()
10961            .flat_map(|outcome| outcome.results.iter())
10962            .filter_map(|result| match result {
10963                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
10964                _ => None,
10965            })
10966            .collect();
10967        self.rejections = active
10968            .iter()
10969            .flat_map(|outcome| outcome.results.iter())
10970            .filter_map(|result| match result {
10971                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
10972                _ => None,
10973            })
10974            .collect();
10975
10976        self.lease_state = self
10977            .get_meta(LEASE_STATE_KEY)
10978            .map(|raw| serde_json::from_str(&raw))
10979            .transpose()
10980            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
10981        self.schema_floor = self
10982            .get_meta(SCHEMA_FLOOR_KEY)
10983            .map(|raw| serde_json::from_str(&raw))
10984            .transpose()
10985            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
10986        self.stopped = self.schema_floor.is_some();
10987        // A replica left in unactivated preflight reopens gated: the quarantine
10988        // decision persists with the data across handle teardown and restart.
10989        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
10990            self.security_preflight = true;
10991        }
10992        Ok(())
10993    }
10994
10995    fn save_subscription_scope_schema(&self) {
10996        let declarations: BTreeMap<_, BTreeMap<_, _>> = self
10997            .schema
10998            .tables
10999            .iter()
11000            .map(|table| {
11001                (
11002                    table.name.clone(),
11003                    table
11004                        .scope_variables
11005                        .iter()
11006                        .map(|scope| {
11007                            (
11008                                scope.variable.clone(),
11009                                [scope.prefix.clone(), scope.column.clone()],
11010                            )
11011                        })
11012                        .collect(),
11013                )
11014            })
11015            .collect();
11016        self.set_meta(
11017            "subscriptionScopeSchema",
11018            &serde_json::to_string(&declarations).expect("scope schema serialization"),
11019        );
11020    }
11021
11022    /// Remove registrations with incompatible table/key/prefix/column meaning.
11023    fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
11024        let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
11025            self.get_meta("subscriptionScopeSchema")
11026                .map(|raw| serde_json::from_str(&raw))
11027                .transpose()
11028                .map_err(|error| format!("invalid persisted scope schema: {error}"))?
11029        } else {
11030            None
11031        };
11032        let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
11033            let Some(table) = self.schema.table(table_name) else {
11034                return false;
11035            };
11036            if requested
11037                .iter()
11038                .any(|(variable, _)| table.scope_column(variable).is_none())
11039            {
11040                return false;
11041            }
11042            if !bump {
11043                return true;
11044            }
11045            let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
11046                return false;
11047            };
11048            let variables: BTreeSet<_> = if requested.is_empty() {
11049                old.keys()
11050                    .cloned()
11051                    .chain(
11052                        table
11053                            .scope_variables
11054                            .iter()
11055                            .map(|scope| scope.variable.clone()),
11056                    )
11057                    .collect()
11058            } else {
11059                requested
11060                    .iter()
11061                    .map(|(variable, _)| variable.clone())
11062                    .collect()
11063            };
11064            variables.iter().all(|variable| {
11065                let current = table
11066                    .scope_variables
11067                    .iter()
11068                    .find(|scope| &scope.variable == variable);
11069                match (old.get(variable), current) {
11070                    (Some(old), Some(current)) => {
11071                        old == &[current.prefix.clone(), current.column.clone()]
11072                    }
11073                    _ => false,
11074                }
11075            })
11076        };
11077        let mut stale_ids: BTreeSet<_> = self
11078            .subs
11079            .iter()
11080            .filter(|sub| !compatible(&sub.table, &sub.requested))
11081            .map(|sub| sub.id.clone())
11082            .collect();
11083        for (id, table, effective) in self.load_pending_evictions()? {
11084            if !compatible(&table, &effective) {
11085                stale_ids.insert(id);
11086            }
11087        }
11088        for id in &stale_ids {
11089            for sql in [
11090                "DELETE FROM _syncular_windows WHERE sub_id = ?1",
11091                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
11092                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
11093            ] {
11094                self.conn
11095                    .execute(sql, rusqlite::params![id])
11096                    .map_err(|error| error.to_string())?;
11097            }
11098        }
11099        self.subs.retain(|sub| !stale_ids.contains(&sub.id));
11100        Ok(())
11101    }
11102
11103    #[must_use]
11104    pub fn local_revision(&self) -> u64 {
11105        self.get_meta(LOCAL_REVISION_KEY)
11106            .and_then(|value| value.parse().ok())
11107            .unwrap_or(0)
11108    }
11109
11110    /// §7.5: resolve a snapshot read on the mutable owner connection through
11111    /// the same primitive the read-only sidecar uses, so the command surface
11112    /// and the file sidecar return one revision, rows, coverage, catch-up, and
11113    /// delivery shape. The structured [`QueryReadFailure`] is preserved; an
11114    /// `owner` records its failure (and its next success) in diagnostics.
11115    pub fn snapshot_read(
11116        &mut self,
11117        request: &SnapshotReadRequest<'_>,
11118        owner: Option<QueryOwner<'_>>,
11119    ) -> Result<SnapshotRead, QueryReadFailure> {
11120        let result = snapshot_read_connection(&self.conn, request);
11121        if let Some(owner) = owner {
11122            self.record_query_read(owner, result.as_ref().err());
11123        }
11124        result
11125    }
11126
11127    #[must_use]
11128    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
11129        SyncStatusSnapshot {
11130            current_schema_version: self.schema.version,
11131            outbox: self.outbox.len(),
11132            upgrading: self.upgrading,
11133            lease_state: self.lease_state.clone(),
11134            schema_floor: self.schema_floor.clone(),
11135            sync_needed: self.sync_needed,
11136            previous_version_context: self.previous_version_status(),
11137        }
11138    }
11139
11140    /// RFC 0005 A2: container presence for a host that must act on it (Diego's
11141    /// rollback precondition), read without creating the file.
11142    fn previous_version_status(&self) -> PreviousVersionStatus {
11143        let Some(replica_path) = self.previous_version_replica_path() else {
11144            return PreviousVersionStatus {
11145                present: false,
11146                created_at_ms: None,
11147            };
11148        };
11149        match crate::previous_version::read_previous_version_record(&replica_path) {
11150            Ok(Some(record)) => PreviousVersionStatus {
11151                present: true,
11152                created_at_ms: Some(record.created_at_ms),
11153            },
11154            _ => PreviousVersionStatus {
11155                present: std::path::Path::new(
11156                    &crate::previous_version::previous_version_container_path(&replica_path),
11157                )
11158                .exists(),
11159                created_at_ms: None,
11160            },
11161        }
11162    }
11163
11164    /// RFC 0005 D7: read captured rows from the previous local schema. `state`
11165    /// is always `previousVersion`; the container never makes
11166    /// `query_snapshot()` coverage complete. Lease stop/expiry, scope
11167    /// revocation, coverage completion and the TTL DISCARD the container and
11168    /// close the read with their own reason.
11169    pub fn previous_version_snapshot(
11170        &self,
11171        spec: &PreviousVersionReadSpec,
11172    ) -> Result<PreviousVersionSnapshot, String> {
11173        let now_ms = self.clock_now_ms();
11174        crate::previous_version::previous_version_snapshot(
11175            &self.conn,
11176            self.previous_version_replica_path().as_deref(),
11177            self.previous_version.as_ref(),
11178            PreviousVersionLifecycle {
11179                lease_inactive: self.previous_version_lease_inactive(now_ms),
11180                scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
11181                coverage_complete: self.previous_version_coverage_complete(),
11182            },
11183            now_ms,
11184            self.schema.version,
11185            spec,
11186        )
11187    }
11188
11189    /// RFC 0005 D6: the advisory pre-reset compatibility audit.
11190    #[must_use]
11191    pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
11192        crate::previous_version::stored_previous_version_audit(&self.conn)
11193    }
11194
11195    /// RFC 0005 A2/D9: the executable downgrade step. Drop the container file
11196    /// and both metadata records, and report whether anything was present.
11197    /// Idempotent: absence is a successful no-op.
11198    pub fn previous_version_discard(
11199        &self,
11200    ) -> crate::previous_version::PreviousVersionDiscardOutcome {
11201        self.drop_previous_version()
11202    }
11203
11204    /// The module's discard has no failure path (it removes the file even when
11205    /// the container cannot be opened), so the only error a caller could ever
11206    /// see is a bug; report absence rather than claiming a discard happened.
11207    fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
11208        let absent = crate::previous_version::PreviousVersionDiscardOutcome {
11209            present: false,
11210            discarded: false,
11211        };
11212        let Some(replica_path) = self.previous_version_replica_path() else {
11213            return absent;
11214        };
11215        crate::previous_version::discard_previous_version(&self.conn, &replica_path)
11216            .unwrap_or(absent)
11217    }
11218
11219    /// RFC 0005 D7: `leaseState.errorCode` or an expired lease.
11220    fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
11221        let Some(lease) = &self.lease_state else {
11222            return false;
11223        };
11224        if lease.error_code.is_some() {
11225            return true;
11226        }
11227        lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
11228    }
11229
11230    /// RFC 0005 D7: §7.4.5 replacement coverage — every ACTIVE subscription
11231    /// has `cursor >= 0` and no resume token. An empty active set is not
11232    /// completion (mirrors the TS predicate exactly).
11233    fn previous_version_coverage_complete(&self) -> bool {
11234        let active = self
11235            .subs
11236            .iter()
11237            .filter(|sub| sub.state == SubState::Active)
11238            .collect::<Vec<_>>();
11239        if active.is_empty() {
11240            return false;
11241        }
11242        active
11243            .iter()
11244            .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
11245    }
11246
11247    /// RFC 0005 D7: lifetime triggers evaluated once per sync round, after the
11248    /// apply. A container that no longer has a reason to exist is discarded so
11249    /// the rows do not outlive the window they were kept for.
11250    fn previous_version_lifetime_check(&self) {
11251        let Some(config) = self.previous_version.filter(|config| config.enabled) else {
11252            return;
11253        };
11254        let now_ms = self.clock_now_ms();
11255        if self.previous_version_lease_inactive(now_ms) {
11256            let _ = self.drop_previous_version();
11257            return;
11258        }
11259        let Some(replica_path) = self.previous_version_replica_path() else {
11260            return;
11261        };
11262        let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
11263        else {
11264            return;
11265        };
11266        if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
11267            let _ = self.drop_previous_version();
11268            return;
11269        }
11270        if now_ms - record.created_at_ms > config.max_age_ms {
11271            let _ = self.drop_previous_version();
11272            return;
11273        }
11274        if self.previous_version_coverage_complete() {
11275            let _ = self.drop_previous_version();
11276        }
11277    }
11278
11279    pub fn diagnostics_snapshot(
11280        &self,
11281        request: &ClientDiagnosticsRequest,
11282    ) -> Result<ClientDiagnosticsSnapshot, String> {
11283        if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
11284            return Err(format!(
11285                "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
11286            ));
11287        }
11288        let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
11289        for sub in &self.subs {
11290            let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
11291            let complete =
11292                sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
11293            let state = match sub.state {
11294                SubState::Revoked => "revoked",
11295                SubState::Failed => "failed",
11296                SubState::Active if reset => "reset",
11297                SubState::Active if complete => "complete",
11298                SubState::Active => "bootstrapping",
11299            };
11300            subscriptions.insert(
11301                sub.id.clone(),
11302                DiagnosticSubscription {
11303                    id: sub.id.clone(),
11304                    table: sub.table.clone(),
11305                    state: state.to_owned(),
11306                    complete,
11307                    cursor: Some(sub.cursor),
11308                    reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
11309                },
11310            );
11311        }
11312        for expected in &request.expected_subscriptions {
11313            if expected.id.is_empty() || expected.table.is_empty() {
11314                return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
11315            }
11316            if subscriptions
11317                .get(&expected.id)
11318                .is_some_and(|registered| registered.table != expected.table)
11319            {
11320                subscriptions.insert(
11321                    expected.id.clone(),
11322                    DiagnosticSubscription {
11323                        id: expected.id.clone(),
11324                        table: expected.table.clone(),
11325                        state: "failed".to_owned(),
11326                        complete: false,
11327                        cursor: None,
11328                        reason_code: Some("client.subscription_intent_mismatch".to_owned()),
11329                    },
11330                );
11331            } else {
11332                subscriptions.entry(expected.id.clone()).or_insert_with(|| {
11333                    DiagnosticSubscription {
11334                        id: expected.id.clone(),
11335                        table: expected.table.clone(),
11336                        state: "unregistered".to_owned(),
11337                        complete: false,
11338                        cursor: None,
11339                        reason_code: None,
11340                    }
11341                });
11342            }
11343        }
11344        let mut ordered_subscriptions = Vec::new();
11345        let mut included = BTreeSet::new();
11346        for expected in &request.expected_subscriptions {
11347            if included.insert(expected.id.clone()) {
11348                if let Some(subscription) = subscriptions.get(&expected.id) {
11349                    ordered_subscriptions.push(subscription.clone());
11350                }
11351            }
11352        }
11353        for (id, subscription) in &subscriptions {
11354            if included.insert(id.clone()) {
11355                ordered_subscriptions.push(subscription.clone());
11356            }
11357        }
11358        let subscriptions_truncated =
11359            ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
11360        ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
11361        let captured_at_ms = self.clock_now_ms();
11362        let lease = if let Some(error_code) = self
11363            .lease_state
11364            .as_ref()
11365            .and_then(|state| state.error_code.clone())
11366        {
11367            ClientDiagnosticsLease {
11368                state: "stopped".to_owned(),
11369                expires_at_ms: self
11370                    .lease_state
11371                    .as_ref()
11372                    .and_then(|state| state.expires_at_ms),
11373                error_code: Some(Self::diagnostic_code(&error_code)),
11374            }
11375        } else if let Some(expires_at_ms) = self
11376            .lease_state
11377            .as_ref()
11378            .and_then(|state| state.expires_at_ms)
11379        {
11380            ClientDiagnosticsLease {
11381                state: if expires_at_ms <= captured_at_ms {
11382                    "expired".to_owned()
11383                } else {
11384                    "active".to_owned()
11385                },
11386                expires_at_ms: Some(expires_at_ms),
11387                error_code: None,
11388            }
11389        } else {
11390            ClientDiagnosticsLease {
11391                state: "none".to_owned(),
11392                expires_at_ms: None,
11393                error_code: None,
11394            }
11395        };
11396        let connectivity = match self.last_round.as_ref() {
11397            _ if !self.transport_enabled && self.active_round.is_none() => "offline",
11398            Some(round) if round.status == "succeeded" => "online",
11399            Some(round)
11400                if round.status == "failed"
11401                    && round
11402                        .error_code
11403                        .as_deref()
11404                        .is_some_and(Self::retryable_transport_code) =>
11405            {
11406                "offline"
11407            }
11408            _ => "unknown",
11409        };
11410        Ok(ClientDiagnosticsSnapshot {
11411            version: CLIENT_DIAGNOSTICS_VERSION,
11412            captured_at_ms,
11413            host: ClientDiagnosticsHost {
11414                kind: "direct".to_owned(),
11415                role: "single".to_owned(),
11416                connectivity: connectivity.to_owned(),
11417                realtime: self.realtime_state,
11418                realtime_policy: self.realtime_policy,
11419                realtime_reason_code: self.realtime_reason_code.clone(),
11420                realtime_retry_delay_ms: self.realtime_retry_delay_ms,
11421            },
11422            security_lifecycle: self.security_lifecycle().to_owned(),
11423            schema: ClientDiagnosticsSchema {
11424                current_version: self.schema.version,
11425                upgrading: self.upgrading,
11426                required_version: self
11427                    .schema_floor
11428                    .as_ref()
11429                    .and_then(|floor| floor.required_schema_version),
11430                latest_version: self
11431                    .schema_floor
11432                    .as_ref()
11433                    .and_then(|floor| floor.latest_schema_version),
11434            },
11435            replica: ClientDiagnosticsReplica {
11436                local_revision: self.local_revision().to_string(),
11437                sync_needed: self.sync_needed,
11438                pending_outbox: self.outbox.len(),
11439            },
11440            lease,
11441            subscriptions: ordered_subscriptions,
11442            subscriptions_truncated,
11443            last_round: self.last_round.clone(),
11444            last_change: self.last_change.clone(),
11445            storage: self.diagnostics_storage(),
11446            query_failures: self.query_failures.clone(),
11447        })
11448    }
11449
11450    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
11451        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
11452            let page_count: i64 = self
11453                .conn
11454                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
11455            let page_size: i64 = self
11456                .conn
11457                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
11458            // Reuse compiled aggregates; every invocation still reads current storage.
11459            let outbox_bytes: i64 = self
11460                .conn
11461                .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
11462                .query_row([], |row| row.get(0))?;
11463            let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
11464                "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
11465            )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
11466            let blob_bytes = if self.schema_has_blobs() {
11467                self.conn
11468                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
11469                    .query_row([], |row| row.get(0))?
11470            } else {
11471                0
11472            };
11473            let pressure = self
11474                .limits
11475                .blob_cache_max_bytes
11476                .is_some_and(|limit| blob_bytes > limit);
11477            Ok(ClientDiagnosticsStorage {
11478                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
11479                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
11480                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
11481                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
11482                retained_outcome_entries: Some(outcome_entries.max(0)),
11483                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
11484                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
11485            })
11486        };
11487        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
11488            status: "unreadable".to_owned(),
11489            database_bytes_approx: None,
11490            pending_outbox_bytes_approx: None,
11491            retained_outcome_bytes_approx: None,
11492            retained_outcome_entries: None,
11493            blob_cache_bytes_approx: None,
11494            pressure_reason_code: None,
11495        })
11496    }
11497
11498    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
11499        self.change_queue.drain(..).collect()
11500    }
11501
11502    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
11503        let intents = self.sync_intent_queue.drain(..).collect();
11504        if self.transport_enabled {
11505            intents
11506        } else {
11507            vec![SyncIntent::None]
11508        }
11509    }
11510
11511    fn schedule_background_retry(&mut self) -> u64 {
11512        let delay_ms = self.retry_delay_ms;
11513        self.sync_intent_queue
11514            .push_back(SyncIntent::Background { delay_ms });
11515        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
11516        self.round_retry_delay_ms = Some(delay_ms);
11517        delay_ms
11518    }
11519
11520    fn reset_background_retry(&mut self) {
11521        self.retry_delay_ms = 250;
11522    }
11523
11524    fn retryable_transport_code(code: &str) -> bool {
11525        code == "transport.failed"
11526            || code == "transport.unavailable"
11527            || code == "sync.transport_failed"
11528    }
11529
11530    /// A failed round worth a background retry: a transport failure, or a
11531    /// §10.2 code the catalog marks retryable (clients may hardcode the
11532    /// catalog metadata, §10.1). Matches the TypeScript core, which retries
11533    /// on the server's `retryable` flag.
11534    fn retryable_failure_code(code: &str) -> bool {
11535        Self::retryable_transport_code(code)
11536            || matches!(
11537                code,
11538                "sync.auth_required"
11539                    | "sync.auth_lease_required"
11540                    | "sync.auth_lease_revoked"
11541                    | "sync.internal_error"
11542                    | "sync.idempotency_cache_miss"
11543                    | "sync.schema_not_ready"
11544                    | "sync.segment_expired"
11545                    | "sync.rate_limited"
11546                    | "sync.websocket_connection_limit"
11547            )
11548    }
11549
11550    fn diagnostic_code(code: &str) -> String {
11551        let valid = !code.is_empty()
11552            && code.len() <= 96
11553            && code.contains('.')
11554            && code
11555                .bytes()
11556                .next()
11557                .is_some_and(|byte| byte.is_ascii_lowercase())
11558            && code.bytes().all(|byte| {
11559                byte.is_ascii_lowercase()
11560                    || byte.is_ascii_digit()
11561                    || matches!(byte, b'.' | b'_' | b'-')
11562            });
11563        if valid {
11564            code.to_owned()
11565        } else {
11566            "client.unknown_failure".to_owned()
11567        }
11568    }
11569
11570    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
11571        if self.sync_needed != value {
11572            if self.begin_observation("syncular_status").is_ok() {
11573                self.sync_needed = value;
11574                let batch = ChangeAccumulator {
11575                    status: true,
11576                    ..ChangeAccumulator::default()
11577                };
11578                if self.finish_observation("syncular_status", batch).is_err() {
11579                    self.rollback_observation("syncular_status");
11580                }
11581            } else {
11582                self.sync_needed = value;
11583            }
11584        }
11585        if value && interactive {
11586            self.sync_intent_queue.push_back(SyncIntent::Interactive);
11587        }
11588    }
11589
11590    fn sqlite_failure(
11591        storage_failure: &RefCell<Option<QueryReadFailure>>,
11592        error: rusqlite::Error,
11593    ) -> String {
11594        let message = error.to_string();
11595        let failure = QueryReadFailure::from(error);
11596        let code = failure.code;
11597        if code.is_some() && storage_failure.borrow().is_none() {
11598            *storage_failure.borrow_mut() = Some(failure);
11599        }
11600        match code {
11601            Some(code) => format!("{code}: {message}"),
11602            None => message,
11603        }
11604    }
11605
11606    fn begin_observation(&self, name: &str) -> Result<(), String> {
11607        self.conn
11608            .execute_batch(&format!("SAVEPOINT {name}"))
11609            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
11610    }
11611
11612    fn rollback_observation(&self, name: &str) {
11613        if let Err(error) = self
11614            .conn
11615            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
11616        {
11617            if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
11618                if failure.rollback_failure.is_none() {
11619                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
11620                }
11621            }
11622        }
11623    }
11624
11625    fn finish_observation(
11626        &mut self,
11627        name: &str,
11628        mut batch: ChangeAccumulator,
11629    ) -> Result<(), String> {
11630        #[cfg(feature = "bench-internals")]
11631        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
11632        if self.failed_commits.iter().any(|(commit, _)| {
11633            commit
11634                .ops
11635                .iter()
11636                .any(|operation| batch.tables.contains_key(&operation.table))
11637        }) {
11638            batch.outcomes = true;
11639        }
11640        if !batch.touched() {
11641            self.conn
11642                .execute_batch(&format!("RELEASE {name}"))
11643                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11644            return Ok(());
11645        }
11646        let revision = self
11647            .local_revision()
11648            .checked_add(1)
11649            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
11650        self.conn
11651            .execute(
11652                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11653                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
11654            )
11655            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11656        let status = batch.status.then(|| self.status_snapshot());
11657        let event = ClientChangeBatch {
11658            revision: revision.to_string(),
11659            tables: batch
11660                .tables
11661                .into_iter()
11662                .map(|(table, scope_keys)| TableChange {
11663                    table,
11664                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
11665                })
11666                .collect(),
11667            windows: batch
11668                .windows
11669                .into_iter()
11670                .map(|((base_key, table), units)| WindowChange {
11671                    base_key,
11672                    table,
11673                    units: units.into_iter().collect(),
11674                })
11675                .collect(),
11676            status,
11677            conflicts_changed: batch.conflicts,
11678            rejections_changed: batch.rejections,
11679            outcomes_changed: batch.outcomes,
11680        };
11681        self.conn
11682            .execute_batch(&format!("RELEASE {name}"))
11683            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11684        let mut diagnostic_tables = event
11685            .tables
11686            .iter()
11687            .map(|entry| entry.table.clone())
11688            .collect::<BTreeSet<_>>()
11689            .into_iter()
11690            .collect::<Vec<_>>();
11691        let mut diagnostic_windows = event
11692            .windows
11693            .iter()
11694            .map(|entry| entry.table.clone())
11695            .collect::<BTreeSet<_>>()
11696            .into_iter()
11697            .collect::<Vec<_>>();
11698        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
11699            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
11700        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
11701        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
11702        self.last_change = Some(DiagnosticLastChange {
11703            revision: event.revision.clone(),
11704            recorded_at_ms: self.clock_now_ms(),
11705            tables: diagnostic_tables,
11706            windows: diagnostic_windows,
11707            domains_truncated,
11708            status_changed: event.status.is_some(),
11709            conflicts_changed: event.conflicts_changed,
11710            rejections_changed: event.rejections_changed,
11711            outcomes_changed: event.outcomes_changed,
11712        });
11713        self.change_queue.push_back(event);
11714        Ok(())
11715    }
11716
11717    fn record_scope_map(
11718        &self,
11719        batch: &mut ChangeAccumulator,
11720        table_name: &str,
11721        scopes: &[(String, Vec<String>)],
11722    ) {
11723        let Some(table) = self.schema.table(table_name) else {
11724            return;
11725        };
11726        for (variable, values) in scopes {
11727            let Some(scope) = table
11728                .scope_variables
11729                .iter()
11730                .find(|scope| &scope.variable == variable)
11731            else {
11732                continue;
11733            };
11734            for value in values {
11735                batch.scope(table_name, format!("{}:{value}", scope.prefix));
11736            }
11737        }
11738    }
11739
11740    /// Record a row's current scope keys from the base or visible table.
11741    fn record_row_scopes(
11742        &self,
11743        batch: &mut ChangeAccumulator,
11744        table_name: &str,
11745        row_id: &str,
11746        base: bool,
11747    ) -> bool {
11748        let Some(table) = self.schema.table(table_name) else {
11749            return false;
11750        };
11751        if table.scope_variables.is_empty() {
11752            return false;
11753        }
11754        let columns = table
11755            .scope_variables
11756            .iter()
11757            .map(|scope| quote_ident(&scope.column))
11758            .collect::<Vec<_>>()
11759            .join(", ");
11760        let full_table = if base {
11761            base_table(table_name)
11762        } else {
11763            visible_table(table_name)
11764        };
11765        let sql = format!(
11766            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
11767            row_id_predicate(table)
11768        );
11769        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
11770            return false;
11771        };
11772        let values = stmt.query_row(rusqlite::params![row_id], |row| {
11773            let mut values = Vec::with_capacity(table.scope_variables.len());
11774            for index in 0..table.scope_variables.len() {
11775                values.push(row.get::<_, Option<String>>(index)?);
11776            }
11777            Ok(values)
11778        });
11779        let Ok(values) = values else {
11780            return false;
11781        };
11782        let mut recorded = false;
11783        for (scope, value) in table.scope_variables.iter().zip(values) {
11784            if let Some(value) = value {
11785                batch.scope(table_name, format!("{}:{value}", scope.prefix));
11786                recorded = true;
11787            }
11788        }
11789        recorded
11790    }
11791
11792    fn record_commit_changes(
11793        &self,
11794        batch: &mut ChangeAccumulator,
11795        tables: &[String],
11796        changes: &[ssp2::model::Change],
11797    ) {
11798        #[cfg(feature = "bench-internals")]
11799        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
11800        for change in changes {
11801            let Some(table_name) = tables.get(change.table_index as usize) else {
11802                continue;
11803            };
11804            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
11805            if let Some(table) = self.schema.table(table_name) {
11806                for (variable, value) in &change.scopes {
11807                    if let Some(scope) = table
11808                        .scope_variables
11809                        .iter()
11810                        .find(|scope| &scope.variable == variable)
11811                    {
11812                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
11813                        precise = true;
11814                    }
11815                }
11816            }
11817            if !precise {
11818                batch.table(table_name);
11819            }
11820        }
11821    }
11822
11823    fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
11824        if effective.is_empty() {
11825            return false;
11826        }
11827        let Some(table) = self.schema.table(table_name) else {
11828            return false;
11829        };
11830        let mut clauses = Vec::new();
11831        let mut params = Vec::new();
11832        for (variable, values) in effective {
11833            let Some(column) = table.scope_column(variable) else {
11834                return false;
11835            };
11836            if values.is_empty() {
11837                return false;
11838            }
11839            let placeholders = values
11840                .iter()
11841                .map(|value| {
11842                    params.push(SqlValue::Text(value.clone()));
11843                    "?"
11844                })
11845                .collect::<Vec<_>>()
11846                .join(", ");
11847            clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
11848        }
11849        let sql = format!(
11850            "SELECT 1 FROM {} WHERE {} LIMIT 1",
11851            base_table(table_name),
11852            clauses.join(" AND ")
11853        );
11854        self.conn
11855            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
11856            .is_ok()
11857    }
11858
11859    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
11860    pub fn upgrading(&self) -> bool {
11861        self.upgrading
11862    }
11863
11864    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
11865    /// keeping this client's local database (identity, outbox, tables). The
11866    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
11867    /// version increases. Recreation runs the same marker guard as opening
11868    /// a durable replica, holds the guard and every write it authorizes under
11869    /// one write transaction, and preserves the current client (compiled
11870    /// schema, outbox, tables) on refusal.
11871    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
11872        let new_schema = parse_schema_json(schema_json)?;
11873        validate_authority_reads(&new_schema, &self.limits.authority_reads)?;
11874        self.conn
11875            .execute_batch("BEGIN IMMEDIATE")
11876            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11877        let prior_schema = std::mem::replace(&mut self.schema, new_schema);
11878        // §7.4.3/§7.4.4: a reset inside this transaction mutates in-memory
11879        // state (dropped outbox commits, rejections, upgrade and floor flags,
11880        // overlay dirtiness) and appends change/intent batches. A COMMIT that
11881        // fails after the reset succeeded rolls the database back, so every
11882        // one of those fields is captured here and restored with it.
11883        let prior_subs = self.subs.clone();
11884        let prior_outbox = self.outbox.clone();
11885        let prior_rejections = self.rejections.clone();
11886        let prior_upgrading = self.upgrading;
11887        let prior_stopped = self.stopped;
11888        let prior_schema_floor = self.schema_floor.clone();
11889        let prior_overlay_dirty = self.overlay_dirty.snapshot();
11890        let prior_sync_needed = self.sync_needed;
11891        let prior_active_round = self.active_round;
11892        let prior_changes = self.change_queue.len();
11893        let prior_intents = self.sync_intent_queue.len();
11894        let prior_last_change = self.last_change.clone();
11895        let guarded = (|| -> Result<(), String> {
11896            // §7.4.2: the marker is re-read under the write lock the caller
11897            // holds, so a replica another process upgraded meanwhile refuses
11898            // this recreation instead of having its marker and tables
11899            // downgraded.
11900            let marker = read_local_schema_version(&self.conn, self.schema.version)?;
11901            self.create_bookkeeping_tables()?;
11902            match marker {
11903                Some(version) if version < self.schema.version => self.run_schema_reset()?,
11904                None => {
11905                    self.create_synced_tables()?;
11906                    self.persist_schema_version()?;
11907                }
11908                _ => {}
11909            }
11910            // §7.4.2: recreation IS the boot for the in-memory core, so reload the
11911            // durable outbox the same-version path is about to replay. Only the
11912            // outbox is reloaded: the recovery below reads it, and the caller's
11913            // rollback snapshot already covers this field.
11914            self.outbox = self.load_outbox()?;
11915            // §7.1/§7.4.4: recover intrinsically codec-invalid persisted values at
11916            // this guarded boundary once the schema and marker agree. A bump went
11917            // through `run_schema_reset`, which performs the same recovery after
11918            // it creates the new physical tables and before its final replay.
11919            self.drop_unencodable_outbox(false, None)?;
11920            if marker == Some(self.schema.version)
11921                && (!self.outbox.is_empty()
11922                    || !self.failed_commits.is_empty()
11923                    || self.has_acknowledged_rows())
11924            {
11925                self.overlay_dirty.set(true);
11926                self.rebuild_overlay()?;
11927            }
11928            self.prune_unknown_subscriptions(false)?;
11929            self.save_subscription_scope_schema();
11930            // The conformance recreate is the in-memory equivalent of reopening
11931            // a durable client. Apply the same startup catch-up contract even
11932            // when the schema itself did not change.
11933            self.enqueue_startup_sync_if_needed()?;
11934            Ok(())
11935        })();
11936        let outcome = match guarded {
11937            Ok(()) => self
11938                .conn
11939                .execute_batch("COMMIT")
11940                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error)),
11941            Err(error) => Err(error),
11942        };
11943        if let Err(error) = outcome {
11944            if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
11945                // Keep a failed rollback's typed storage classification.
11946                let _ = Self::sqlite_failure(&self.storage_failure, rollback);
11947            }
11948            self.schema = prior_schema;
11949            self.subs = prior_subs;
11950            self.outbox = prior_outbox;
11951            self.rejections = prior_rejections;
11952            self.upgrading = prior_upgrading;
11953            self.stopped = prior_stopped;
11954            self.schema_floor = prior_schema_floor;
11955            self.overlay_dirty.restore(prior_overlay_dirty);
11956            self.sync_needed = prior_sync_needed;
11957            self.active_round = prior_active_round;
11958            self.change_queue.truncate(prior_changes);
11959            self.sync_intent_queue.truncate(prior_intents);
11960            self.last_change = prior_last_change;
11961            // Prepared insert statements were derived from the reverted schema.
11962            self.insert_sql.borrow_mut().clear();
11963            return Err(error);
11964        }
11965        Ok(())
11966    }
11967
11968    fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
11969        let startup_work = !self.stopped
11970            && (!self.outbox.is_empty()
11971                || self.has_acknowledged_rows()
11972                || !self.load_pending_evictions()?.is_empty()
11973                || self.subs.iter().any(|sub| sub.state == SubState::Active));
11974        if startup_work {
11975            self.sync_needed = true;
11976            self.sync_intent_queue.push_back(SyncIntent::Interactive);
11977        }
11978        Ok(())
11979    }
11980
11981    /// A native client persists schema-floor stops across process restarts.
11982    /// Once the running app already satisfies that floor, the persisted stop
11983    /// is only stale evidence from an older server round (for example, the
11984    /// server was restarted after catching up to an app that was ahead).
11985    /// Clear it and let the normal startup pull re-negotiate. If the server is
11986    /// still incompatible it will return the floor again in that first round.
11987    fn clear_satisfied_persisted_schema_floor(&mut self) {
11988        let satisfied = self
11989            .schema_floor
11990            .as_ref()
11991            .and_then(|floor| floor.required_schema_version)
11992            .is_some_and(|required| self.schema.version >= required);
11993        if !satisfied {
11994            return;
11995        }
11996        self.schema_floor = None;
11997        self.stopped = false;
11998        self.delete_meta(SCHEMA_FLOOR_KEY);
11999    }
12000
12001    /// RFC 0005 D1: discard an orphan or stale container at boot. The reset
12002    /// path already swept it; this covers a same-version or fresh-install open,
12003    /// which runs no reset and therefore no sweep — plus the D7 TTL, which only
12004    /// an aware binary can apply.
12005    fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
12006        let Some(replica_path) = self.previous_version_replica_path() else {
12007            return Ok(());
12008        };
12009        let max_age_ms = self
12010            .previous_version
12011            .filter(|config| config.enabled)
12012            .map(|config| config.max_age_ms);
12013        reconcile_previous_version_at_boot(
12014            &self.conn,
12015            &replica_path,
12016            self.schema.version,
12017            max_age_ms,
12018            self.clock_now_ms(),
12019        )
12020    }
12021
12022    /// RFC 0005 D3: the sibling container path, or `None` when the replica has
12023    /// no file — an in-memory replica returns an empty path, and a `file:` URI
12024    /// has no plain sibling path — so the feature resolves as off there.
12025    fn previous_version_replica_path(&self) -> Option<String> {
12026        let path = self.conn.path()?;
12027        if path.is_empty() || path.starts_with("file:") {
12028            return None;
12029        }
12030        Some(path.to_owned())
12031    }
12032
12033    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
12034    /// and leaseState. Drops/recreates every synced table from the new
12035    /// schema, resets subscription sync-state (keeping registrations), clears
12036    /// the schema-floor stop state, rewrites the marker, drops outbox commits
12037    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
12038    fn run_schema_reset(&mut self) -> Result<(), String> {
12039        self.begin_observation("syncular_schema_reset")?;
12040        let prior_subs = self.subs.clone();
12041        let prior_outbox = self.outbox.clone();
12042        let prior_rejections = self.rejections.clone();
12043        let prior_upgrading = self.upgrading;
12044        let prior_stopped = self.stopped;
12045        let prior_schema_floor = self.schema_floor.clone();
12046        let prior_overlay_dirty = self.overlay_dirty.snapshot();
12047        let prior_sync_needed = self.sync_needed;
12048        let prior_active_round = self.active_round;
12049        let mut batch = ChangeAccumulator::default();
12050        let result = self
12051            .run_schema_reset_observed(&mut batch, true)
12052            .and_then(|()| self.finish_observation("syncular_schema_reset", batch));
12053        if let Err(error) = result {
12054            self.rollback_observation("syncular_schema_reset");
12055            self.subs = prior_subs;
12056            self.outbox = prior_outbox;
12057            self.rejections = prior_rejections;
12058            self.upgrading = prior_upgrading;
12059            self.stopped = prior_stopped;
12060            self.schema_floor = prior_schema_floor;
12061            self.overlay_dirty.restore(prior_overlay_dirty);
12062            self.sync_needed = prior_sync_needed;
12063            self.active_round = prior_active_round;
12064            return Err(error);
12065        }
12066        Ok(())
12067    }
12068
12069    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
12070        if self.get_meta(LOG_EPOCH_KEY).is_none() {
12071            self.begin_observation("syncular_epoch_acquisition")?;
12072            let previous_sync_needed = self.sync_needed;
12073            if let Err(error) = self.conn.execute(
12074                "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
12075                rusqlite::params![LOG_EPOCH_KEY, log_epoch],
12076            ) {
12077                self.rollback_observation("syncular_epoch_acquisition");
12078                return Err(Self::sqlite_failure(&self.storage_failure, error));
12079            }
12080            self.sync_needed = true;
12081            let batch = ChangeAccumulator {
12082                status: true,
12083                ..ChangeAccumulator::default()
12084            };
12085            if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
12086                self.rollback_observation("syncular_epoch_acquisition");
12087                self.sync_needed = previous_sync_needed;
12088                return Err(error);
12089            }
12090            self.sync_intent_queue.push_back(SyncIntent::Interactive);
12091            return Ok(Vec::new());
12092        }
12093        let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
12094        self.begin_observation("syncular_log_epoch_reset")?;
12095        let prior_subs = self.subs.clone();
12096        let prior_outbox = self.outbox.clone();
12097        let prior_rejections = self.rejections.clone();
12098        let prior_upgrading = self.upgrading;
12099        let prior_stopped = self.stopped;
12100        let prior_schema_floor = self.schema_floor.clone();
12101        let prior_overlay_dirty = self.overlay_dirty.snapshot();
12102        let prior_sync_needed = self.sync_needed;
12103        let prior_active_round = self.active_round;
12104        let mut batch = ChangeAccumulator::default();
12105        let result = self
12106            .run_schema_reset_observed(&mut batch, false)
12107            .and_then(|()| {
12108                self.conn
12109                    .execute(
12110                        "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
12111                        rusqlite::params![LOG_EPOCH_KEY, log_epoch],
12112                    )
12113                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12114                self.sync_needed = true;
12115                batch.status = true;
12116                self.finish_observation("syncular_log_epoch_reset", batch)
12117            });
12118        if let Err(error) = result {
12119            self.rollback_observation("syncular_log_epoch_reset");
12120            self.subs = prior_subs;
12121            self.outbox = prior_outbox;
12122            self.rejections = prior_rejections;
12123            self.upgrading = prior_upgrading;
12124            self.stopped = prior_stopped;
12125            self.schema_floor = prior_schema_floor;
12126            self.overlay_dirty.restore(prior_overlay_dirty);
12127            self.sync_needed = prior_sync_needed;
12128            self.active_round = prior_active_round;
12129            return Err(error);
12130        }
12131        self.sync_intent_queue.push_back(SyncIntent::Interactive);
12132        Ok(resets)
12133    }
12134
12135    fn run_schema_reset_observed(
12136        &mut self,
12137        batch: &mut ChangeAccumulator,
12138        capture: bool,
12139    ) -> Result<(), String> {
12140        // §7.4.2: the reset is the only path that wipes local tables for a
12141        // version bump, and it rewrites the marker last. Re-read the marker
12142        // under this reset's transaction: a replica another process upgraded
12143        // meanwhile refuses here instead of being reset to this client's
12144        // older schema.
12145        read_local_schema_version(&self.conn, self.schema.version)?;
12146        self.conn
12147            .execute_batch(
12148                "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
12149            )
12150            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12151        self.cancel_sync_round();
12152        self.upgrading = true;
12153        batch.status = true;
12154        // RFC 0005 D6: classify the pending outbox against the NEW compiled
12155        // schema BEFORE the wipe, and record the advisory audit. This pass
12156        // drops nothing, so the audit sees the original pending intent; the
12157        // §7.4.4 send-time drop and the intrinsic value recovery later in this
12158        // reset are the paths that remove a commit. The classification is
12159        // shared with the send-time drop so the two cannot disagree about what
12160        // is incompatible.
12161        if self.previous_version.is_some_and(|config| config.enabled) {
12162            let previous_version = self
12163                .get_meta(LOCAL_SCHEMA_VERSION_KEY)
12164                .and_then(|value| value.parse::<i32>().ok())
12165                .unwrap_or(self.schema.version);
12166            let pending = self
12167                .outbox
12168                .iter()
12169                .map(|commit| PendingCommitAudit {
12170                    commit_id: &commit.client_commit_id,
12171                    operations: commit_audit_operations(commit),
12172                })
12173                .collect::<Vec<_>>();
12174            crate::previous_version::write_previous_version_audit(
12175                &self.conn,
12176                &crate::previous_version::build_previous_version_audit(
12177                    &self.schema,
12178                    &pending,
12179                    previous_version,
12180                    self.schema.version,
12181                    self.clock_now_ms(),
12182                ),
12183            );
12184        }
12185        // RFC 0005 D5: the container lives in its own FILE, so the reset can
12186        // never reach it. Sweep first (unconditional — also on the log-epoch
12187        // reset, which captures nothing), then capture BEFORE the wipe drops
12188        // any row. A refused capture records its reason and leaves no file.
12189        if let Some(replica_path) = self.previous_version_replica_path() {
12190            sweep_previous_version_container(&self.conn, &replica_path)?;
12191            if capture {
12192                if let Some(config) = self.previous_version.filter(|config| config.enabled) {
12193                    let previous_version = self
12194                        .get_meta(LOCAL_SCHEMA_VERSION_KEY)
12195                        .and_then(|value| value.parse::<i32>().ok())
12196                        .unwrap_or(self.schema.version);
12197                    capture_previous_version_from_replica(
12198                        &self.conn,
12199                        &replica_path,
12200                        previous_version,
12201                        self.schema.version,
12202                        &config,
12203                        self.clock_now_ms(),
12204                    )?;
12205                }
12206            }
12207        }
12208        for table in &self.schema.tables {
12209            batch.table(&table.name);
12210        }
12211        for (base_key, unit, table) in self.load_registered_window_units() {
12212            batch.window(&base_key, &table, &unit);
12213        }
12214        if capture {
12215            self.prune_unknown_subscriptions(true)?;
12216        }
12217        // The per-table insert SQL is derived from the OLD column lists.
12218        self.insert_sql.borrow_mut().clear();
12219        self.overlay_dirty.set(true);
12220        // Drop every synced table (base + visible) that currently exists —
12221        // discovered from sqlite_master so a bump that adds/removes tables is
12222        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
12223        // and the blob cache) are preserved; base tables are `_syncular_base_*`
12224        // so they are matched explicitly, not by the bookkeeping filter.
12225        // Drop virtual tables first so SQLite removes every FTS shadow table
12226        // atomically instead of the generic discovery tearing it apart.
12227        let virtual_tables: Vec<String> = {
12228            let mut stmt = self
12229                .conn
12230                .prepare(
12231                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
12232                )
12233                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12234            let rows = stmt
12235                .query_map([], |row| row.get::<_, String>(0))
12236                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12237            rows.filter_map(Result::ok)
12238                .filter(|name| is_synced_table_name(name))
12239                .collect()
12240        };
12241        for name in virtual_tables {
12242            self.conn
12243                .execute_batch(&format!(
12244                    "DROP TABLE IF EXISTS {}",
12245                    quote_ident(&format!("_syncular_fts_{name}"))
12246                ))
12247                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12248            self.conn
12249                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
12250                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12251        }
12252        let existing: Vec<String> = {
12253            let mut stmt = self
12254                .conn
12255                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
12256                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12257            let rows = stmt
12258                .query_map([], |row| row.get::<_, String>(0))
12259                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12260            rows.filter_map(Result::ok)
12261                .filter(|name| is_synced_table_name(name))
12262                .collect()
12263        };
12264        for name in &existing {
12265            if let Some(table) = name.strip_prefix("_syncular_base_") {
12266                batch.table(table);
12267            } else if !name.starts_with("_syncular_") {
12268                batch.table(name);
12269            }
12270        }
12271        for name in existing {
12272            self.conn
12273                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
12274                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12275        }
12276        // Recreate the synced tables from the NEW schema.
12277        self.create_synced_tables()?;
12278        // Reset every subscription's sync-state, keeping the registration.
12279        for sub in &mut self.subs {
12280            sub.cursor = -1;
12281            sub.bootstrap_state = None;
12282            sub.effective = None;
12283            sub.state = SubState::Active;
12284            sub.reason_code = None;
12285            sub.synced_once = false;
12286        }
12287        let subs = self.subs.clone();
12288        for sub in &subs {
12289            self.persist_sub(sub)?;
12290        }
12291        // The stop state is over: this client now ships a servable schema.
12292        self.stopped = false;
12293        self.schema_floor = None;
12294        self.delete_meta(SCHEMA_FLOOR_KEY);
12295        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
12296        self.persist_schema_version()?;
12297        self.save_subscription_scope_schema();
12298        // §7.1/§7.4.4: the previous-version capture and audit above saw the
12299        // original pending intent; the synced tables now carry the new schema, so
12300        // recover intrinsically codec-invalid persisted values here, before the
12301        // final replay. A pending upsert that names a column the new schema
12302        // removed stays pending for the send-time `drop_incompatible_outbox`
12303        // prepass. The reset wrapper snapshots and restores the outbox,
12304        // rejections, and change queues, so a failed commit rolls this recovery
12305        // back with the rest of the reset.
12306        // §7.4.4: the enclosing reset observation owns the publication, so the
12307        // recovery contributes its rejection, outcome, and table domains to it.
12308        self.drop_unencodable_outbox(false, Some(batch))?;
12309        // Re-apply the surviving outbox optimistically over the empty tables.
12310        self.rebuild_overlay()?;
12311        Ok(())
12312    }
12313
12314    /// §7.4.4 send-time prepass: a persisted upsert whose values reference a
12315    /// column the current schema lacks (or a removed table) cannot be encoded
12316    /// (`commit_audit_operations` inspects upserts, so a value-free delete stays
12317    /// encodable and is validated by the server). Drop the commit, undo its
12318    /// optimistic projection, and raise a client-local
12319    /// `sync.outbox_incompatible` rejection.
12320    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
12321        let incompatible = self
12322            .outbox
12323            .iter()
12324            .filter(|commit| {
12325                first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
12326            })
12327            .cloned()
12328            .collect::<Vec<_>>();
12329        if incompatible.is_empty() {
12330            return Ok(false);
12331        }
12332        // §7.4.4: one observation covers the durable outcome writes, the outbox
12333        // removal, the in-memory drop, and the overlay rebuild, so a storage
12334        // fault leaves the replica and the client unchanged and publishes no
12335        // change; a successful drop publishes exactly one batch carrying the
12336        // rejections, outcomes, status, and the tables whose projection changed.
12337        let prior_outbox = self.outbox.clone();
12338        let prior_rejections = self.rejections.clone();
12339        let prior_overlay_dirty = self.overlay_dirty.snapshot();
12340        let owns_transaction = self.conn.is_autocommit();
12341        self.begin_observation("syncular_outbox_drop")?;
12342        let mut batch = ChangeAccumulator::default();
12343        let dropped = (|| -> Result<(), String> {
12344            let mut rejections = Vec::new();
12345            for commit in &incompatible {
12346                let results = commit
12347                    .ops
12348                    .iter()
12349                    .enumerate()
12350                    .map(|(op_index, operation)| {
12351                        let rejection = RejectionRecord {
12352                            client_commit_id: commit.client_commit_id.clone(),
12353                            op_index: op_index as i32,
12354                            code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
12355                            message: "the persisted commit cannot encode under the current schema"
12356                                .to_owned(),
12357                            retryable: false,
12358                            details: None,
12359                            operation: Some(CommitOperation::from(operation)),
12360                        };
12361                        rejections.push(rejection.clone());
12362                        CommitOperationOutcome::Error { rejection }
12363                    })
12364                    .collect::<Vec<_>>();
12365                self.persist_commit_outcome(
12366                    &commit.client_commit_id,
12367                    CommitOutcomeStatus::Rejected,
12368                    &results,
12369                    Some(&commit.ops),
12370                )?;
12371                self.delete_outbox_persisted(&commit.client_commit_id)?;
12372                // A removed table has no local projection to invalidate; every
12373                // surviving table the dropped commit touched does.
12374                for operation in &commit.ops {
12375                    if self.schema.table(&operation.table).is_some() {
12376                        batch.table(&operation.table);
12377                    }
12378                }
12379            }
12380            self.prune_commit_outcomes()?;
12381            let incompatible_ids = incompatible
12382                .iter()
12383                .map(|commit| commit.client_commit_id.as_str())
12384                .collect::<BTreeSet<_>>();
12385            self.outbox
12386                .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
12387            self.rejections.extend(rejections);
12388            // The dropped commits' purely-optimistic rows are undone: the
12389            // visible projection is re-derived from the surviving base + outbox.
12390            self.overlay_dirty.set(true);
12391            self.rebuild_overlay()?;
12392            batch.status = true;
12393            batch.rejections = true;
12394            batch.outcomes = true;
12395            Ok(())
12396        })();
12397        let result = dropped.and_then(|()| self.finish_observation("syncular_outbox_drop", batch));
12398        if let Err(error) = result {
12399            // An outer RELEASE is the COMMIT. Roll back that transaction
12400            // directly: retrying RELEASE after ROLLBACK TO still needs the
12401            // reader's lock. A nested observation only rolls back its savepoint.
12402            if owns_transaction {
12403                if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
12404                    let mut failure = self.storage_failure.borrow_mut();
12405                    let failure =
12406                        failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
12407                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
12408                }
12409            } else {
12410                self.rollback_observation("syncular_outbox_drop");
12411            }
12412            self.outbox = prior_outbox;
12413            self.rejections = prior_rejections;
12414            self.overlay_dirty.restore(prior_overlay_dirty);
12415            return Err(error);
12416        }
12417        Ok(true)
12418    }
12419
12420    /// §7.4.3: (re)create the base + visible table pair for every synced
12421    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
12422    fn create_synced_tables(&self) -> Result<(), String> {
12423        for table in &self.schema.tables {
12424            // The base half + the visible half form the synced-table pair. An
12425            // index name is global in SQLite, so the base half's indexes are
12426            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
12427            for (full, index_prefix) in [
12428                (base_table(&table.name), "_syncular_base_"),
12429                (visible_table(&table.name), ""),
12430            ] {
12431                let mut cols: Vec<String> =
12432                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
12433                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
12434                let sql = format!(
12435                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
12436                    cols.join(", "),
12437                    quote_ident(&table.primary_key)
12438                );
12439                self.conn
12440                    .execute(&sql, [])
12441                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12442                // Local secondary indexes (CREATE INDEX subset). Created on
12443                // both halves so mirror reads hit an index on either. Runs on
12444                // both the initial create and the §7.4.3 reset recreate path.
12445                for index in &table.indexes {
12446                    let unique = if index.unique { "UNIQUE " } else { "" };
12447                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
12448                    let cols_sql = index
12449                        .columns
12450                        .iter()
12451                        .map(|c| quote_ident(c))
12452                        .collect::<Vec<_>>()
12453                        .join(", ");
12454                    let index_sql = format!(
12455                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
12456                    );
12457                    self.conn
12458                        .execute(&index_sql, [])
12459                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12460                }
12461            }
12462        }
12463        // FTS exists only on the visible half. It is a local search
12464        // projection, never a synced/base table or a wire-schema member.
12465        for table in &self.schema.tables {
12466            for index in &table.fts_indexes {
12467                self.create_fts_projection(table, index)?;
12468            }
12469        }
12470        Ok(())
12471    }
12472
12473    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
12474        let count: i64 = self
12475            .conn
12476            .query_row(
12477                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
12478                rusqlite::params![index.name],
12479                |row| row.get(0),
12480            )
12481            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12482        Ok(count > 0)
12483    }
12484
12485    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
12486        for suffix in ["ai", "ad", "au"] {
12487            self.conn
12488                .execute(
12489                    &format!(
12490                        "DROP TRIGGER IF EXISTS {}",
12491                        quote_ident(&format!("{}_{suffix}", index.name))
12492                    ),
12493                    [],
12494                )
12495                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12496        }
12497        Ok(())
12498    }
12499
12500    fn create_fts_triggers(
12501        &self,
12502        table: &TableSchema,
12503        index: &FtsIndexSchema,
12504    ) -> Result<(), String> {
12505        let fts = quote_ident(&index.name);
12506        let source = visible_table(&table.name);
12507        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
12508        let pk = quote_ident(&table.primary_key);
12509        let projection_columns = std::iter::once(source_id.clone())
12510            .chain(index.columns.iter().map(|column| quote_ident(column)))
12511            .collect::<Vec<_>>()
12512            .join(", ");
12513        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
12514            .chain(
12515                index
12516                    .columns
12517                    .iter()
12518                    .map(|column| format!("new.{}", quote_ident(column))),
12519            )
12520            .collect::<Vec<_>>()
12521            .join(", ");
12522        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
12523        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)");
12524        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)");
12525        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})");
12526        let bi = quote_ident(&format!("{}_bi", index.name));
12527        let ai = quote_ident(&format!("{}_ai", index.name));
12528        let ad = quote_ident(&format!("{}_ad", index.name));
12529        let au = quote_ident(&format!("{}_au", index.name));
12530        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
12531        let sql = format!(
12532            "DROP TRIGGER IF EXISTS {bi};
12533             DROP TRIGGER IF EXISTS {ai};
12534             DROP TRIGGER IF EXISTS {ad};
12535             DROP TRIGGER IF EXISTS {au};
12536             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
12537             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
12538             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
12539             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
12540        );
12541        self.conn
12542            .execute_batch(&sql)
12543            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12544    }
12545
12546    fn rebuild_fts_projection(
12547        &self,
12548        table: &TableSchema,
12549        index: &FtsIndexSchema,
12550    ) -> Result<(), String> {
12551        let fts = quote_ident(&index.name);
12552        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
12553        let pk = quote_ident(&table.primary_key);
12554        let indexed_columns = index
12555            .columns
12556            .iter()
12557            .map(|column| quote_ident(column))
12558            .collect::<Vec<_>>();
12559        let projection_columns = std::iter::once(source_id.clone())
12560            .chain(indexed_columns.iter().cloned())
12561            .collect::<Vec<_>>()
12562            .join(", ");
12563        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
12564        let sql = format!(
12565            "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};",
12566            columns = indexed_columns.join(", "),
12567            source = visible_table(&table.name),
12568        );
12569        self.conn
12570            .execute_batch(&sql)
12571            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12572    }
12573
12574    fn create_fts_projection(
12575        &self,
12576        table: &TableSchema,
12577        index: &FtsIndexSchema,
12578    ) -> Result<(), String> {
12579        self.begin_observation("syncular_fts_schema")?;
12580        let result = (|| {
12581            let existed = self.fts_projection_exists(index)?;
12582            let tokenizer = index.tokenize.replace('\'', "''");
12583            let columns = index
12584                .columns
12585                .iter()
12586                .map(|column| quote_ident(column))
12587                .collect::<Vec<_>>()
12588                .join(", ");
12589            let sql = format!(
12590            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
12591            fts = quote_ident(&index.name),
12592            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
12593        );
12594            self.conn.execute(&sql, []).map_err(|error| {
12595                format!(
12596                    "cannot create local FTS5 projection {:?}: {error}",
12597                    index.name
12598                )
12599            })?;
12600            let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
12601            let mapped: bool = self
12602                .conn
12603                .query_row(
12604                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
12605                    [format!("_syncular_fts_{}", index.name)],
12606                    |row| row.get(0),
12607                )
12608                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12609            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))?;
12610            if !mapped && existed {
12611                self.conn
12612                    .execute_batch(&format!(
12613                        "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
12614                        quote_ident(FTS_SOURCE_ID_COLUMN),
12615                        quote_ident(&index.name)
12616                    ))
12617                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12618            }
12619            self.create_fts_triggers(table, index)?;
12620            if !existed {
12621                self.rebuild_fts_projection(table, index)?;
12622            }
12623            Ok(())
12624        })();
12625        if let Err(error) = result {
12626            self.rollback_observation("syncular_fts_schema");
12627            return Err(error);
12628        }
12629        if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
12630            self.rollback_observation("syncular_fts_schema");
12631            return Err(error.to_string());
12632        }
12633        Ok(())
12634    }
12635
12636    // -- persistence write-through --------------------------------------------
12637
12638    fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
12639        let state = serde_json::json!({
12640            "requested": scope_map_to_json(&sub.requested),
12641            "params": sub.params,
12642            "cursor": sub.cursor,
12643            "bootstrapState": sub.bootstrap_state,
12644            "status": sub.state.name(),
12645            "reasonCode": sub.reason_code,
12646            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
12647            "syncedOnce": sub.synced_once,
12648        });
12649        self.conn.execute(
12650            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
12651            rusqlite::params![sub.id, sub.table, state.to_string()],
12652        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12653    }
12654
12655    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
12656        let ops: Vec<Value> = commit
12657            .ops
12658            .iter()
12659            .map(|op| {
12660                serde_json::json!({
12661                    "op": if op.upsert { "upsert" } else { "delete" },
12662                    "table": op.table,
12663                    "rowId": op.row_id,
12664                    "baseVersion": op.base_version,
12665                    "values": op.values.clone().map(Value::Object),
12666                })
12667            })
12668            .collect();
12669        self.conn
12670            .execute(
12671                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
12672                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
12673            )
12674            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12675        for operation in &commit.ops {
12676            let Some(values) = &operation.values else {
12677                continue;
12678            };
12679            let Some(table) = self.schema.table(&operation.table) else {
12680                continue;
12681            };
12682            for column in table
12683                .columns
12684                .iter()
12685                .filter(|column| column.ty == ColumnType::BlobRef)
12686            {
12687                let Some(Value::String(raw)) = values.get(&column.name) else {
12688                    continue;
12689                };
12690                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
12691                    continue;
12692                };
12693                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
12694                    continue;
12695                };
12696                self.conn
12697                    .execute(
12698                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
12699                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
12700                        rusqlite::params![commit.client_commit_id, blob_id],
12701                    )
12702                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12703            }
12704        }
12705        Ok(())
12706    }
12707
12708    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
12709        if let Some(commit) = self
12710            .outbox
12711            .iter()
12712            .find(|commit| commit.client_commit_id == client_commit_id)
12713        {
12714            for operation in &commit.ops {
12715                self.overlay_dirty.table(&operation.table);
12716            }
12717        }
12718
12719        self.conn
12720            .execute(
12721                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
12722                rusqlite::params![client_commit_id],
12723            )
12724            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12725        self.conn
12726            .execute(
12727                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
12728                rusqlite::params![client_commit_id],
12729            )
12730            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12731        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)", [])
12732            .map(|_| ())
12733            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12734    }
12735
12736    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
12737        match status {
12738            CommitOutcomeStatus::Applied => "applied",
12739            CommitOutcomeStatus::Cached => "cached",
12740            CommitOutcomeStatus::Conflict => "conflict",
12741            CommitOutcomeStatus::Rejected => "rejected",
12742        }
12743    }
12744
12745    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
12746        match resolution {
12747            CommitOutcomeResolution::Active => "active",
12748            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
12749            CommitOutcomeResolution::Superseded => "superseded",
12750            CommitOutcomeResolution::Dismissed => "dismissed",
12751        }
12752    }
12753
12754    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
12755        match value {
12756            "applied" => Ok(CommitOutcomeStatus::Applied),
12757            "cached" => Ok(CommitOutcomeStatus::Cached),
12758            "conflict" => Ok(CommitOutcomeStatus::Conflict),
12759            "rejected" => Ok(CommitOutcomeStatus::Rejected),
12760            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
12761        }
12762    }
12763
12764    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
12765        match value {
12766            "active" => Ok(CommitOutcomeResolution::Active),
12767            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
12768            "superseded" => Ok(CommitOutcomeResolution::Superseded),
12769            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
12770            _ => Err(format!(
12771                "invalid persisted commit outcome resolution {value:?}"
12772            )),
12773        }
12774    }
12775
12776    fn persist_commit_outcome(
12777        &self,
12778        client_commit_id: &str,
12779        status: CommitOutcomeStatus,
12780        results: &[CommitOperationOutcome],
12781        operations: Option<&[OutboxOp]>,
12782    ) -> Result<(), String> {
12783        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
12784        let operations_json = operations
12785            .map(|items| {
12786                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
12787            })
12788            .transpose()
12789            .map_err(|error| error.to_string())?;
12790        self.conn
12791            .execute(
12792                "INSERT INTO _syncular_commit_outcomes (
12793                   client_commit_id, status, recorded_at_ms, results_json,
12794                   operations_json, resolution
12795                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
12796                rusqlite::params![
12797                    client_commit_id,
12798                    Self::outcome_status_name(status),
12799                    self.clock_now_ms(),
12800                    results_json,
12801                    operations_json
12802                ],
12803            )
12804            .map(|_| ())
12805            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12806    }
12807
12808    /// §7.2: the journal holds a stored cell only in its canonical shape. A cell
12809    /// the byte-envelope rule refuses cannot be journaled verbatim.
12810    fn stored_cell_is_representable(cell: &Value) -> bool {
12811        cell.is_null()
12812            || cell.is_string()
12813            || cell.is_boolean()
12814            || cell.is_number()
12815            || is_canonical_bytes_envelope(cell)
12816    }
12817
12818    fn stored_row_is_representable(value: &Value) -> bool {
12819        value
12820            .as_object()
12821            .is_some_and(|row| row.values().all(Self::stored_cell_is_representable))
12822    }
12823
12824    fn stored_operation_is_representable(value: &Value) -> bool {
12825        value.get("table").is_some_and(Value::is_string)
12826            && value.get("rowId").is_some_and(Value::is_string)
12827            && value.get("baseVersion").is_none_or(|v| {
12828                v.as_i64()
12829                    .is_some_and(|n| n.unsigned_abs() <= MAX_JS_SAFE_INTEGER)
12830            })
12831            && match value.get("op").and_then(Value::as_str) {
12832                Some("upsert") => value
12833                    .get("values")
12834                    .is_some_and(Self::stored_row_is_representable),
12835                Some("delete") => value.get("values").is_none(),
12836                _ => false,
12837            }
12838    }
12839
12840    /// §7.2/§7.4.4: a dropped commit keeps its operation envelope in the journal
12841    /// only when the journal reader accepts the serialized operation, which is the
12842    /// rule `outcome_from_row` applies to this same row later.
12843    fn outbox_op_is_journalable(operation: &OutboxOp) -> bool {
12844        serde_json::to_value(CommitOperation::from(operation))
12845            .is_ok_and(|value| Self::stored_operation_is_representable(&value))
12846    }
12847
12848    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
12849        let StoredCommitOutcomeRow {
12850            sequence,
12851            client_commit_id,
12852            status,
12853            recorded_at_ms,
12854            results_json,
12855            operations_json,
12856            resolution,
12857            resolved_at_ms,
12858            replacement_client_commit_id,
12859        } = row;
12860        if sequence.unsigned_abs() > MAX_JS_SAFE_INTEGER
12861            || recorded_at_ms.unsigned_abs() > MAX_JS_SAFE_INTEGER
12862            || resolved_at_ms.is_some_and(|value| value.unsigned_abs() > MAX_JS_SAFE_INTEGER)
12863        {
12864            return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned());
12865        }
12866        let invalid = || "sync.local_corrupt: persisted commit outcome is invalid".to_owned();
12867        let stored: Vec<Value> = serde_json::from_str(&results_json).map_err(|_| invalid())?;
12868        for result in &stored {
12869            let nested = match result.get("status").and_then(Value::as_str) {
12870                Some("conflict") => result.get("conflict"),
12871                Some("error") => result.get("rejection"),
12872                _ => None,
12873            };
12874            if nested
12875                .and_then(|record| record.get("operation"))
12876                .is_some_and(|op| !Self::stored_operation_is_representable(op))
12877                || (result.get("status").and_then(Value::as_str) == Some("conflict")
12878                    && nested
12879                        .and_then(|record| record.get("serverRow"))
12880                        .is_some_and(|row| !Self::stored_row_is_representable(row)))
12881            {
12882                return Err(invalid());
12883            }
12884        }
12885        let results: Vec<CommitOperationOutcome> =
12886            serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())?;
12887        let has_conflict = results
12888            .iter()
12889            .any(|result| matches!(result, CommitOperationOutcome::Conflict { .. }));
12890        let has_error = results
12891            .iter()
12892            .any(|result| matches!(result, CommitOperationOutcome::Error { .. }));
12893        let status = Self::parse_outcome_status(&status).map_err(|_| invalid())?;
12894        let redacted = results.is_empty() && operations_json.is_none();
12895        if match status {
12896            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => has_conflict || has_error,
12897            CommitOutcomeStatus::Conflict => !(has_conflict || redacted),
12898            CommitOutcomeStatus::Rejected => has_conflict || !(has_error || redacted),
12899        } {
12900            return Err(invalid());
12901        }
12902        let operations: Option<Vec<CommitOperation>> = operations_json
12903            .map(|raw| {
12904                let stored: Vec<Value> = serde_json::from_str(&raw).map_err(|_| invalid())?;
12905                if !stored.iter().all(Self::stored_operation_is_representable) {
12906                    return Err(invalid());
12907                }
12908                serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())
12909            })
12910            .transpose()?;
12911        for result in &results {
12912            match result {
12913                CommitOperationOutcome::Conflict { conflict }
12914                    if conflict.server_version.unsigned_abs() > MAX_JS_SAFE_INTEGER =>
12915                {
12916                    return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned())
12917                }
12918                CommitOperationOutcome::Error { rejection } => {
12919                    if let Some(details) = &rejection.details {
12920                        let encoded = serde_json::to_string(details).map_err(|_| {
12921                            "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
12922                        })?;
12923                        RejectionDetails::parse(&encoded).map_err(|_| {
12924                            "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
12925                        })?;
12926                    }
12927                }
12928                _ => {}
12929            }
12930        }
12931        Ok(CommitOutcome {
12932            sequence,
12933            client_commit_id,
12934            status,
12935            recorded_at_ms,
12936            results,
12937            operations,
12938            retained_rows: None,
12939            resolution: Self::parse_outcome_resolution(&resolution)?,
12940            resolved_at_ms,
12941            replacement_client_commit_id,
12942        })
12943    }
12944
12945    /// Resolve declared unique keys through SQLite, including affinity and NULL semantics.
12946    fn unique_conflicts(
12947        &self,
12948        table: &TableSchema,
12949        values: &Map<String, Value>,
12950        base: bool,
12951    ) -> Result<Vec<RetainedUniqueConflict>, String> {
12952        let source = if base {
12953            base_table(&table.name)
12954        } else {
12955            visible_table(&table.name)
12956        };
12957        let mut conflicts = Vec::new();
12958        for index in table.indexes.iter().filter(|index| index.unique) {
12959            let mut params = vec![json_param_to_sql(
12960                values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
12961            )?];
12962            for name in &index.columns {
12963                let column = table
12964                    .columns
12965                    .iter()
12966                    .find(|column| column.name == *name)
12967                    .ok_or("sync.local_corrupt")?;
12968                let cell = json_to_column_value(column, values.get(name))?;
12969                params.push(owned_sql_value(RowParam::Cell(&cell))?);
12970            }
12971            let sql = format!(
12972                "SELECT * FROM {source} WHERE {} != ? AND {}",
12973                quote_ident(&table.primary_key),
12974                index
12975                    .columns
12976                    .iter()
12977                    .map(|column| format!("{} = ?", quote_ident(column)))
12978                    .collect::<Vec<_>>()
12979                    .join(" AND ")
12980            );
12981            let mut statement = self
12982                .conn
12983                .prepare_cached(&sql)
12984                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12985            let records = statement
12986                .query_map(rusqlite::params_from_iter(params), |record| {
12987                    let values = table
12988                        .columns
12989                        .iter()
12990                        .enumerate()
12991                        .map(|(index, column)| {
12992                            let value = sql_ref_to_json(column, record.get_ref(index)?);
12993                            json_to_column_value(column, Some(&value)).map_err(|message| {
12994                                rusqlite::Error::ToSqlConversionFailure(message.into())
12995                            })
12996                        })
12997                        .collect::<Result<Row, _>>()?;
12998                    Ok((values, record.get::<_, i64>(table.columns.len())?))
12999                })
13000                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13001            for record in records {
13002                let (row, version) =
13003                    record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13004                let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
13005                conflicts.push(RetainedUniqueConflict {
13006                    index: index.name.clone(),
13007                    columns: index.columns.clone(),
13008                    row_id,
13009                    server_row: table
13010                        .columns
13011                        .iter()
13012                        .zip(&row)
13013                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
13014                        .collect(),
13015                    server_version: version,
13016                });
13017            }
13018        }
13019        Ok(conflicts)
13020    }
13021
13022    fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
13023        if let Some((commit, initial)) = self
13024            .failed_commits
13025            .iter()
13026            .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
13027        {
13028            let mut rows = Vec::new();
13029            for operation in &commit.ops {
13030                let table = self
13031                    .schema
13032                    .table(&operation.table)
13033                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
13034                let key =
13035                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
13036                        .map_err(|_| "sync.local_corrupt".to_owned())?;
13037                let base = self.stored_row(table, &operation.row_id, true)?;
13038                rows.push(RetainedCommitRow {
13039                    table: operation.table.clone(),
13040                    row_id: operation.row_id.clone(),
13041                    unique_conflicts: if operation.upsert {
13042                        self.unique_conflicts(
13043                            table,
13044                            initial
13045                                .get(&key)
13046                                .and_then(Value::as_object)
13047                                .or(operation.values.as_ref())
13048                                .ok_or("sync.local_corrupt")?,
13049                            true,
13050                        )?
13051                    } else {
13052                        Vec::new()
13053                    },
13054                    local_row: if operation.upsert {
13055                        initial
13056                            .get(&key)
13057                            .and_then(Value::as_object)
13058                            .cloned()
13059                            .or_else(|| operation.values.clone())
13060                    } else {
13061                        None
13062                    },
13063                    server_row: base.as_ref().map(|(values, _)| {
13064                        table
13065                            .columns
13066                            .iter()
13067                            .zip(values)
13068                            .map(|(column, value)| {
13069                                (column.name.clone(), column_value_to_json(value))
13070                            })
13071                            .collect()
13072                    }),
13073                    server_version: base.map(|(_, version)| version),
13074                });
13075            }
13076            outcome.retained_rows = Some(rows);
13077        }
13078        Ok(outcome)
13079    }
13080
13081    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
13082        let row = self
13083            .conn
13084            .query_row(
13085                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
13086                        resolution, resolved_at_ms, replacement_client_commit_id
13087                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
13088                rusqlite::params![client_commit_id],
13089                |row| {
13090                    Ok(StoredCommitOutcomeRow {
13091                        sequence: row.get(0)?,
13092                        client_commit_id: row.get(1)?,
13093                        status: row.get(2)?,
13094                        recorded_at_ms: row.get(3)?,
13095                        results_json: row.get(4)?,
13096                        operations_json: row.get(5)?,
13097                        resolution: row.get(6)?,
13098                        resolved_at_ms: row.get(7)?,
13099                        replacement_client_commit_id: row.get(8)?,
13100                    })
13101                },
13102            )
13103            .optional()
13104            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13105        row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
13106            .transpose()
13107    }
13108
13109    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
13110        if query.limit == Some(0) {
13111            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
13112        }
13113        let mut sql = String::from(
13114            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
13115                    resolution, resolved_at_ms, replacement_client_commit_id
13116               FROM _syncular_commit_outcomes",
13117        );
13118        if query.active_only {
13119            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
13120        }
13121        sql.push_str(" ORDER BY seq DESC");
13122        if let Some(limit) = query.limit {
13123            sql.push_str(&format!(" LIMIT {limit}"));
13124        }
13125        let mut stmt = self
13126            .conn
13127            .prepare(&sql)
13128            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13129        let rows = stmt
13130            .query_map([], |row| {
13131                Ok(StoredCommitOutcomeRow {
13132                    sequence: row.get(0)?,
13133                    client_commit_id: row.get(1)?,
13134                    status: row.get(2)?,
13135                    recorded_at_ms: row.get(3)?,
13136                    results_json: row.get(4)?,
13137                    operations_json: row.get(5)?,
13138                    resolution: row.get(6)?,
13139                    resolved_at_ms: row.get(7)?,
13140                    replacement_client_commit_id: row.get(8)?,
13141                })
13142            })
13143            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13144        let mut outcomes = Vec::new();
13145        for row in rows {
13146            outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
13147                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
13148            )?)?);
13149        }
13150        Ok(outcomes)
13151    }
13152
13153    fn prune_commit_outcomes(&self) -> Result<(), String> {
13154        #[cfg(test)]
13155        self.outcome_prune_count
13156            .set(self.outcome_prune_count.get() + 1);
13157        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
13158        let count = self
13159            .conn
13160            .query_row(
13161                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
13162                [],
13163                |row| row.get::<_, i64>(0),
13164            )
13165            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13166            as usize;
13167        let excess = count.saturating_sub(max_entries);
13168        if excess == 0 {
13169            return Ok(());
13170        }
13171        let mut stmt = self
13172            .conn
13173            .prepare(
13174                "SELECT seq FROM _syncular_commit_outcomes
13175                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
13176                  ORDER BY seq ASC LIMIT ?1",
13177            )
13178            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13179        let rows = stmt
13180            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
13181            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13182        let sequences = rows
13183            .collect::<Result<Vec<_>, _>>()
13184            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13185        drop(stmt);
13186        for sequence in sequences {
13187            self.conn
13188                .execute(
13189                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
13190                    rusqlite::params![sequence],
13191                )
13192                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13193        }
13194        Ok(())
13195    }
13196
13197    // -- driver surface ---------------------------------------------------------
13198
13199    pub fn subscribe(
13200        &mut self,
13201        id: String,
13202        table: String,
13203        scopes: Vec<(String, Vec<String>)>,
13204        params: Option<String>,
13205    ) -> Result<(), String> {
13206        if self.schema.table(&table).is_none() {
13207            return Err(format!("unknown table {table:?}"));
13208        }
13209        let registered_at = self
13210            .subs
13211            .iter()
13212            .position(|subscription| subscription.id == id);
13213        if let Some(index) = registered_at {
13214            let existing = &self.subs[index];
13215            let same_intent = existing.table == table
13216                && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
13217                && existing.params == params;
13218            if !same_intent {
13219                return Err(
13220                    "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
13221                        .to_owned(),
13222                );
13223            }
13224            // §3.3: a revoked registration keeps its id and intent but no
13225            // progress. Subscribing again with the same intent re-arms it and
13226            // asks for a pull, which resolves its scopes from scratch. A
13227            // `failed` registration is a configuration error and stays put.
13228            if existing.state != SubState::Revoked {
13229                return Ok(());
13230            }
13231        }
13232        let sub = Subscription {
13233            id: id.clone(),
13234            table,
13235            requested: scopes,
13236            params,
13237            cursor: -1,
13238            bootstrap_state: None,
13239            state: SubState::Active,
13240            reason_code: None,
13241            effective: None,
13242            synced_once: false,
13243        };
13244        self.persist_sub(&sub)?;
13245        match registered_at {
13246            Some(index) => self.subs[index] = sub,
13247            None => self.subs.push(sub),
13248        }
13249        // Only a re-arm asks for a sync here: a fresh registration reaches the
13250        // next round through the host's schedule, and `set_window` (§4.8)
13251        // batches its own intent for the units it registers.
13252        if registered_at.is_some() {
13253            self.set_sync_needed(true, true);
13254        }
13255        Ok(())
13256    }
13257
13258    pub fn unsubscribe(&mut self, id: &str) {
13259        if self.subs.iter().any(|s| s.id == id) {
13260            self.cancel_sync_round();
13261        }
13262        self.subs.retain(|s| s.id != id);
13263        let _ = self.conn.execute(
13264            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
13265            rusqlite::params![id],
13266        );
13267    }
13268
13269    // -- windowed subscriptions (§4.8) ------------------------------------------
13270
13271    /// §4.8: set the live window units for a base — a value-sharded family
13272    /// of subscriptions, one per unit. Added units get fresh subscriptions
13273    /// (image-lane bootstrap on the next sync); removed units are
13274    /// unsubscribed with their first eviction chunk in one transaction (E1–E4).
13275    /// Idempotent; re-entry cancels any deferred eviction.
13276    pub fn set_window(
13277        &mut self,
13278        base: &WindowBase,
13279        units: &[String],
13280    ) -> Result<CommandEffects, String> {
13281        let table = self
13282            .schema
13283            .table(&base.table)
13284            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
13285        if table.scope_column(&base.variable).is_none() {
13286            return Err(format!(
13287                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
13288                base.table, base.variable
13289            ));
13290        }
13291        let base_key = window_base_key(base);
13292        let wanted: std::collections::HashSet<&String> = units.iter().collect();
13293        let live = self.load_window_units(&base_key);
13294        self.begin_observation("syncular_window")?;
13295        let mut batch = ChangeAccumulator::default();
13296        let mut changed = false;
13297
13298        let prior_subs = self.subs.clone();
13299        let widened = (|| {
13300            // Widen: units wanted but not live → fresh subscription + registry row.
13301            for unit in units {
13302                if live.iter().any(|(u, _)| u == unit) {
13303                    continue;
13304                }
13305                let sub_id = derive_sub_id(base, unit);
13306                self.delete_pending_evict(&sub_id)?;
13307                self.insert_window_unit(&base_key, unit, &sub_id)?;
13308                self.subscribe(
13309                    sub_id,
13310                    base.table.clone(),
13311                    unit_scopes(base, unit),
13312                    base.params.clone(),
13313                )?;
13314                batch.window(&base_key, &base.table, unit);
13315                changed = true;
13316            }
13317
13318            self.finish_observation("syncular_window", batch)
13319        })();
13320        if let Err(error) = widened {
13321            self.rollback_observation("syncular_window");
13322            self.subs = prior_subs;
13323            return Err(error);
13324        }
13325        for (unit, sub_id) in live {
13326            if wanted.contains(&unit) {
13327                continue;
13328            }
13329            self.rebuild_overlay_if_dirty()?;
13330            let prior_subs = self.subs.clone();
13331            self.begin_observation("syncular_window")?;
13332            let effective = self
13333                .subs
13334                .iter()
13335                .find(|sub| sub.id == sub_id)
13336                .and_then(|sub| sub.effective.clone())
13337                .unwrap_or_else(|| unit_scopes(base, &unit));
13338            let mut batch = ChangeAccumulator::default();
13339            self.record_scope_map(&mut batch, &base.table, &effective);
13340            batch.window(&base_key, &base.table, &unit);
13341            let result = self.evict_unit(&base_key, base, &unit, &sub_id);
13342            let remaining = match result {
13343                Ok(remaining) => remaining,
13344                Err(error) => {
13345                    self.rollback_observation("syncular_window");
13346                    self.subs = prior_subs;
13347                    return Err(error);
13348                }
13349            };
13350            if let Err(error) = self.finish_observation("syncular_window", batch) {
13351                self.rollback_observation("syncular_window");
13352                self.subs = prior_subs;
13353                return Err(error);
13354            }
13355            if remaining {
13356                self.drain_pending_evictions()?;
13357            }
13358            changed = true;
13359        }
13360        Ok(if changed {
13361            CommandEffects::interactive()
13362        } else {
13363            CommandEffects::none()
13364        })
13365    }
13366
13367    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
13368    /// the subset still bootstrap-pending. Registration alone is not
13369    /// completeness — a unit is pending until its subscription completes a
13370    /// bootstrap round (cursor advances past -1 with no resume token held).
13371    pub fn window_state(&self, base: &WindowBase) -> WindowState {
13372        let mut units = Vec::new();
13373        let mut pending = Vec::new();
13374        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
13375            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
13376                Some(sub) => {
13377                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
13378                }
13379                None => true,
13380            };
13381            if is_pending {
13382                pending.push(unit.clone());
13383            }
13384            units.push(unit);
13385        }
13386        WindowState { units, pending }
13387    }
13388
13389    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
13390        let mut stmt = match self
13391            .conn
13392            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
13393        {
13394            Ok(stmt) => stmt,
13395            Err(_) => return Vec::new(),
13396        };
13397        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
13398            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
13399        });
13400        match rows {
13401            Ok(rows) => rows.filter_map(Result::ok).collect(),
13402            Err(_) => Vec::new(),
13403        }
13404    }
13405
13406    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
13407        let mut stmt = match self.conn.prepare(
13408            "SELECT windows.base, windows.unit, subscriptions.tbl
13409               FROM _syncular_windows AS windows
13410               JOIN _syncular_subscriptions AS subscriptions
13411                 ON subscriptions.id = windows.sub_id
13412               ORDER BY windows.base, windows.unit",
13413        ) {
13414            Ok(stmt) => stmt,
13415            Err(_) => return Vec::new(),
13416        };
13417        let rows = stmt.query_map([], |row| {
13418            Ok((
13419                row.get::<_, String>(0)?,
13420                row.get::<_, String>(1)?,
13421                row.get::<_, String>(2)?,
13422            ))
13423        });
13424        match rows {
13425            Ok(rows) => rows.filter_map(Result::ok).collect(),
13426            Err(_) => Vec::new(),
13427        }
13428    }
13429
13430    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
13431        self.conn
13432            .query_row(
13433                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
13434                rusqlite::params![sub_id],
13435                |row| Ok((row.get(0)?, row.get(1)?)),
13436            )
13437            .ok()
13438    }
13439
13440    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
13441        self.conn
13442            .execute(
13443                "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
13444                rusqlite::params![base_key, unit, sub_id],
13445            )
13446            .map(|_| ())
13447            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13448    }
13449
13450    fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
13451        self.conn
13452            .execute(
13453                "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
13454                rusqlite::params![base_key, unit],
13455            )
13456            .map(|_| ())
13457            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13458    }
13459
13460    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
13461    /// Deletes the unit's rows except those pinned by a pending outbox
13462    /// commit (E1); records a deferred eviction if any pin remains; discards
13463    /// the subscription's cursor/resume/effective-echo (E3) and version
13464    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
13465    /// nothing.
13466    fn evict_unit(
13467        &mut self,
13468        base_key: &str,
13469        base: &WindowBase,
13470        unit: &str,
13471        sub_id: &str,
13472    ) -> Result<bool, String> {
13473        let effective = self
13474            .subs
13475            .iter()
13476            .find(|s| s.id == sub_id)
13477            .and_then(|s| s.effective.clone())
13478            .unwrap_or_else(|| unit_scopes(base, unit));
13479        let pinned = self.pinned_row_ids(&base.table);
13480        let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
13481        self.delete_window_unit(base_key, unit)?;
13482        self.conn
13483            .execute(
13484                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
13485                [sub_id],
13486            )
13487            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13488        self.subs.retain(|sub| sub.id != sub_id);
13489        if deferred {
13490            self.save_pending_evict(sub_id, &base.table, &effective)?;
13491        } else {
13492            self.delete_pending_evict(sub_id)?;
13493        }
13494        Ok(remaining)
13495    }
13496
13497    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
13498    /// primary keys. Returns (more unpinned work, any scoped rows remain).
13499    /// A missing scope-column mapping fails without deleting rows.
13500    fn evict_scope_rows(
13501        &self,
13502        table_name: &str,
13503        effective: &[(String, Vec<String>)],
13504        pinned: &std::collections::HashSet<String>,
13505    ) -> Result<(bool, bool), String> {
13506        if effective.is_empty() {
13507            return Ok((false, false));
13508        }
13509        let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
13510        let mut clauses = Vec::new();
13511        let mut params: Vec<SqlValue> = Vec::new();
13512        for (variable, values) in effective {
13513            let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
13514            if values.is_empty() {
13515                return Ok((false, false));
13516            }
13517            let holes = values
13518                .iter()
13519                .map(|v| {
13520                    params.push(SqlValue::Text(v.clone()));
13521                    "?"
13522                })
13523                .collect::<Vec<_>>()
13524                .join(", ");
13525            clauses.push(format!("{} IN ({holes})", quote_ident(column)));
13526        }
13527        let scope = clauses.join(" AND ");
13528        let scope_params = params.len();
13529        let pk = quote_ident(&table.primary_key);
13530        if !pinned.is_empty() {
13531            let holes = pinned
13532                .iter()
13533                .map(|id| {
13534                    params.push(SqlValue::Text(id.clone()));
13535                    "?"
13536                })
13537                .collect::<Vec<_>>()
13538                .join(", ");
13539            clauses.push(format!("{pk} NOT IN ({holes})"));
13540        }
13541        let predicate = clauses.join(" AND ");
13542        let base = base_table(table_name);
13543        let visible = visible_table(table_name);
13544        let mut ids: Vec<SqlValue> = Vec::new();
13545        for source in [&base, &visible] {
13546            let mut stmt = self
13547                .conn
13548                .prepare_cached(&format!(
13549                    "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
13550                ))
13551                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13552            ids = stmt
13553                .query_map(rusqlite::params_from_iter(&params), |row| row.get(0))
13554                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13555                .collect::<Result<_, _>>()
13556                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13557            if !ids.is_empty() {
13558                break;
13559            }
13560        }
13561        if !ids.is_empty() {
13562            for id in &ids {
13563                self.conn
13564                    .execute(
13565                        "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
13566                        rusqlite::params![table_name, id],
13567                    )
13568                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13569            }
13570            let holes = vec!["?"; ids.len()].join(", ");
13571            for source in [&base, &visible] {
13572                self.conn
13573                    .execute(
13574                        &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
13575                        rusqlite::params_from_iter(&ids),
13576                    )
13577                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13578            }
13579        }
13580        let mut remaining = false;
13581        let mut deferred = false;
13582        for source in [&base, &visible] {
13583            remaining |= self
13584                .conn
13585                .query_row(
13586                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
13587                    rusqlite::params_from_iter(&params),
13588                    |row| row.get::<_, bool>(0),
13589                )
13590                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13591            deferred |= self
13592                .conn
13593                .query_row(
13594                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
13595                    rusqlite::params_from_iter(&params[..scope_params]),
13596                    |row| row.get::<_, bool>(0),
13597                )
13598                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13599        }
13600        Ok((remaining, deferred))
13601    }
13602
13603    /// Resume committed eviction chunks, retaining outbox pins and their durable marker.
13604    fn drain_pending_evictions(&mut self) -> Result<(), String> {
13605        let pending = self.load_pending_evictions()?;
13606        if pending.is_empty() {
13607            return Ok(());
13608        }
13609        self.rebuild_overlay_if_dirty()?;
13610        for (sub_id, table_name, effective) in pending {
13611            if self.schema.table(&table_name).is_none() {
13612                self.delete_pending_evict(&sub_id)?;
13613                continue;
13614            }
13615            loop {
13616                std::thread::yield_now();
13617                self.begin_observation("syncular_evict")?;
13618                let pinned = self.pinned_row_ids(&table_name);
13619                let result = self.evict_scope_rows(&table_name, &effective, &pinned);
13620                let (remaining, deferred) = match result {
13621                    Ok(result) => result,
13622                    Err(error) => {
13623                        self.rollback_observation("syncular_evict");
13624                        return Err(error);
13625                    }
13626                };
13627                if !deferred {
13628                    if let Err(error) = self.delete_pending_evict(&sub_id) {
13629                        self.rollback_observation("syncular_evict");
13630                        return Err(error);
13631                    }
13632                }
13633                let mut batch = ChangeAccumulator::default();
13634                self.record_scope_map(&mut batch, &table_name, &effective);
13635                if let Err(error) = self.finish_observation("syncular_evict", batch) {
13636                    self.rollback_observation("syncular_evict");
13637                    return Err(error);
13638                }
13639                if !remaining {
13640                    break;
13641                }
13642            }
13643        }
13644        Ok(())
13645    }
13646
13647    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
13648    /// commit — rows that MUST NOT be evicted until the commit drains.
13649    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
13650        let mut pinned = std::collections::HashSet::new();
13651        for commit in self
13652            .outbox
13653            .iter()
13654            .chain(self.failed_commits.iter().map(|(commit, _)| commit))
13655        {
13656            for op in &commit.ops {
13657                if op.table == table {
13658                    pinned.insert(op.row_id.clone());
13659                }
13660            }
13661        }
13662        pinned
13663    }
13664
13665    fn save_pending_evict(
13666        &self,
13667        sub_id: &str,
13668        table: &str,
13669        effective: &[(String, Vec<String>)],
13670    ) -> Result<(), String> {
13671        self.conn.execute(
13672            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
13673            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
13674        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13675    }
13676
13677    fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
13678        self.conn
13679            .execute(
13680                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
13681                rusqlite::params![sub_id],
13682            )
13683            .map(|_| ())
13684            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13685    }
13686
13687    fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
13688        let mut stmt = self
13689            .conn
13690            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
13691            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13692        let rows = stmt
13693            .query_map([], |row| {
13694                Ok((
13695                    row.get::<_, String>(0)?,
13696                    row.get::<_, String>(1)?,
13697                    row.get::<_, String>(2)?,
13698                ))
13699            })
13700            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13701        rows.map(|row| {
13702            let (sub_id, table, json) =
13703                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13704            let value: Value = serde_json::from_str(&json)
13705                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
13706            let effective = json_to_scope_map(&value)
13707                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
13708            Ok((sub_id, table, effective))
13709        })
13710        .collect()
13711    }
13712
13713    /// Select failed-intent retention; reload durable retained aggregates.
13714    pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
13715        self.retain_failed_commits = enabled;
13716        for (commit, _) in &self.failed_commits {
13717            for operation in &commit.ops {
13718                self.overlay_dirty.table(&operation.table);
13719            }
13720        }
13721        self.failed_commits = self.load_failed_commits()?;
13722        for (commit, _) in &self.failed_commits {
13723            for operation in &commit.ops {
13724                self.overlay_dirty.table(&operation.table);
13725            }
13726        }
13727        Ok(())
13728    }
13729
13730    fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
13731        let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
13732            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13733        let rows = statement
13734            .query_map([], |row| {
13735                Ok((
13736                    row.get::<_, String>(0)?,
13737                    row.get::<_, String>(1)?,
13738                    row.get::<_, String>(2)?,
13739                ))
13740            })
13741            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13742        rows.map(|row| {
13743            let (id, operations, initial) =
13744                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13745            let operations: Vec<CommitOperation> =
13746                serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
13747            let initial =
13748                serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
13749            Ok((
13750                OutboxCommit {
13751                    client_commit_id: id,
13752                    ops: operations
13753                        .into_iter()
13754                        .map(|op| OutboxOp {
13755                            upsert: op.op == "upsert",
13756                            table: op.table,
13757                            row_id: op.row_id,
13758                            base_version: op.base_version,
13759                            values: op.values,
13760                        })
13761                        .collect(),
13762                },
13763                initial,
13764            ))
13765        })
13766        .collect()
13767    }
13768
13769    fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
13770        let mut initial = Map::new();
13771        for operation in operations {
13772            let table = self
13773                .schema
13774                .table(&operation.table)
13775                .ok_or_else(|| "sync.unknown_table".to_owned())?;
13776            let initial_row = match self.stored_row(table, &operation.row_id, true)? {
13777                Some(row) => Some(row),
13778                None => self.visible_row(table, &operation.row_id)?,
13779            };
13780            if let Some((values, _)) = initial_row {
13781                let mut intended: Map<String, Value> = table
13782                    .columns
13783                    .iter()
13784                    .zip(values.iter())
13785                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
13786                    .collect();
13787                if let Some(patch) = &operation.values {
13788                    intended.extend(patch.clone());
13789                }
13790                initial.insert(
13791                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
13792                        .map_err(|_| "sync.local_corrupt".to_owned())?,
13793                    Value::Object(intended),
13794                );
13795            }
13796        }
13797        self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
13798            serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
13799            serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
13800            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13801        self.failed_commits.push((
13802            OutboxCommit {
13803                client_commit_id: id.to_owned(),
13804                ops: operations.to_vec(),
13805            },
13806            initial,
13807        ));
13808        Ok(())
13809    }
13810
13811    fn drop_failed_commits(
13812        &mut self,
13813        matches: impl Fn(&str, &Map<String, Value>) -> bool,
13814    ) -> Result<Vec<OutboxCommit>, String> {
13815        let mut doomed = self.drop_acknowledged_rows(&matches)?;
13816        for (commit, initial) in &self.failed_commits {
13817            for operation in &commit.ops {
13818                let key =
13819                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
13820                        .map_err(|_| "sync.local_corrupt".to_owned())?;
13821                if initial
13822                    .get(&key)
13823                    .and_then(Value::as_object)
13824                    .is_some_and(|values| matches(&operation.table, values))
13825                {
13826                    doomed.push(commit.clone());
13827                    break;
13828                }
13829            }
13830        }
13831        for commit in &doomed {
13832            self.conn
13833                .execute(
13834                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
13835                    rusqlite::params![commit.client_commit_id],
13836                )
13837                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13838        }
13839        for commit in &doomed {
13840            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))?;
13841        }
13842        self.conflicts.retain(|record| {
13843            !doomed
13844                .iter()
13845                .any(|commit| commit.client_commit_id == record.client_commit_id)
13846        });
13847        self.rejections.retain(|record| {
13848            !doomed
13849                .iter()
13850                .any(|commit| commit.client_commit_id == record.client_commit_id)
13851        });
13852        self.failed_commits.retain(|(commit, _)| {
13853            !doomed
13854                .iter()
13855                .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
13856        });
13857        for commit in &doomed {
13858            for operation in &commit.ops {
13859                self.overlay_dirty.table(&operation.table);
13860            }
13861        }
13862        Ok(doomed)
13863    }
13864
13865    /// Record one commit of local mutations. The returned commit ID is the
13866    /// §2.3 idempotency key. Failures are structured so callers read
13867    /// `code`/`message`/`details` instead of parsing a string prefix.
13868    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, ClientError> {
13869        let prior = self.storage_failure.borrow_mut().take();
13870        let outcome = (|| -> Result<String, String> {
13871            if mutations.is_empty() {
13872                return Err(
13873                    "sync.invalid_request: a commit must contain at least one operation (§6.1)"
13874                        .to_owned(),
13875                );
13876            }
13877            let mut ops = Vec::with_capacity(mutations.len());
13878            for mutation in mutations {
13879                match mutation {
13880                    Mutation::Patch {
13881                        table,
13882                        values,
13883                        base_version,
13884                    } => {
13885                        let schema_table = self
13886                            .schema
13887                            .table(&table)
13888                            .ok_or_else(|| "sync.unknown_table".to_owned())?;
13889                        let values = normalize_values_casing(schema_table, values)?;
13890                        let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
13891                        ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
13892                    }
13893                    Mutation::Upsert {
13894                        table,
13895                        values,
13896                        base_version,
13897                    } => {
13898                        let schema_table = self.schema.table(&table).ok_or_else(|| {
13899                            format!("sync.unknown_table: unknown table {table:?}")
13900                        })?;
13901                        // §5: value keys are accepted in snake_case AND the
13902                        // generated row types' camelCase; normalize to SQL truth
13903                        // before the pk lookup / codec see them.
13904                        let values = normalize_values_casing(schema_table, values)?;
13905                        let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
13906                        // §6.7 `mutate` marks every column present: fill missing
13907                        // nullable columns with NULL before the payload is built.
13908                        let values = full_row_values(schema_table, values)?;
13909                        // §5.11: validate the payload encodes with the current
13910                        // codec. A key-selection or unknown-key failure is a
13911                        // durable rejection at the push seam (§10.3), never an
13912                        // author-time error. Full-row `mutate` presents every
13913                        // column, so no stored fallback is needed.
13914                        self.validate_author_encode(schema_table, &row_id, &values)?;
13915                        ops.push(OutboxOp {
13916                            upsert: true,
13917                            table,
13918                            row_id,
13919                            base_version,
13920                            values: Some(values),
13921                        });
13922                    }
13923                    Mutation::Delete {
13924                        table,
13925                        row_id,
13926                        base_version,
13927                    } => {
13928                        if self.schema.table(&table).is_none() {
13929                            return Err(format!("sync.unknown_table: unknown table {table:?}"));
13930                        }
13931                        ops.push(OutboxOp {
13932                            upsert: false,
13933                            table,
13934                            row_id,
13935                            base_version,
13936                            values: None,
13937                        });
13938                    }
13939                }
13940            }
13941            self.record_outbox_commit(ops)
13942        })();
13943        self.finish_authoring(prior, outcome)
13944    }
13945
13946    /// Scope the retained `storage_failure` diagnostic to this call so an
13947    /// earlier operation's failure cannot classify a new one, and surface the
13948    /// call's own classified failure with its details. A fresh classified
13949    /// failure replaces the retained diagnostic; otherwise the earlier one is
13950    /// kept for the next sync.
13951    fn finish_authoring(
13952        &self,
13953        prior: Option<QueryReadFailure>,
13954        outcome: Result<String, String>,
13955    ) -> Result<String, ClientError> {
13956        let fresh = self.storage_failure.borrow_mut().take();
13957        *self.storage_failure.borrow_mut() = fresh.clone().or(prior);
13958        match outcome {
13959            Ok(id) => Ok(id),
13960            Err(message) => Err(fresh
13961                .map(ClientError::from)
13962                .unwrap_or_else(|| ClientError::from(message))),
13963        }
13964    }
13965
13966    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
13967        let commit = OutboxCommit {
13968            client_commit_id: uuid::Uuid::new_v4().to_string(),
13969            ops,
13970        };
13971        self.begin_observation("syncular_mutation")?;
13972        let mut batch = ChangeAccumulator::default();
13973        for op in &commit.ops {
13974            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
13975            if let Some(values) = &op.values {
13976                if let Some(table) = self.schema.table(&op.table) {
13977                    for scope in &table.scope_variables {
13978                        if let Some(Value::String(value)) = values.get(&scope.column) {
13979                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
13980                            precise = true;
13981                        }
13982                    }
13983                }
13984            }
13985            if !precise {
13986                batch.table(&op.table);
13987            }
13988        }
13989        if let Err(error) = self.persist_outbox_insert(&commit) {
13990            self.rollback_observation("syncular_mutation");
13991            return Err(error);
13992        }
13993        let was_dirty = self.overlay_dirty.snapshot();
13994        // A clean overlay contains the FIFO fold of every previous commit, so
13995        // the new commit applies over it. §7.1: an operation that does not
13996        // apply (a secondary unique collision) fails the whole commit; the
13997        // savepoint rollback removes the outbox entry and every visible write.
13998        if let Err(error) = self.rebuild_overlay_if_dirty() {
13999            self.rollback_observation("syncular_mutation");
14000            self.overlay_dirty.restore(was_dirty.clone());
14001            return Err(error);
14002        }
14003        if let Some(error) = commit
14004            .ops
14005            .iter()
14006            .find_map(|op| self.apply_outbox_op(op).err())
14007        {
14008            self.rollback_observation("syncular_mutation");
14009            self.overlay_dirty.restore(was_dirty.clone());
14010            return Err(error);
14011        }
14012        let id = commit.client_commit_id.clone();
14013        self.outbox.push(commit);
14014        batch.status = true;
14015        if let Err(error) = self.finish_observation("syncular_mutation", batch) {
14016            self.rollback_observation("syncular_mutation");
14017            self.outbox.pop();
14018            self.overlay_dirty.restore(was_dirty.clone());
14019            return Err(error);
14020        }
14021        Ok(id)
14022    }
14023
14024    /// Record one §6.1 sparse upsert: the primary key plus the supplied
14025    /// non-scope columns are present; every other column is absent and stays
14026    /// untouched on the server and in the local overlay. An absent local base
14027    /// refuses the whole batch with sync.row_missing before enqueueing (§7.1).
14028    /// Failures are structured so callers read `code`/`message`/`details`.
14029    pub fn patch(
14030        &mut self,
14031        table: &str,
14032        row_id: &str,
14033        partial: Map<String, Value>,
14034        base_version: Option<i64>,
14035    ) -> Result<String, ClientError> {
14036        let prior = self.storage_failure.borrow_mut().take();
14037        let outcome = self.perform_patch(table, row_id, partial, base_version);
14038        self.finish_authoring(prior, outcome)
14039    }
14040
14041    /// The unscoped patch body. Public authoring entry points scope
14042    /// `storage_failure` around the whole operation, including any pre-read.
14043    fn perform_patch(
14044        &mut self,
14045        table: &str,
14046        row_id: &str,
14047        partial: Map<String, Value>,
14048        base_version: Option<i64>,
14049    ) -> Result<String, String> {
14050        let operation = self.prepare_patch(table, row_id, partial, base_version)?;
14051        self.record_outbox_commit(vec![operation])
14052    }
14053
14054    fn prepare_patch(
14055        &self,
14056        table: &str,
14057        row_id: &str,
14058        partial: Map<String, Value>,
14059        base_version: Option<i64>,
14060    ) -> Result<OutboxOp, String> {
14061        let schema_table = self
14062            .schema
14063            .table(table)
14064            .ok_or_else(|| format!("sync.unknown_table: unknown table {table:?}"))?;
14065        let partial = normalize_values_casing(schema_table, partial)?;
14066        if let Some(pk) = partial.get(&schema_table.primary_key) {
14067            if render_row_id_json(Some(pk)).ok().as_deref() != Some(row_id) {
14068                return Err(format!(
14069                    "sync.invalid_request: table {table:?}: patch cannot change the primary key"
14070                ));
14071            }
14072        }
14073        // Existence reads only the key. Locked encrypted values stay opaque;
14074        // decoding the complete row here would reject a valid plain patch.
14075        let exists: bool = self
14076            .conn
14077            .query_row(
14078                &format!(
14079                    "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
14080                    visible_table(table),
14081                    row_id_predicate(schema_table)
14082                ),
14083                rusqlite::params![row_id],
14084                |row| row.get(0),
14085            )
14086            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14087        if !exists {
14088            return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
14089        }
14090        // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
14091        // server accepts a present scope column whose value equals the
14092        // stored row and applies it as a no-op, so a decoded envelope
14093        // round-trips. The local row is the only value the client can prove
14094        // equality against; when the row is absent locally the client cannot
14095        // prove equality against the server's stored row and fails closed.
14096        // A primary key that is also a scope column needs no stored-row
14097        // read: the primary key in a sparse payload is by construction the
14098        // row id being patched (§6.1), so its value is proven equal already.
14099        for scope in &schema_table.scope_variables {
14100            if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
14101                continue;
14102            }
14103            let Some(column) = schema_table
14104                .columns
14105                .iter()
14106                .find(|column| column.name == scope.column)
14107            else {
14108                continue;
14109            };
14110            let stored = self
14111                .conn
14112                .query_row(
14113                    &format!(
14114                        "SELECT {} FROM {} WHERE {}",
14115                        quote_ident(&scope.column),
14116                        visible_table(&schema_table.name),
14117                        row_id_predicate(schema_table)
14118                    ),
14119                    rusqlite::params![row_id],
14120                    |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
14121                )
14122                .optional()
14123                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14124            // Decode the supplied value through the same column-type path the
14125            // rest of the patch encoding uses, so a value that only equals
14126            // the stored one in another number representation still compares
14127            // equal.
14128            let incoming = json_to_column_value(column, partial.get(&scope.column))
14129                .ok()
14130                .map(|value| column_value_to_json(&value));
14131            // A stored blob never equals a patch value, matching the server's
14132            // scope-column comparison (§3.4): bytes are unstorable as scope
14133            // values.
14134            let equal = stored.as_ref().is_some_and(|stored| {
14135                stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
14136            });
14137            if !equal {
14138                return Err(format!(
14139                    "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
14140                    scope.column
14141                ));
14142            }
14143        }
14144        let mut values = partial;
14145        // Presence-set semantics: a proven-equal scope column is dropped, so
14146        // the patch leaves the stored value untouched (§6.2).
14147        for scope in &schema_table.scope_variables {
14148            values.remove(&scope.column);
14149        }
14150        let pk_value = match schema_table.columns[schema_table.pk_index].ty {
14151            // §2.4: the rowId of an integer key is its canonical decimal form.
14152            ColumnType::Integer => match row_id.parse::<i64>() {
14153                Ok(v)
14154                    if v.to_string() == row_id
14155                        && (-ssp2::primitives::I64_SAFE_MAX..=ssp2::primitives::I64_SAFE_MAX)
14156                            .contains(&v) =>
14157                {
14158                    Value::from(v)
14159                }
14160                _ => {
14161                    return Err(format!(
14162                    "sync.invalid_request: table {table:?}: invalid integer primary key {row_id:?}"
14163                ))
14164                }
14165            },
14166            ColumnType::Boolean => match row_id {
14167                "true" => Value::Bool(true),
14168                "false" => Value::Bool(false),
14169                _ => {
14170                    return Err(format!(
14171                    "sync.invalid_request: table {table:?}: invalid boolean primary key {row_id:?}"
14172                ))
14173                }
14174            },
14175            _ => Value::from(row_id.to_owned()),
14176        };
14177        values.insert(schema_table.primary_key.clone(), pk_value);
14178        // §5.11: structural validation only; an absent key-id selector is
14179        // resolved at the push seam from the stored local row, and a
14180        // key-selection or unknown-key failure becomes a durable
14181        // `client.encrypt_failed` rejection there (§10.3), never an
14182        // author-time error.
14183        self.validate_author_encode(schema_table, row_id, &values)?;
14184        Ok(OutboxOp {
14185            upsert: true,
14186            table: table.to_owned(),
14187            row_id: row_id.to_owned(),
14188            base_version,
14189            values: Some(values),
14190        })
14191    }
14192
14193    /// §5.11 key-id fallback: the stored local row's selector value as a
14194    /// positional row (only the selector slot filled). Returns None when no
14195    /// selector is configured, the row is locally absent, or the stored
14196    /// value is not a usable key id.
14197    fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
14198        let selector = self.encryption.key_id_columns.get(&table.name)?;
14199        let index = table
14200            .columns
14201            .iter()
14202            .position(|column| &column.name == selector)?;
14203        let sql = format!(
14204            "SELECT {} FROM {} WHERE {}",
14205            quote_ident(selector),
14206            visible_table(&table.name),
14207            row_id_predicate(table)
14208        );
14209        let key: Option<String> = self
14210            .conn
14211            .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
14212            .ok()?;
14213        let key = key.filter(|key| !key.is_empty())?;
14214        let mut fallback: Row = vec![None; table.columns.len()];
14215        fallback[index] = Some(ColumnValue::String(key));
14216        Some(fallback)
14217    }
14218
14219    /// §5.11/§10.3 author-time encode check: a structural failure (a
14220    /// non-nullable NULL, a value the codec rejects) is an author-time
14221    /// error; a key-selection or unknown-key failure is deferred to the push
14222    /// seam, where it becomes a durable `client.encrypt_failed` rejection.
14223    fn validate_author_encode(
14224        &self,
14225        table: &TableSchema,
14226        row_id: &str,
14227        values: &Map<String, Value>,
14228    ) -> Result<(), String> {
14229        match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
14230            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
14231            result => result.map(|_| ()),
14232        }
14233    }
14234
14235    /// §5.11/§10.3: drop every pending commit whose push encode fails with
14236    /// `client.encrypt_failed` (an unresolvable key id or an unknown key) and
14237    /// raise one durable rejection per commit. `sync()` never aborts for an
14238    /// encode failure. Mirrors §7.4.4's `drop_incompatible_outbox`, including
14239    /// undoing the dropped commit's purely-optimistic rows.
14240    /// §7.1/§7.4.4: drop pending commits whose values the current codec refuses
14241    /// (an envelope or a type an earlier version accepted). `with_encryption`
14242    /// additionally drops commits whose §5.11 encryption cannot resolve a key;
14243    /// the startup boundary leaves those to the send seam, where hosts configure
14244    /// keys after open.
14245    /// Recovery runs as one observation. An enclosing observation (the guarded
14246    /// schema reset) passes its `batch`, so the rejection, outcome, and table
14247    /// domains join that change event instead of publishing a second one.
14248    fn drop_unencodable_outbox(
14249        &mut self,
14250        with_encryption: bool,
14251        batch: Option<&mut ChangeAccumulator>,
14252    ) -> Result<bool, String> {
14253        let failures = self
14254            .outbox
14255            .iter()
14256            .filter_map(|commit| {
14257                commit
14258                    .ops
14259                    .iter()
14260                    .find_map(|op| {
14261                        let values = op.values.as_ref().filter(|_| op.upsert)?;
14262                        let table = self.schema.table(&op.table)?;
14263                        let fallback = self.stored_key_fallback(table, &op.row_id);
14264                        match encode_sparse_row_json(
14265                            table,
14266                            &op.row_id,
14267                            values,
14268                            &self.encryption,
14269                            fallback.as_ref(),
14270                        ) {
14271                            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => {
14272                                with_encryption.then_some((error, ENCRYPT_FAILED_CODE))
14273                            }
14274                            // §7.1/§7.4.4: a value the current codec refuses was
14275                            // persisted by a version that accepted it; it leaves
14276                            // through the incompatible-outbox path, so the FIFO
14277                            // queue never wedges.
14278                            Err(error) if error.starts_with(INVALID_REQUEST_CODE) => Some((
14279                                LEGACY_VALUE_REFUSAL_MESSAGE.to_owned(),
14280                                OUTBOX_INCOMPATIBLE_CODE,
14281                            )),
14282                            _ => None,
14283                        }
14284                    })
14285                    .map(|(error, code)| (commit.clone(), error, code))
14286            })
14287            .collect::<Vec<_>>();
14288        if failures.is_empty() {
14289            return Ok(false);
14290        }
14291        // §7.4.4: one observation covers the durable outcome writes, the outbox
14292        // removal, the in-memory drop, and the overlay rebuild, so a storage fault
14293        // leaves the replica and the client unchanged, a successful recovery
14294        // publishes one batch with the rejection, outcome, and table domains, and
14295        // an ordinary open with nothing to recover publishes nothing.
14296        let prior_outbox = self.outbox.clone();
14297        let prior_rejections = self.rejections.clone();
14298        // §5.11 retention and a self-published observation mutate two more
14299        // in-memory fields before this transaction ends, so the rollback below
14300        // restores them with the rest.
14301        let prior_failed_commits = self.failed_commits.clone();
14302        let prior_changes = self.change_queue.len();
14303        let prior_intents = self.sync_intent_queue.len();
14304        let prior_last_change = self.last_change.clone();
14305        let prior_overlay_dirty = self.overlay_dirty.snapshot();
14306        let owns_observation = batch.is_none();
14307        let owns_transaction = self.conn.is_autocommit();
14308        let mut domains = ChangeAccumulator::default();
14309        if owns_observation {
14310            self.begin_observation("syncular_value_recovery")?;
14311        }
14312        let dropped = (|| -> Result<(), String> {
14313            for (commit, message, code) in failures {
14314                if self.retain_failed_commits && code == ENCRYPT_FAILED_CODE {
14315                    self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
14316                }
14317                let representable = commit.ops.iter().all(Self::outbox_op_is_journalable);
14318                let rejection = RejectionRecord {
14319                    client_commit_id: commit.client_commit_id.clone(),
14320                    op_index: 0,
14321                    code: code.to_owned(),
14322                    message,
14323                    retryable: false,
14324                    details: (code == OUTBOX_INCOMPATIBLE_CODE).then(|| RejectionDetails {
14325                        field_paths: None,
14326                        reason: Some(INVALID_STORED_VALUES_REASON.to_owned()),
14327                        required_action: None,
14328                        references: None,
14329                    }),
14330                    operation: representable
14331                        .then(|| commit.ops.first())
14332                        .flatten()
14333                        .map(CommitOperation::from),
14334                };
14335                self.persist_commit_outcome(
14336                    &commit.client_commit_id,
14337                    CommitOutcomeStatus::Rejected,
14338                    &[CommitOperationOutcome::Error {
14339                        rejection: rejection.clone(),
14340                    }],
14341                    representable.then_some(commit.ops.as_slice()),
14342                )?;
14343                self.delete_outbox_persisted(&commit.client_commit_id)?;
14344                self.outbox
14345                    .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
14346                self.rejections.push(rejection);
14347                for operation in &commit.ops {
14348                    domains.table(operation.table.as_str());
14349                }
14350            }
14351            domains.status = true;
14352            domains.rejections = true;
14353            domains.outcomes = true;
14354            self.prune_commit_outcomes()?;
14355            // The visible projection is re-derived from the surviving base + outbox.
14356            self.overlay_dirty.set(true);
14357            self.rebuild_overlay()?;
14358            Ok(())
14359        })();
14360        let result = match batch {
14361            Some(batch) => {
14362                if dropped.is_ok() {
14363                    for table in domains.tables.keys() {
14364                        batch.table(table.as_str());
14365                    }
14366                    batch.status |= domains.status;
14367                    batch.rejections |= domains.rejections;
14368                    batch.outcomes |= domains.outcomes;
14369                }
14370                dropped
14371            }
14372            None => {
14373                dropped.and_then(|()| self.finish_observation("syncular_value_recovery", domains))
14374            }
14375        };
14376        if let Err(error) = result {
14377            if owns_observation {
14378                if owns_transaction {
14379                    if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
14380                        let mut failure = self.storage_failure.borrow_mut();
14381                        let failure =
14382                            failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
14383                        failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
14384                    }
14385                } else {
14386                    self.rollback_observation("syncular_value_recovery");
14387                }
14388            }
14389            self.outbox = prior_outbox;
14390            self.rejections = prior_rejections;
14391            self.failed_commits = prior_failed_commits;
14392            self.change_queue.truncate(prior_changes);
14393            self.sync_intent_queue.truncate(prior_intents);
14394            self.last_change = prior_last_change;
14395            self.overlay_dirty.restore(prior_overlay_dirty);
14396            return Err(error);
14397        }
14398        Ok(true)
14399    }
14400
14401    pub fn pending_commit_ids(&self) -> Vec<String> {
14402        self.outbox
14403            .iter()
14404            .map(|c| c.client_commit_id.clone())
14405            .collect()
14406    }
14407
14408    /// §6.1: the sparse push payload bytes of every pending upsert operation,
14409    /// FIFO — exactly what the next push round would put on the wire. Used by
14410    /// conformance B.21(h) to pin cross-core byte identity.
14411    pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
14412        let mut payloads = Vec::new();
14413        for commit in &self.outbox {
14414            for op in &commit.ops {
14415                let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
14416                    continue;
14417                };
14418                let Some(table) = self.schema.table(&op.table) else {
14419                    continue;
14420                };
14421                let fallback = self.stored_key_fallback(table, &op.row_id);
14422                if let Ok(payload) = encode_sparse_row_json(
14423                    table,
14424                    &op.row_id,
14425                    values,
14426                    &self.encryption,
14427                    fallback.as_ref(),
14428                ) {
14429                    payloads.push(payload);
14430                }
14431            }
14432        }
14433        payloads
14434    }
14435
14436    pub fn conflicts(&self) -> &[ConflictRecord] {
14437        &self.conflicts
14438    }
14439
14440    pub fn rejections(&self) -> &[RejectionRecord] {
14441        &self.rejections
14442    }
14443
14444    pub fn resolve_commit_outcome(
14445        &mut self,
14446        input: ResolveCommitOutcomeInput,
14447    ) -> Result<CommitOutcome, String> {
14448        let current = self
14449            .commit_outcome(&input.client_commit_id)?
14450            .ok_or_else(|| {
14451                format!(
14452                    "sync.outcome_not_found: no durable outcome exists for {:?}",
14453                    input.client_commit_id
14454                )
14455            })?;
14456        if current.resolution != CommitOutcomeResolution::Active {
14457            return Ok(current);
14458        }
14459        if input.resolution == CommitOutcomeResolution::Active {
14460            return Err("sync.invalid_request: resolution must leave active state".to_owned());
14461        }
14462        match input.resolution {
14463            CommitOutcomeResolution::Superseded => {
14464                let replacement = input
14465                    .replacement_client_commit_id
14466                    .as_deref()
14467                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
14468                    .ok_or_else(|| {
14469                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
14470                            .to_owned()
14471                    })?;
14472                let _ = replacement;
14473            }
14474            _ if input.replacement_client_commit_id.is_some() => {
14475                return Err(
14476                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
14477                        .to_owned(),
14478                );
14479            }
14480            _ => {}
14481        }
14482        let allowed = match current.status {
14483            CommitOutcomeStatus::Conflict => matches!(
14484                input.resolution,
14485                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
14486            ),
14487            CommitOutcomeStatus::Rejected => {
14488                matches!(
14489                    input.resolution,
14490                    CommitOutcomeResolution::Superseded
14491                        | CommitOutcomeResolution::ResolvedKeepServer
14492                )
14493            }
14494            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
14495                input.resolution == CommitOutcomeResolution::Dismissed
14496            }
14497        };
14498        if !allowed {
14499            return Err(format!(
14500                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
14501                input.resolution, current.status
14502            ));
14503        }
14504
14505        self.begin_observation("syncular_outcome_resolution")?;
14506        let previous_failed = self.failed_commits.clone();
14507        let previous_conflicts = self.conflicts.clone();
14508        let previous_rejections = self.rejections.clone();
14509        let result = (|| {
14510            self.conn
14511                .execute(
14512                    "UPDATE _syncular_commit_outcomes
14513                        SET resolution = ?1, resolved_at_ms = ?2,
14514                            replacement_client_commit_id = ?3
14515                      WHERE client_commit_id = ?4 AND resolution = 'active'",
14516                    rusqlite::params![
14517                        Self::outcome_resolution_name(input.resolution),
14518                        self.clock_now_ms(),
14519                        input.replacement_client_commit_id,
14520                        input.client_commit_id
14521                    ],
14522                )
14523                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14524            let resolved = self
14525                .commit_outcome(&current.client_commit_id)?
14526                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
14527            self.conn
14528                .execute(
14529                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
14530                    rusqlite::params![input.client_commit_id],
14531                )
14532                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14533            for (commit, _) in &self.failed_commits {
14534                if commit.client_commit_id == input.client_commit_id {
14535                    for operation in &commit.ops {
14536                        self.overlay_dirty.table(&operation.table);
14537                    }
14538                }
14539            }
14540            self.failed_commits
14541                .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
14542            self.prune_commit_outcomes()?;
14543            Ok(resolved)
14544        })();
14545        let resolved = match result {
14546            Ok(outcome) => outcome,
14547            Err(error) => {
14548                self.rollback_observation("syncular_outcome_resolution");
14549                self.failed_commits = previous_failed;
14550                self.conflicts = previous_conflicts;
14551                self.rejections = previous_rejections;
14552                return Err(error);
14553            }
14554        };
14555        self.conflicts
14556            .retain(|record| record.client_commit_id != current.client_commit_id);
14557        self.rejections
14558            .retain(|record| record.client_commit_id != current.client_commit_id);
14559        let mut batch = ChangeAccumulator {
14560            conflicts: current.status == CommitOutcomeStatus::Conflict,
14561            rejections: current.status == CommitOutcomeStatus::Rejected,
14562            outcomes: true,
14563            ..ChangeAccumulator::default()
14564        };
14565        if let Some(operations) = &current.operations {
14566            for operation in operations {
14567                batch.table(&operation.table);
14568            }
14569        }
14570        if let Err(error) = self
14571            .rebuild_overlay_if_dirty()
14572            .and_then(|()| self.finish_observation("syncular_outcome_resolution", batch))
14573        {
14574            self.rollback_observation("syncular_outcome_resolution");
14575            self.failed_commits = previous_failed;
14576            self.conflicts = previous_conflicts;
14577            self.rejections = previous_rejections;
14578            return Err(error);
14579        }
14580        Ok(resolved)
14581    }
14582
14583    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
14584        self.schema_floor.as_ref()
14585    }
14586
14587    /// §7.3.5: the client's opaque auth-lease state, if any.
14588    pub fn lease_state(&self) -> Option<&LeaseState> {
14589        self.lease_state.as_ref()
14590    }
14591
14592    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
14593    /// the two lease codes set it; other errors leave leaseState untouched.
14594    fn record_lease_error(&mut self, code: &str) {
14595        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
14596            return;
14597        }
14598        let mut next = self.lease_state.clone().unwrap_or_default();
14599        next.error_code = Some(code.to_owned());
14600        self.set_lease_state(Some(next));
14601    }
14602
14603    fn set_lease_state(&mut self, next: Option<LeaseState>) {
14604        if self.lease_state == next {
14605            return;
14606        }
14607        if self.begin_observation("syncular_lease").is_err() {
14608            return;
14609        }
14610        self.lease_state = next;
14611        if let Some(lease) = &self.lease_state {
14612            if let Ok(json) = serde_json::to_string(lease) {
14613                self.set_meta(LEASE_STATE_KEY, &json);
14614            }
14615        } else {
14616            self.delete_meta(LEASE_STATE_KEY);
14617        }
14618        let batch = ChangeAccumulator {
14619            status: true,
14620            ..ChangeAccumulator::default()
14621        };
14622        if self.finish_observation("syncular_lease", batch).is_err() {
14623            self.rollback_observation("syncular_lease");
14624        }
14625    }
14626
14627    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
14628        if self.schema_floor == next {
14629            return;
14630        }
14631        if self.begin_observation("syncular_schema_floor").is_err() {
14632            return;
14633        }
14634        self.schema_floor = next;
14635        self.stopped = self.schema_floor.is_some();
14636        if let Some(floor) = &self.schema_floor {
14637            if let Ok(json) = serde_json::to_string(floor) {
14638                self.set_meta(SCHEMA_FLOOR_KEY, &json);
14639            }
14640        } else {
14641            self.delete_meta(SCHEMA_FLOOR_KEY);
14642        }
14643        let batch = ChangeAccumulator {
14644            status: true,
14645            ..ChangeAccumulator::default()
14646        };
14647        if self
14648            .finish_observation("syncular_schema_floor", batch)
14649            .is_err()
14650        {
14651            self.rollback_observation("syncular_schema_floor");
14652        }
14653    }
14654
14655    fn set_upgrading(&mut self, value: bool) {
14656        if self.upgrading == value {
14657            return;
14658        }
14659        if self.begin_observation("syncular_upgrading").is_err() {
14660            return;
14661        }
14662        self.upgrading = value;
14663        let batch = ChangeAccumulator {
14664            status: true,
14665            ..ChangeAccumulator::default()
14666        };
14667        if self
14668            .finish_observation("syncular_upgrading", batch)
14669            .is_err()
14670        {
14671            self.rollback_observation("syncular_upgrading");
14672        }
14673    }
14674
14675    pub fn sync_needed(&self) -> bool {
14676        self.sync_needed
14677    }
14678
14679    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
14680        let sub = self.subs.iter().find(|s| s.id == id)?;
14681        Some(SubscriptionStateView {
14682            id: sub.id.clone(),
14683            table: sub.table.clone(),
14684            status: sub.state.name().to_owned(),
14685            cursor: sub.cursor,
14686            has_resume_token: sub.bootstrap_state.is_some(),
14687            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
14688            reason_code: sub.reason_code.clone(),
14689        })
14690    }
14691
14692    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
14693        let schema_table = self
14694            .schema
14695            .table(table)
14696            .ok_or_else(|| format!("unknown table {table:?}"))?;
14697        let sql = format!(
14698            "SELECT * FROM {} ORDER BY {} ASC",
14699            visible_table(table),
14700            quote_ident(&schema_table.primary_key)
14701        );
14702        let mut stmt = self
14703            .conn
14704            .prepare(&sql)
14705            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14706        let mut rows = stmt
14707            .query([])
14708            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14709        let mut out = Vec::new();
14710        while let Some(row) = rows
14711            .next()
14712            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14713        {
14714            let mut values = Map::new();
14715            for (i, column) in schema_table.columns.iter().enumerate() {
14716                let value = row
14717                    .get_ref(i)
14718                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14719                values.insert(column.name.clone(), sql_ref_to_json(column, value));
14720            }
14721            let version: i64 = row
14722                .get(schema_table.columns.len())
14723                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14724            let row_id = match values.get(&schema_table.primary_key) {
14725                Some(Value::String(s)) => s.clone(),
14726                Some(Value::Number(n)) => n.to_string(),
14727                Some(Value::Bool(b)) => b.to_string(),
14728                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
14729            };
14730            out.push(RowState {
14731                row_id,
14732                version,
14733                values,
14734            });
14735        }
14736        Ok(out)
14737    }
14738
14739    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
14740    //
14741    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
14742    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
14743    // the op with `yrs`, re-encodes the whole doc state, and records a
14744    // baseVersion-less sparse upsert carrying only the primary key and the
14745    // crdt column (§5.10.3, §6.6). No local merge — merging is server-side;
14746    // the overlay re-materializes the edit immediately (optimistic apply,
14747    // §7.1) and the server-merged bytes arrive on the next pull, idempotently.
14748    // Byte-compatible with `@syncular/crdt-yjs`.
14749
14750    /// The current stored value of a `crdt` column for one row — the visible
14751    /// (optimistic) bytes, or `None` when the row is absent or the column is
14752    /// NULL (the empty document, §5.10.1). Errors if the column is not a
14753    /// `crdt` column (guards the app against a typo'd column name).
14754    #[cfg(feature = "crdt-yjs")]
14755    fn crdt_column_bytes(
14756        &self,
14757        table: &str,
14758        row_id: &str,
14759        column: &str,
14760    ) -> Result<Option<Vec<u8>>, String> {
14761        let schema_table = self
14762            .schema
14763            .table(table)
14764            .ok_or_else(|| format!("unknown table {table:?}"))?;
14765        let col = schema_table
14766            .columns
14767            .iter()
14768            .find(|c| c.name == column)
14769            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
14770        if col.ty != ColumnType::Crdt {
14771            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
14772        }
14773        let sql = format!(
14774            "SELECT {} FROM {} WHERE {}",
14775            quote_ident(column),
14776            visible_table(table),
14777            row_id_predicate(schema_table)
14778        );
14779        let bytes: Option<Vec<u8>> = self
14780            .conn
14781            .prepare_cached(&sql)
14782            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14783            .query_row(rusqlite::params![row_id], |row| {
14784                row.get::<_, Option<Vec<u8>>>(0)
14785            })
14786            .map_err(|e| match e {
14787                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
14788                other => Self::sqlite_failure(&self.storage_failure, other),
14789            })?;
14790        Ok(bytes)
14791    }
14792
14793    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
14794    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
14795    /// An absent row / NULL column is the empty document (empty string).
14796    #[cfg(feature = "crdt-yjs")]
14797    pub fn crdt_text(
14798        &self,
14799        table: &str,
14800        row_id: &str,
14801        column: &str,
14802        name: &str,
14803    ) -> Result<String, String> {
14804        let bytes = self
14805            .crdt_column_bytes(table, row_id, column)?
14806            .unwrap_or_default();
14807        crate::crdt::text(&bytes, name)
14808    }
14809
14810    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and
14811    /// record the resulting crdt-only sparse operation. Returns the enqueued
14812    /// `clientCommitId`.
14813    #[cfg(feature = "crdt-yjs")]
14814    pub fn crdt_insert_text(
14815        &mut self,
14816        table: &str,
14817        row_id: &str,
14818        column: &str,
14819        name: &str,
14820        index: u32,
14821        value: &str,
14822    ) -> Result<String, ClientError> {
14823        let prior = self.storage_failure.borrow_mut().take();
14824        let outcome = (|| -> Result<String, String> {
14825            let current = self
14826                .crdt_column_bytes(table, row_id, column)?
14827                .unwrap_or_default();
14828            let update = crate::crdt::insert_text(&current, name, index, value)?;
14829            self.perform_crdt_push_update(table, row_id, column, &update)
14830        })();
14831        self.finish_authoring(prior, outcome)
14832    }
14833
14834    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and
14835    /// record the resulting crdt-only sparse operation. Returns the enqueued
14836    /// `clientCommitId`.
14837    #[cfg(feature = "crdt-yjs")]
14838    pub fn crdt_delete_text(
14839        &mut self,
14840        table: &str,
14841        row_id: &str,
14842        column: &str,
14843        name: &str,
14844        index: u32,
14845        len: u32,
14846    ) -> Result<String, ClientError> {
14847        let prior = self.storage_failure.borrow_mut().take();
14848        let outcome = (|| -> Result<String, String> {
14849            let current = self
14850                .crdt_column_bytes(table, row_id, column)?
14851                .unwrap_or_default();
14852            let update = crate::crdt::delete_text(&current, name, index, len)?;
14853            self.perform_crdt_push_update(table, row_id, column, &update)
14854        })();
14855        self.finish_authoring(prior, outcome)
14856    }
14857
14858    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
14859    /// `crdt` column's current state and record the resulting crdt-only sparse
14860    /// operation. The app authored the update with its own `yrs` model.
14861    /// Returns the enqueued `clientCommitId`.
14862    #[cfg(feature = "crdt-yjs")]
14863    pub fn crdt_apply_update(
14864        &mut self,
14865        table: &str,
14866        row_id: &str,
14867        column: &str,
14868        update: &[u8],
14869    ) -> Result<String, ClientError> {
14870        let prior = self.storage_failure.borrow_mut().take();
14871        let outcome = (|| -> Result<String, String> {
14872            let current = self
14873                .crdt_column_bytes(table, row_id, column)?
14874                .unwrap_or_default();
14875            let next = crate::crdt::apply_update(&current, update)?;
14876            self.perform_crdt_push_update(table, row_id, column, &next)
14877        })();
14878        self.finish_authoring(prior, outcome)
14879    }
14880
14881    /// Shared tail of the crdt edit methods: record a §6.1 sparse crdt-only
14882    /// upsert — the primary key plus the crdt column, no `baseVersion` — and
14883    /// enqueue it. The server merges the crdt column and leaves every other
14884    /// column untouched (§5.10.3), so a baseVersion-less crdt edit cannot
14885    /// clobber a concurrent edit to another column. A locally absent row
14886    /// records the same partial operation; the server answers per §6.2.
14887    #[cfg(feature = "crdt-yjs")]
14888    fn perform_crdt_push_update(
14889        &mut self,
14890        table: &str,
14891        row_id: &str,
14892        column: &str,
14893        crdt_bytes: &[u8],
14894    ) -> Result<String, String> {
14895        let mut bytes_obj = Map::new();
14896        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
14897        let mut partial = Map::new();
14898        partial.insert(column.to_owned(), Value::Object(bytes_obj));
14899        self.perform_patch(table, row_id, partial, None)
14900    }
14901
14902    /// Run an arbitrary read-only SQL query against the local database and
14903    /// return each row as a `column-name → JSON value` map. This is the seam
14904    /// the React `useSyncQuery` live-query API needs (it takes app-authored
14905    /// SQL over the visible tables/views, not a fixed table read like
14906    /// [`read_rows`]).
14907    ///
14908    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
14909    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
14910    /// same envelope the command surface uses everywhere else. Output BLOB
14911    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
14912    ///
14913    /// The result column typing is dynamic (SQLite's stored affinity), because
14914    /// arbitrary SQL can alias, join, and compute — there is no schema column
14915    /// to consult per output cell, unlike [`read_rows`].
14916    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
14917        query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
14918    }
14919
14920    /// Repository benchmark and conformance access, present only with `bench-internals`.
14921    #[cfg(feature = "bench-internals")]
14922    #[doc(hidden)]
14923    pub fn benchmark_connection(&mut self) -> &mut Connection {
14924        &mut self.conn
14925    }
14926
14927    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
14928    #[cfg(feature = "bench-internals")]
14929    #[doc(hidden)]
14930    pub fn benchmark_phases(
14931        &self,
14932        enabled: Option<bool>,
14933        reset: bool,
14934    ) -> Result<Option<Value>, String> {
14935        self.benchmark_phases.configure(enabled, reset)?;
14936        Ok(self.benchmark_phases.snapshot())
14937    }
14938
14939    /// One serialized, read-only authority observation, including durable coverage.
14940    pub fn authority_snapshot(&self) -> Result<Value, String> {
14941        validate_authority_reads(&self.schema, &self.limits.authority_reads)?;
14942        if self.limits.authority_reads.is_empty() {
14943            return Err("client.authority_read_forbidden: no authority reads were declared at client creation".into());
14944        }
14945        self.conn
14946            .execute_batch("SAVEPOINT authority_snapshot")
14947            .map_err(|e| e.to_string())?;
14948        let result = (|| {
14949            let revision = self
14950                .get_meta(LOCAL_REVISION_KEY)
14951                .ok_or("sync.local_corrupt: authority revision is missing")?;
14952            let parsed_revision = revision
14953                .parse::<u64>()
14954                .map_err(|_| "sync.local_corrupt: authority revision is invalid")?;
14955            if parsed_revision.to_string() != revision {
14956                return Err("sync.local_corrupt: authority revision is invalid".into());
14957            }
14958            let mut tables = Vec::new();
14959            for read in &self.limits.authority_reads {
14960                let table = self
14961                    .schema
14962                    .table(&read.table)
14963                    .ok_or("client.authority_read_forbidden: authority table is not declared")?;
14964                let scalar = |column: &str, value: &Value| -> Result<String, String> {
14965                    let ty = table
14966                        .columns
14967                        .iter()
14968                        .find(|c| c.name == column)
14969                        .expect("validated authority column")
14970                        .ty;
14971                    if ty == ColumnType::Boolean {
14972                        if let Some(value) = value.as_bool() {
14973                            return Ok(value.to_string());
14974                        }
14975                        return match value.as_i64() {
14976                            Some(0) => Ok("false".into()),
14977                            Some(1) => Ok("true".into()),
14978                            _ => Err("sync.local_corrupt: authority boolean is invalid".into()),
14979                        };
14980                    }
14981                    match value {
14982                        Value::String(s) => Ok(s.clone()),
14983                        Value::Number(n) => Ok(n.to_string()),
14984                        Value::Object(o) if ty == ColumnType::Integer && o.len() == 1 => o
14985                            .get("$bigint")
14986                            .and_then(Value::as_str)
14987                            .map(str::to_owned)
14988                            .ok_or("sync.local_corrupt: authority integer is invalid".into()),
14989                        _ => {
14990                            Err("sync.local_corrupt: authority scalar is missing or invalid".into())
14991                        }
14992                    }
14993                };
14994                let matches = |row: &QueryRow| -> Result<bool, String> {
14995                    for (variable, values) in &read.scopes {
14996                        let column = table
14997                            .scope_column(variable)
14998                            .expect("validated authority scope");
14999                        let value = row
15000                            .get(column)
15001                            .ok_or("sync.local_corrupt: authority scope column is missing")?;
15002                        if value.is_null() || !values.contains(&scalar(column, value)?) {
15003                            return Ok(false);
15004                        }
15005                    }
15006                    Ok(true)
15007                };
15008                let selected = read
15009                    .columns
15010                    .iter()
15011                    .map(|c| quote_ident(c))
15012                    .collect::<Vec<_>>()
15013                    .join(",");
15014                let mut intent = BTreeSet::new();
15015                let mut relevant = BTreeSet::new();
15016                for row in query_connection(&self.conn,
15017                    "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=?",
15018                    &[Value::String(read.table.clone())]).map_err(|e| e.to_string())? {
15019                    let id = row.get("id").and_then(Value::as_str).ok_or("sync.local_corrupt: authority intent id is invalid")?.to_owned();
15020                    intent.insert(id.clone());
15021                    if let Some(raw) = row.get("values_json").and_then(Value::as_str) {
15022                        let values: QueryRow = serde_json::from_str(raw).map_err(|_| "sync.local_corrupt: authority intent values are invalid")?;
15023                        if read.scopes.keys().all(|v| values.contains_key(table.scope_column(v).expect("validated scope"))) && matches(&values)? {relevant.insert(id);}
15024                    }
15025                }
15026                let mut rows = BTreeMap::new();
15027                for full in [base_table(&read.table), visible_table(&read.table)] {
15028                    for mut values in query_connection(
15029                        &self.conn,
15030                        &format!(
15031                            "SELECT {selected},_syncular_version AS authority_version FROM {full}"
15032                        ),
15033                        &[],
15034                    )
15035                    .map_err(|e| e.to_string())?
15036                    {
15037                        if !matches(&values)? {
15038                            continue;
15039                        }
15040                        let id = scalar(
15041                            &table.primary_key,
15042                            values
15043                                .get(&table.primary_key)
15044                                .expect("selected primary key"),
15045                        )?;
15046                        relevant.insert(id.clone());
15047                        if full == base_table(&read.table) {
15048                            let version = values
15049                                .remove("authority_version")
15050                                .expect("selected version");
15051                            let version = version
15052                                .as_i64()
15053                                .ok_or("sync.local_corrupt: authority server version is invalid")?;
15054                            if version >= 0 {
15055                                rows.insert(id.clone(), json!({"values": values, "version": version, "hasLocalIntent": intent.contains(&id)}));
15056                            }
15057                        }
15058                    }
15059                }
15060                intent.retain(|id| relevant.contains(id));
15061                let rows = rows.into_values().collect::<Vec<_>>();
15062                let mut persisted = Vec::new();
15063                for row in query_connection(
15064                    &self.conn,
15065                    "SELECT state_json FROM _syncular_subscriptions WHERE tbl=? ORDER BY rowid",
15066                    &[Value::String(read.table.clone())],
15067                )
15068                .map_err(|e| e.message)?
15069                {
15070                    let state: Value = serde_json::from_str(
15071                        row.get("state_json")
15072                            .and_then(Value::as_str)
15073                            .ok_or("sync.local_corrupt: authority subscription state is missing")?,
15074                    )
15075                    .map_err(|_| "sync.local_corrupt: authority subscription state is invalid")?;
15076                    let effective = state.get("effectiveScopes").and_then(Value::as_object);
15077                    let status = state
15078                        .get("status")
15079                        .and_then(Value::as_str)
15080                        .ok_or("sync.local_corrupt: authority subscription status is missing")?;
15081                    let cursor = state
15082                        .get("cursor")
15083                        .and_then(Value::as_i64)
15084                        .ok_or("sync.local_corrupt: authority subscription cursor is missing")?;
15085                    let complete = effective.is_some_and(|s| s.len() == read.scopes.len())
15086                        && status == "active"
15087                        && cursor >= 0
15088                        && state.get("bootstrapState").is_none_or(Value::is_null)
15089                        && state.get("params").is_none_or(Value::is_null);
15090                    let exposed = effective.map(|s| {
15091                        read.scopes
15092                            .keys()
15093                            .map(|v| (v.clone(), s.get(v).cloned().unwrap_or(json!([]))))
15094                            .collect::<Map<_, _>>()
15095                    });
15096                    let requested = read
15097                        .scopes
15098                        .keys()
15099                        .map(|v| {
15100                            (
15101                                v.clone(),
15102                                state
15103                                    .get("requested")
15104                                    .and_then(|s| s.get(v))
15105                                    .cloned()
15106                                    .unwrap_or(json!([])),
15107                            )
15108                        })
15109                        .collect::<Map<_, _>>();
15110                    persisted.push(json!({"requestedScopes": requested, "status": status, "cursor": cursor, "effectiveScopes": exposed, "complete": complete}));
15111                }
15112                fn covered(selectors: &[(&String, &Vec<String>)], candidates: &[&Value]) -> bool {
15113                    match selectors.split_first() {
15114                        None => !candidates.is_empty(),
15115                        Some(((variable, values), rest)) => values.iter().all(|value| {
15116                            covered(
15117                                rest,
15118                                &candidates
15119                                    .iter()
15120                                    .copied()
15121                                    .filter(|p| {
15122                                        p["effectiveScopes"][variable.as_str()]
15123                                            .as_array()
15124                                            .is_some_and(|held| {
15125                                                held.contains(&Value::String(value.clone()))
15126                                                    || held.contains(&Value::String("*".into()))
15127                                            })
15128                                    })
15129                                    .collect::<Vec<_>>(),
15130                            )
15131                        }),
15132                    }
15133                }
15134                let coverage = if covered(
15135                    &read.scopes.iter().collect::<Vec<_>>(),
15136                    &persisted
15137                        .iter()
15138                        .filter(|p| p["complete"] == true)
15139                        .collect::<Vec<_>>(),
15140                ) {
15141                    "complete"
15142                } else if persisted.iter().any(|p| {
15143                    p["status"] == "active"
15144                        && read.scopes.iter().all(|(v, values)| {
15145                            values.iter().any(|value| {
15146                                p["requestedScopes"][v].as_array().is_some_and(|held| {
15147                                    held.contains(&Value::String(value.clone()))
15148                                        || held.contains(&Value::String("*".into()))
15149                                })
15150                            })
15151                        })
15152                }) {
15153                    "pending"
15154                } else {
15155                    "missing"
15156                };
15157                tables.push(json!({"table": read.table, "rows": rows, "localIntentRowIds": intent, "scopes": read.scopes, "coverage": coverage, "persisted": persisted}));
15158            }
15159            Ok(
15160                json!({"revision": revision, "complete": tables.iter().all(|t| t["coverage"] == "complete"), "tables": tables}),
15161            )
15162        })();
15163        match result {
15164            Ok(snapshot) => {
15165                self.conn
15166                    .execute_batch("RELEASE authority_snapshot")
15167                    .map_err(|e| e.to_string())?;
15168                Ok(snapshot)
15169            }
15170            Err(error) => {
15171                self.conn
15172                    .execute_batch("ROLLBACK TO authority_snapshot; RELEASE authority_snapshot")
15173                    .map_err(|e| format!("{error}; rollback failed: {e}"))?;
15174                Err(error)
15175            }
15176        }
15177    }
15178
15179    /// Rows, coverage, and local revision from one SQLite read snapshot.
15180    /// §7.5 atomic snapshot read. An `owner` records a failure in
15181    /// `diagnostics_snapshot().query_failures` until its next successful read.
15182    pub fn query_snapshot(
15183        &mut self,
15184        sql: &str,
15185        params: &[QueryValue],
15186        coverage: &[WindowCoverage],
15187        owner: Option<QueryOwner<'_>>,
15188    ) -> Result<QuerySnapshot, String> {
15189        let result = snapshot_connection(&self.conn, sql, params, coverage);
15190        if let Some(owner) = owner {
15191            self.record_query_read(owner, result.as_ref().err());
15192        }
15193        result.map_err(|failure| failure.to_string())
15194    }
15195
15196    /// §7.6: record the outcome of one owned snapshot read, including reads a
15197    /// host ran on a separate connection. A success removes the owner's entry;
15198    /// an identical failure leaves it unchanged; a changed failure moves last.
15199    pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
15200        let position = self
15201            .query_failures
15202            .iter()
15203            .position(|entry| entry.id == owner.id);
15204        let Some(failure) = failure else {
15205            if let Some(index) = position {
15206                self.query_failures.remove(index);
15207            }
15208            return;
15209        };
15210        let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
15211            .into_iter()
15212            .map(str::to_owned)
15213            .collect();
15214        let code = failure.code.unwrap_or("client.query_failed").to_owned();
15215        if position.is_some_and(|index| {
15216            let entry = &self.query_failures[index];
15217            entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
15218        }) {
15219            return;
15220        }
15221        if let Some(index) = position {
15222            self.query_failures.remove(index);
15223        }
15224        self.query_failures.push(DiagnosticQueryFailure {
15225            id: owner.id.to_owned(),
15226            tables,
15227            code,
15228            sqlite_code: failure.sqlite_code,
15229            at_ms: self.clock_now_ms(),
15230        });
15231        if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
15232            self.query_failures.remove(0);
15233        }
15234    }
15235
15236    // -- request building ---------------------------------------------------------
15237
15238    fn build_request(&self, url_capable: bool) -> Result<(Message, RequestMeta), Box<SyncOutcome>> {
15239        #[cfg(feature = "bench-internals")]
15240        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
15241        let log_epoch = self.get_meta(LOG_EPOCH_KEY);
15242        let max_commits = self.limits.max_push_commits_per_request;
15243        let max_ops = self
15244            .limits
15245            .max_push_operations_per_request
15246            .unwrap_or(PUSH_OPS_PER_REQUEST);
15247        let byte_cap = self.limits.max_push_request_bytes;
15248        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
15249        // bare URL — capability negotiation, decided per transport.
15250        let accept = self.limits.accept.unwrap_or(if url_capable {
15251            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
15252        } else {
15253            DEFAULT_ACCEPT
15254        });
15255        let header = Frame::ReqHeader {
15256            client_id: self.client_id.clone(),
15257            schema_version: self.schema.version,
15258            log_epoch: log_epoch.clone(),
15259        };
15260        let pull = Frame::PullHeader {
15261            limit_commits: self.limits.limit_commits.unwrap_or(0),
15262            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
15263            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
15264            accept,
15265        };
15266        let mut fresh = Vec::new();
15267        let mut sub_frames = Vec::new();
15268        for sub in &self.subs {
15269            if sub.state != SubState::Active {
15270                continue;
15271            }
15272            let mut scopes = sub.requested.clone();
15273            sort_scope_map(&mut scopes);
15274            sub_frames.push(Frame::Subscription {
15275                id: sub.id.clone(),
15276                table: sub.table.clone(),
15277                scopes,
15278                params: sub.params.clone().map(RawJson),
15279                cursor: sub.cursor,
15280                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
15281            });
15282            fresh.push((
15283                sub.id.clone(),
15284                sub.cursor < 0 && sub.bootstrap_state.is_none(),
15285            ));
15286        }
15287        // The fixed frames (order-independent size) and the header+pull probe
15288        // both encode canonically, so the byte budget is the exact wire size.
15289        let fixed_bytes = match byte_cap {
15290            Some(_) => {
15291                let mut frames = vec![header.clone(), pull.clone()];
15292                frames.extend(sub_frames.iter().cloned());
15293                encode_message(&Message {
15294                    wire_version: WIRE_VERSION,
15295                    msg_kind: MsgKind::Request,
15296                    frames,
15297                })
15298                .len()
15299            }
15300            None => 0,
15301        };
15302        if let Some(cap) = byte_cap {
15303            if fixed_bytes > cap {
15304                return Err(Self::push_capacity_outcome("bytes", cap, fixed_bytes, None));
15305            }
15306        }
15307        let base_bytes = match byte_cap {
15308            Some(_) => encode_message(&Message {
15309                wire_version: WIRE_VERSION,
15310                msg_kind: MsgKind::Request,
15311                frames: vec![header.clone(), pull.clone()],
15312            })
15313            .len(),
15314            None => 0,
15315        };
15316        let total_outbox = if log_epoch.is_some() {
15317            self.outbox.len()
15318        } else {
15319            0
15320        };
15321        let mut push_frames: Vec<Frame> = Vec::new();
15322        let mut pushed_ids = Vec::new();
15323        let mut ops_in_request = 0usize;
15324        let mut bytes_in_request = 0usize;
15325        let mut deferred_commits = 0usize;
15326        #[cfg(feature = "bench-internals")]
15327        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
15328        for (index, commit) in self.outbox.iter().take(total_outbox).enumerate() {
15329            // §6.1/§7.1: the commit-count and operation caps are hard. A
15330            // first commit over the operation cap is a typed capacity error
15331            // and stays queued; a later one defers with the complete suffix.
15332            if max_commits.is_some_and(|cap| push_frames.len() >= cap) {
15333                deferred_commits = total_outbox - index;
15334                break;
15335            }
15336            if ops_in_request + commit.ops.len() > max_ops {
15337                if push_frames.is_empty() {
15338                    return Err(Self::push_capacity_outcome(
15339                        "operations",
15340                        max_ops,
15341                        commit.ops.len(),
15342                        Some(commit.client_commit_id.as_str()),
15343                    ));
15344                }
15345                deferred_commits = total_outbox - index;
15346                break;
15347            }
15348            let operations = commit
15349                .ops
15350                .iter()
15351                .map(|op| {
15352                    let payload = op.values.as_ref().and_then(|values| {
15353                        let table = self.schema.table(&op.table)?;
15354                        // §0: outbox entries encode at send time with the
15355                        // current codec (validated at mutate()). §6.1: the
15356                        // values map's keys are the sparse presence set.
15357                        // §5.11: encrypted columns are encrypted here, with
15358                        // an absent key-id selector read from the stored row.
15359                        // `sync_inner` runs `drop_unencodable_outbox` first,
15360                        // so an encrypt failure cannot reach this encode; the
15361                        // author seam validated every other failure.
15362                        let fallback = self.stored_key_fallback(table, &op.row_id);
15363                        encode_sparse_row_json(
15364                            table,
15365                            &op.row_id,
15366                            values,
15367                            &self.encryption,
15368                            fallback.as_ref(),
15369                        )
15370                        .ok()
15371                    });
15372                    ssp2::model::Operation {
15373                        table: op.table.clone(),
15374                        row_id: op.row_id.clone(),
15375                        op: if op.upsert { Op::Upsert } else { Op::Delete },
15376                        base_version: op.base_version,
15377                        payload,
15378                    }
15379                })
15380                .collect();
15381            let frame = Frame::PushCommit {
15382                client_commit_id: commit.client_commit_id.clone(),
15383                operations,
15384            };
15385            let frame_bytes = match byte_cap {
15386                Some(_) => {
15387                    encode_message(&Message {
15388                        wire_version: WIRE_VERSION,
15389                        msg_kind: MsgKind::Request,
15390                        frames: vec![header.clone(), frame.clone(), pull.clone()],
15391                    })
15392                    .len()
15393                        - base_bytes
15394                }
15395                None => 0,
15396            };
15397            // The head is always encoded so the caller can report the full
15398            // projected request size; a later commit defers once it no longer
15399            // fits.
15400            if let Some(cap) = byte_cap {
15401                if !push_frames.is_empty() && fixed_bytes + bytes_in_request + frame_bytes > cap {
15402                    deferred_commits = total_outbox - index;
15403                    break;
15404                }
15405            }
15406            push_frames.push(frame);
15407            pushed_ids.push(commit.client_commit_id.clone());
15408            ops_in_request += commit.ops.len();
15409            bytes_in_request += frame_bytes;
15410        }
15411        #[cfg(feature = "bench-internals")]
15412        drop(outbox_phase);
15413        if let Some(cap) = byte_cap {
15414            if fixed_bytes + bytes_in_request > cap {
15415                // The head alone does not fit this budget; the commit stays
15416                // queued and no transport round is attempted.
15417                let blocked = if push_frames.is_empty() {
15418                    None
15419                } else {
15420                    self.outbox.first().map(|c| c.client_commit_id.as_str())
15421                };
15422                return Err(Self::push_capacity_outcome(
15423                    "bytes",
15424                    cap,
15425                    fixed_bytes + bytes_in_request,
15426                    blocked,
15427                ));
15428            }
15429        }
15430        let mut frames = vec![header];
15431        frames.extend(push_frames);
15432        frames.push(pull);
15433        frames.extend(sub_frames);
15434        let message = Message {
15435            wire_version: WIRE_VERSION,
15436            msg_kind: MsgKind::Request,
15437            frames,
15438        };
15439        Ok((
15440            message,
15441            RequestMeta {
15442                pushed_ids,
15443                fresh,
15444                accept,
15445                deferred_commits,
15446            },
15447        ))
15448    }
15449
15450    /// §7.1: a configured push capacity limit cannot carry this request.
15451    fn push_capacity_outcome(
15452        kind: &str,
15453        limit: usize,
15454        size: usize,
15455        client_commit_id: Option<&str>,
15456    ) -> Box<SyncOutcome> {
15457        let mut details = Map::new();
15458        details.insert("kind".to_owned(), Value::from(kind));
15459        details.insert("limit".to_owned(), Value::from(limit as u64));
15460        details.insert("size".to_owned(), Value::from(size as u64));
15461        if let Some(id) = client_commit_id {
15462            details.insert("clientCommitId".to_owned(), Value::from(id));
15463        }
15464        Box::new(SyncOutcome::Failed {
15465            error_code: "client.push_request_too_large".into(),
15466            message: "the push request exceeds a configured capacity limit".into(),
15467            details: Some(Value::Object(details)),
15468        })
15469    }
15470
15471    // -- sync -------------------------------------------------------------------
15472
15473    /// Cloneable observation handle; no client lock is needed to read it.
15474    pub fn progress(&self) -> ProgressObserver {
15475        self.progress.clone()
15476    }
15477
15478    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
15479        let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
15480            Ok(prepared) => prepared,
15481            Err(outcome) => return *outcome,
15482        };
15483        // The round's network deadline rides `PreparedSyncRound` and is scoped
15484        // to each exchange, so uploads, continuations, the main request, and
15485        // every segment fetch share one monotonic anchor without leaving
15486        // deadline state on a reused transport.
15487        let mut next = prepared;
15488        loop {
15489            match self.apply_sync_round(next.exchange(transport)) {
15490                crate::AppliedSyncRound::Continue(prepared) => next = prepared,
15491                crate::AppliedSyncRound::Complete {
15492                    outcome, controls, ..
15493                } => {
15494                    if self.transport_enabled {
15495                        for text in controls {
15496                            let _ = transport.realtime_send(&text);
15497                        }
15498                    } else {
15499                        self.disconnect_realtime(transport);
15500                    }
15501                    return outcome;
15502                }
15503            }
15504        }
15505    }
15506
15507    fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
15508        if let SyncOutcome::Failed {
15509            error_code,
15510            message,
15511            details,
15512        } = &mut outcome
15513        {
15514            if let Some(failure) = self.storage_failure.borrow().as_ref() {
15515                let code = failure
15516                    .code
15517                    .expect("retained storage failures are classified");
15518                *error_code = code.to_owned();
15519                *message = match code {
15520                    "client.storage_busy" => "local SQLite storage is busy",
15521                    "client.storage_full" => "local SQLite storage is full",
15522                    "client.storage_io" => "local SQLite storage I/O failed",
15523                    "client.storage_corrupt" => "local SQLite storage is corrupt",
15524                    _ => unreachable!("unrecognized classified storage failure"),
15525                }
15526                .to_owned();
15527                *details = failure.details();
15528            }
15529        }
15530        let retry_delay_ms = self.round_retry_delay_ms;
15531        self.progress.update(|p| match &outcome {
15532            SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report)
15533                if report.failed.is_empty() =>
15534            {
15535                p.state = ProgressState::Complete
15536            }
15537            SyncOutcome::Ok(_) | SyncOutcome::BudgetExhausted(_) => {
15538                p.state = ProgressState::Failed;
15539                p.error_code = Some("sync.scope_revoked".into());
15540            }
15541            SyncOutcome::Failed { error_code, .. } => {
15542                p.state = ProgressState::Failed;
15543                p.error_code = Some(Self::diagnostic_code(error_code));
15544                p.retry_delay_ms = retry_delay_ms;
15545            }
15546            SyncOutcome::RealtimeUnavailable { .. } => {
15547                p.state = ProgressState::Failed;
15548                p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
15549                p.retry_delay_ms = retry_delay_ms;
15550            }
15551        });
15552        let completed_at_ms = self.clock_now_ms();
15553        self.last_round = Some(match &outcome {
15554            SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report) => DiagnosticLastRound {
15555                status: "succeeded".to_owned(),
15556                started_at_ms,
15557                completed_at_ms,
15558                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
15559                counters: Some(DiagnosticRoundCounters {
15560                    pushed: report.pushed,
15561                    applied: report.applied.len(),
15562                    rejected: report.rejected.len(),
15563                    retryable: report.retryable.len(),
15564                    conflicts: report.conflicts,
15565                    commits_applied: report.commits_applied,
15566                    segment_rows_applied: report.segment_rows_applied,
15567                    bootstrapping: report.bootstrapping.len(),
15568                    resets: report.resets.len(),
15569                    revoked: report.revoked.len(),
15570                    failed: report.failed.len(),
15571                    deferred_commits: report.deferred_commits,
15572                }),
15573                error_code: None,
15574            },
15575            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
15576                status: "failed".to_owned(),
15577                started_at_ms,
15578                completed_at_ms,
15579                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
15580                counters: None,
15581                error_code: Some(Self::diagnostic_code(error_code)),
15582            },
15583            SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
15584                status: "failed".to_owned(),
15585                started_at_ms,
15586                completed_at_ms,
15587                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
15588                counters: None,
15589                error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
15590            },
15591        });
15592        outcome
15593    }
15594
15595    /// Capture one request on the client owner. Mutations made after this
15596    /// call remain in the outbox for a later round; no network I/O occurs here.
15597    pub fn prepare_sync_round(
15598        &mut self,
15599        url_capable: bool,
15600    ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
15601        if !self.transport_enabled {
15602            return Err(Box::new(SyncOutcome::Failed {
15603                error_code: "sync.offline".into(),
15604                message: "transport gate is closed".into(),
15605                details: None,
15606            }));
15607        }
15608        if self.active_round.is_some() {
15609            return Err(Box::new(SyncOutcome::Failed {
15610                error_code: "client.round_in_progress".into(),
15611                message: "a sync round is already in flight".into(),
15612                details: None,
15613            }));
15614        }
15615        self.storage_failure.borrow_mut().take();
15616        self.progress.start();
15617        self.round_retry_delay_ms = None;
15618        let started_at_ms = self.clock_now_ms();
15619        let prepared = (|| {
15620            if self.stopped {
15621                return Err(Box::new(SyncOutcome::Ok(SyncReport {
15622                    schema_floor: self.schema_floor.clone(),
15623                    ..SyncReport::default()
15624                })));
15625            }
15626            if self.security_preflight {
15627                return Err(Box::new(SyncOutcome::Failed {
15628                    error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
15629                    message: "security preflight must complete before syncing".into(),
15630                    details: None,
15631                }));
15632            }
15633            self.set_sync_needed(false, false);
15634            self.drain_pending_evictions()
15635                .and_then(|_| self.drop_incompatible_outbox())
15636                .and_then(|_| self.drop_unencodable_outbox(true, None))
15637                .map_err(|message| {
15638                    Box::new(SyncOutcome::Failed {
15639                        error_code: "storage.failed".into(),
15640                        message,
15641                        details: None,
15642                    })
15643                })?;
15644            let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
15645                self.pending_blob_uploads().map_err(|e| {
15646                    Box::new(SyncOutcome::Failed {
15647                        error_code: e.code,
15648                        message: e.message,
15649                        details: e.details,
15650                    })
15651                })?
15652            } else {
15653                Vec::new()
15654            };
15655            if self.realtime_state != RealtimeState::Connected
15656                && self.realtime_policy == RealtimePolicy::Required
15657            {
15658                let retry_delay_ms = self.schedule_background_retry();
15659                self.realtime_retry_delay_ms = Some(retry_delay_ms);
15660                return Err(Box::new(SyncOutcome::RealtimeUnavailable {
15661                    state: self.realtime_state,
15662                    reason_code: self.realtime_reason_code.clone(),
15663                    retry_delay_ms,
15664                }));
15665            }
15666            let (message, meta) = self.build_request(url_capable)?;
15667            let id = uuid::Uuid::new_v4();
15668            self.active_round = Some(id);
15669            Ok(crate::PreparedSyncRound {
15670                id,
15671                started_at_ms,
15672                message,
15673                meta,
15674                uploads: uploads.into(),
15675                realtime: self.realtime_state == RealtimeState::Connected,
15676                scopes: self
15677                    .subs
15678                    .iter()
15679                    .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
15680                    .collect(),
15681                #[cfg(feature = "bench-internals")]
15682                benchmark_phases: self.benchmark_phases.clone(),
15683                progress: self.progress.clone(),
15684                fixed_now: self.now_ms,
15685                round_deadline_at: None,
15686            })
15687        })();
15688        prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
15689    }
15690
15691    /// Discard the reply of an old authorization/subscription context. Its
15692    /// submitted commits stay durable and can be retried with the same ids.
15693    pub fn cancel_sync_round(&mut self) {
15694        self.active_round = None;
15695    }
15696
15697    /// Apply captured network results on the owner, preserving all normal
15698    /// version, schema, content-address and cursor checks. No network I/O.
15699    pub fn apply_sync_round(
15700        &mut self,
15701        completed: crate::CompletedSyncRound,
15702    ) -> crate::AppliedSyncRound {
15703        let prepared = completed.prepared;
15704        if self.active_round != Some(prepared.id) || self.security_preflight {
15705            return crate::AppliedSyncRound::Complete {
15706                outcome: SyncOutcome::Failed {
15707                    error_code: "client.round_cancelled".into(),
15708                    message: "sync round belongs to a released client context".into(),
15709                    details: None,
15710                },
15711                controls: Vec::new(),
15712                more: false,
15713                bootstrap_advanced: false,
15714            };
15715        }
15716        self.storage_failure.borrow_mut().take();
15717        let (response, mut downloads, transport_failed) = match completed.exchange {
15718            crate::round::ExchangeResult::Upload { id, result } => {
15719                let result = result.and_then(|()| {
15720                    self.conn
15721                        .execute(
15722                            "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
15723                            rusqlite::params![id],
15724                        )
15725                        .map(|_| ())
15726                        .map_err(|error| {
15727                            TransportError::new(
15728                                "client.failed",
15729                                Self::sqlite_failure(&self.storage_failure, error),
15730                            )
15731                        })
15732                });
15733                match result {
15734                    Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
15735                    Err(TransportError {
15736                        code,
15737                        message,
15738                        details,
15739                    }) => {
15740                        self.active_round = None;
15741                        if Self::retryable_failure_code(&code) {
15742                            self.schedule_background_retry();
15743                        }
15744                        let outcome = self.finish_sync_round(
15745                            prepared.started_at_ms,
15746                            SyncOutcome::Failed {
15747                                error_code: code,
15748                                message,
15749                                details,
15750                            },
15751                        );
15752                        return crate::AppliedSyncRound::Complete {
15753                            outcome,
15754                            controls: Vec::new(),
15755                            more: false,
15756                            bootstrap_advanced: false,
15757                        };
15758                    }
15759                }
15760            }
15761            crate::round::ExchangeResult::Reply {
15762                response,
15763                downloads,
15764                transport_failed,
15765            } => (response, downloads, transport_failed),
15766        };
15767        self.active_round = None;
15768        let before = self
15769            .subs
15770            .iter()
15771            .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
15772            .collect::<Vec<_>>();
15773        let outcome = (|| {
15774            let response = match response {
15775                Ok(response) => response,
15776                Err(TransportError {
15777                    code,
15778                    message,
15779                    details,
15780                }) => {
15781                    if transport_failed {
15782                        if prepared.realtime {
15783                            self.set_realtime_state(RealtimeState::Lost, Some(&code));
15784                        }
15785                        self.record_lease_error(&code);
15786                    }
15787                    if Self::retryable_failure_code(&code) {
15788                        let delay = self.schedule_background_retry();
15789                        if prepared.realtime && transport_failed {
15790                            self.realtime_retry_delay_ms = Some(delay);
15791                        }
15792                    }
15793                    return SyncOutcome::Failed {
15794                        error_code: code,
15795                        message,
15796                        details,
15797                    };
15798                }
15799            };
15800            let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
15801            if let SyncOutcome::Ok(report) = &mut outcome {
15802                report.deferred_commits = prepared.meta.deferred_commits;
15803            }
15804            match &outcome {
15805                SyncOutcome::Ok(_) => self.reset_background_retry(),
15806                SyncOutcome::Failed { error_code, .. }
15807                    if Self::retryable_failure_code(error_code) =>
15808                {
15809                    self.schedule_background_retry();
15810                }
15811                _ => {}
15812            }
15813            if prepared.meta.deferred_commits > 0 {
15814                self.set_sync_needed(true, true);
15815            }
15816            self.previous_version_lifetime_check();
15817            outcome
15818        })();
15819        let more = self.transport_enabled
15820            && matches!(&outcome, SyncOutcome::Ok(report)
15821                if report.schema_floor.is_none()
15822                    && (!report.bootstrapping.is_empty()
15823                        || report.commits_applied > 0
15824                        || report.segment_rows_applied > 0
15825                        || !report.resets.is_empty()
15826                        || report.deferred_commits > 0
15827                        || self.sync_needed));
15828        let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
15829            && before
15830                != self
15831                    .subs
15832                    .iter()
15833                    .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
15834                    .collect::<Vec<_>>();
15835        crate::AppliedSyncRound::Complete {
15836            outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
15837            controls: if self.transport_enabled {
15838                downloads.controls
15839            } else {
15840                Vec::new()
15841            },
15842            more,
15843            bootstrap_advanced,
15844        }
15845    }
15846
15847    pub fn sync_until_idle(
15848        &mut self,
15849        transport: &mut dyn Transport,
15850        max_rounds: Option<u32>,
15851    ) -> SyncOutcome {
15852        // §7.7: an explicit budget is a strict positive round count. Zero is a
15853        // caller error, not an exhaustion.
15854        if max_rounds == Some(0) {
15855            return SyncOutcome::Failed {
15856                details: None,
15857                error_code: "sync.invalid_request".into(),
15858                message: "maxRounds must be a positive integer".into(),
15859            };
15860        }
15861        let rounds = max_rounds.unwrap_or(20);
15862        let mut aggregate = SyncReport::default();
15863        let mut spent = 0;
15864        while spent < rounds {
15865            spent += 1;
15866            let before = self
15867                .subs
15868                .iter()
15869                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
15870                .collect::<Vec<_>>();
15871            match self.sync(transport) {
15872                SyncOutcome::Failed {
15873                    error_code,
15874                    message,
15875                    details,
15876                } => {
15877                    return SyncOutcome::Failed {
15878                        details,
15879                        error_code,
15880                        message,
15881                    };
15882                }
15883                outcome @ SyncOutcome::RealtimeUnavailable { .. } => return outcome,
15884                SyncOutcome::BudgetExhausted(_) => {
15885                    unreachable!("sync() never exhausts a round budget")
15886                }
15887                SyncOutcome::Ok(report) => {
15888                    if max_rounds.is_none()
15889                        && report.segment_rows_applied > 0
15890                        && !report.bootstrapping.is_empty()
15891                        && before
15892                            != self
15893                                .subs
15894                                .iter()
15895                                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
15896                                .collect::<Vec<_>>()
15897                    {
15898                        spent = 0;
15899                    }
15900                    // TS parity: a disabled transport or a schema floor ends
15901                    // the loop. The latest round still merges first.
15902                    let stop = !self.transport_enabled() || report.schema_floor.is_some();
15903                    aggregate.merge(&report);
15904                    if stop {
15905                        return SyncOutcome::Ok(aggregate);
15906                    }
15907                    // §4.5: pull again whenever the response contained
15908                    // commits or segments; resets re-bootstrap; a pending
15909                    // resume token continues paging (§4.7); deferred outbox
15910                    // commits and a raised sync-needed signal push on the next
15911                    // round.
15912                    let more = !report.bootstrapping.is_empty()
15913                        || report.commits_applied > 0
15914                        || report.segment_rows_applied > 0
15915                        || !report.resets.is_empty()
15916                        || report.deferred_commits > 0
15917                        || self.sync_needed;
15918                    if !more {
15919                        return SyncOutcome::Ok(aggregate);
15920                    }
15921                }
15922            }
15923        }
15924        // §7.7: partial success. The aggregate report is retained and the run
15925        // is explicitly not idle.
15926        SyncOutcome::BudgetExhausted(aggregate)
15927    }
15928
15929    fn process_response(
15930        &mut self,
15931        transport: &mut dyn Transport,
15932        response: Message,
15933        meta: &RequestMeta,
15934    ) -> SyncOutcome {
15935        #[cfg(feature = "bench-internals")]
15936        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
15937        let mut report = SyncReport {
15938            pushed: meta.pushed_ids.len() as u32,
15939            ..SyncReport::default()
15940        };
15941        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
15942            HashMap::new();
15943        let pushed_ids = meta
15944            .pushed_ids
15945            .iter()
15946            .map(String::as_str)
15947            .collect::<HashSet<_>>();
15948        let mut last_final_push_result_id: Option<String> = None;
15949        for frame in &response.frames {
15950            match frame {
15951                Frame::PushResultDetails {
15952                    client_commit_id,
15953                    entries,
15954                } => {
15955                    let details = rejection_details_by_commit
15956                        .entry(client_commit_id.clone())
15957                        .or_default();
15958                    for entry in entries {
15959                        let parsed = match RejectionDetails::parse(&entry.details.0) {
15960                            Ok(value) => value,
15961                            Err(message) => {
15962                                return SyncOutcome::Failed {
15963                                    details: None,
15964                                    error_code: "sync.invalid_request".to_owned(),
15965                                    message,
15966                                };
15967                            }
15968                        };
15969                        details.insert(entry.op_index, parsed);
15970                    }
15971                }
15972                Frame::PushResult {
15973                    client_commit_id,
15974                    status,
15975                    results,
15976                    ..
15977                } if pushed_ids.contains(client_commit_id.as_str())
15978                    && Self::push_result_is_final(*status, results) =>
15979                {
15980                    last_final_push_result_id = Some(client_commit_id.clone());
15981                }
15982                _ => {}
15983            }
15984        }
15985        let mut frames = response.frames.into_iter().peekable();
15986        if response.wire_version < 2 {
15987            return SyncOutcome::Failed {
15988                details: None,
15989                error_code: "client.invalid_host_response".to_owned(),
15990                message: "server response does not carry wire version 2 log-epoch state".to_owned(),
15991            };
15992        }
15993        match frames.next() {
15994            Some(Frame::RespHeader {
15995                required_schema_version,
15996                latest_schema_version,
15997                log_epoch,
15998                reset_required,
15999            }) => {
16000                let Some(log_epoch) = log_epoch else {
16001                    return SyncOutcome::Failed {
16002                        details: None,
16003                        error_code: "client.invalid_host_response".to_owned(),
16004                        message: "response header omits logEpoch".to_owned(),
16005                    };
16006                };
16007                let Some(reset_required) = reset_required else {
16008                    return SyncOutcome::Failed {
16009                        details: None,
16010                        error_code: "client.invalid_host_response".to_owned(),
16011                        message: "response header omits resetRequired".to_owned(),
16012                    };
16013                };
16014                if let Some(required) = required_schema_version {
16015                    // §1.6 schema-floor response: nothing else is processed;
16016                    // stop syncing and surface the upgrade requirement.
16017                    let floor = SchemaFloor {
16018                        required_schema_version: Some(required),
16019                        latest_schema_version,
16020                    };
16021                    self.set_schema_floor(Some(floor.clone()));
16022                    report.schema_floor = Some(floor);
16023                    return SyncOutcome::Ok(report);
16024                }
16025                if reset_required {
16026                    if frames.next().is_some() {
16027                        return SyncOutcome::Failed {
16028                            details: None,
16029                            error_code: "client.invalid_host_response".to_owned(),
16030                            message: "log-epoch reset response contains body frames".to_owned(),
16031                        };
16032                    }
16033                    match self.run_log_epoch_reset(&log_epoch) {
16034                        Ok(resets) => {
16035                            report.resets = resets;
16036                            return SyncOutcome::Ok(report);
16037                        }
16038                        Err(message) => {
16039                            return SyncOutcome::Failed {
16040                                details: None,
16041                                error_code: "sync.local_corrupt".to_owned(),
16042                                message,
16043                            };
16044                        }
16045                    }
16046                }
16047                if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
16048                    return SyncOutcome::Failed {
16049                        details: None,
16050                        error_code: "client.invalid_host_response".to_owned(),
16051                        message: "server changed logEpoch without requiring a reset".to_owned(),
16052                    };
16053                }
16054            }
16055            _ => {
16056                return SyncOutcome::Failed {
16057                    details: None,
16058                    error_code: "sync.invalid_request".to_owned(),
16059                    message: "response does not start with RESP_HEADER".to_owned(),
16060                };
16061            }
16062        }
16063
16064        let mut failure: Option<(String, String)> = None;
16065        while let Some(frame) = frames.next() {
16066            match frame {
16067                frame @ Frame::PushResult { .. } => {
16068                    let successful = matches!(
16069                        &frame,
16070                        Frame::PushResult {
16071                            status: PushStatus::Applied | PushStatus::Cached,
16072                            ..
16073                        }
16074                    );
16075                    let mut results = vec![frame];
16076                    if successful {
16077                        while matches!(
16078                            frames.peek(),
16079                            Some(Frame::PushResult {
16080                                status: PushStatus::Applied | PushStatus::Cached,
16081                                ..
16082                            })
16083                        ) {
16084                            if let Some(next) = frames.next() {
16085                                results.push(next);
16086                            }
16087                        }
16088                    }
16089                    if self
16090                        .handle_push_results(
16091                            &results,
16092                            &pushed_ids,
16093                            &rejection_details_by_commit,
16094                            &mut report,
16095                            last_final_push_result_id.as_deref(),
16096                        )
16097                        .is_err()
16098                    {
16099                        failure = Some((
16100                            "client.outcome_persistence_failed".to_owned(),
16101                            "local commit outcome could not be persisted".to_owned(),
16102                        ));
16103                        break;
16104                    }
16105                }
16106                Frame::PushResultDetails { .. } => {}
16107                Frame::SubStart {
16108                    id,
16109                    status,
16110                    reason_code,
16111                    effective_scopes,
16112                    bootstrap: _,
16113                } => {
16114                    let mut body = Vec::new();
16115                    let mut sub_end: Option<(i64, Option<String>)> = None;
16116                    for inner in frames.by_ref() {
16117                        match inner {
16118                            Frame::SubEnd {
16119                                next_cursor,
16120                                bootstrap_state,
16121                            } => {
16122                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
16123                                break;
16124                            }
16125                            Frame::Unknown { .. } => {}
16126                            other => body.push(other),
16127                        }
16128                    }
16129                    if let Err(SectionError::Abort(code, message)) = self.process_section(
16130                        transport,
16131                        &id,
16132                        status,
16133                        &reason_code,
16134                        effective_scopes,
16135                        body,
16136                        sub_end,
16137                        meta,
16138                        &mut report,
16139                    ) {
16140                        failure = Some((code, message));
16141                        break;
16142                    }
16143                }
16144                Frame::Lease {
16145                    lease_id,
16146                    expires_at_ms,
16147                } => {
16148                    // §7.3.5: persist the opaque lease; a fresh lease clears
16149                    // any prior lease error (the outage/revocation is over).
16150                    self.set_lease_state(Some(LeaseState {
16151                        lease_id: Some(lease_id),
16152                        expires_at_ms: Some(expires_at_ms),
16153                        error_code: None,
16154                    }));
16155                }
16156                Frame::Error { code, message, .. } => {
16157                    // §1.6: the whole request failed; already-completed
16158                    // subscriptions keep their applied data and cursors.
16159                    failure = Some((code, message));
16160                    break;
16161                }
16162                Frame::Unknown { .. } => {}
16163                _ => {
16164                    failure = Some((
16165                        "sync.invalid_request".to_owned(),
16166                        "unexpected frame in response".to_owned(),
16167                    ));
16168                    break;
16169                }
16170            }
16171        }
16172
16173        if let Some((error_code, message)) = failure {
16174            return SyncOutcome::Failed {
16175                details: None,
16176                error_code,
16177                message,
16178            };
16179        }
16180        // Local apply boundaries already reconciled their affected overlay.
16181        // Refuse round success if any remaining dirty work fails to reconcile.
16182        if let Err(message) = self.rebuild_overlay_if_dirty() {
16183            return SyncOutcome::Failed {
16184                details: None,
16185                error_code: "storage.failed".into(),
16186                message,
16187            };
16188        }
16189        // §4.8 E1: the push half may have drained commits that pinned rows of
16190        // a shrunk window unit — retry any deferred evictions now.
16191        if let Err(message) = self.drain_pending_evictions() {
16192            return SyncOutcome::Failed {
16193                details: None,
16194                error_code: "storage.failed".into(),
16195                message,
16196            };
16197        }
16198        self.ack_after_pull(transport);
16199        // §7.4.5: the reset is over once the first post-reset pull round
16200        // leaves no subscription mid-bootstrap — the tables are rebuilt.
16201        if self.upgrading && report.bootstrapping.is_empty() {
16202            self.set_upgrading(false);
16203        }
16204        SyncOutcome::Ok(report)
16205    }
16206
16207    // -- push results (§6.3, §7.2) ------------------------------------------------
16208
16209    fn handle_push_results(
16210        &mut self,
16211        frames: &[Frame],
16212        pushed_ids: &HashSet<&str>,
16213        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
16214        report: &mut SyncReport,
16215        last_final_push_result_id: Option<&str>,
16216    ) -> Result<(), String> {
16217        self.begin_observation("syncular_push_results")?;
16218        let previous_failed = self.failed_commits.clone();
16219        let previous_sync_needed = self.sync_needed;
16220        let conflict_count = self.conflicts.len();
16221        let rejection_count = self.rejections.len();
16222        let applied_count = report.applied.len();
16223        let rejected_count = report.rejected.len();
16224        let retryable_count = report.retryable.len();
16225        let was_dirty = self.overlay_dirty.snapshot();
16226        let mut removed = Vec::new();
16227        let mut batch = ChangeAccumulator::default();
16228        let persisted = (|| {
16229            for frame in frames {
16230                let Frame::PushResult {
16231                    client_commit_id,
16232                    status,
16233                    results,
16234                    commit_seq,
16235                } = frame
16236                else {
16237                    return Err("unexpected acknowledgement frame".to_owned());
16238                };
16239                if !pushed_ids.contains(client_commit_id.as_str()) {
16240                    continue;
16241                }
16242                let Some(index) = self
16243                    .outbox
16244                    .iter()
16245                    .position(|commit| commit.client_commit_id == *client_commit_id)
16246                else {
16247                    continue;
16248                };
16249                let status = *status;
16250                let rejection_details = rejection_details_by_commit.get(client_commit_id);
16251                let operations = self.outbox[index].ops.clone();
16252                match status {
16253                    PushStatus::Applied | PushStatus::Cached => {
16254                        // §7.2: a lost ack replays as `cached` — proceed as if the
16255                        // ack had arrived.
16256                        let sequence =
16257                            commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
16258                        let affected: HashSet<_> = operations
16259                            .iter()
16260                            .map(|op| (op.table.as_str(), op.row_id.as_str()))
16261                            .collect();
16262                        let prior = self.acknowledged_operations(Some(&affected), None)?;
16263                        for (op_index, operation) in operations.iter().enumerate() {
16264                            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))
16265                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16266                            if delivered {
16267                                continue;
16268                            }
16269                            let table = self
16270                                .schema
16271                                .table(&operation.table)
16272                                .ok_or_else(|| "sync.unknown_table".to_owned())?;
16273                            let mut intended: Map<String, Value> = self
16274                                .stored_row(table, &operation.row_id, true)?
16275                                .map(|(row, _)| {
16276                                    table
16277                                        .columns
16278                                        .iter()
16279                                        .zip(&row)
16280                                        .map(|(column, value)| {
16281                                            (column.name.clone(), column_value_to_json(value))
16282                                        })
16283                                        .collect()
16284                                })
16285                                .unwrap_or_default();
16286                            let mut scope_image = intended.clone();
16287                            for op in prior
16288                                .iter()
16289                                .chain(
16290                                    self.failed_commits
16291                                        .iter()
16292                                        .flat_map(|(commit, _)| &commit.ops),
16293                                )
16294                                .chain(
16295                                    self.outbox
16296                                        .iter()
16297                                        .take(index)
16298                                        .flat_map(|commit| &commit.ops),
16299                                )
16300                                .chain(operations.iter().take(op_index + 1))
16301                            {
16302                                if op.table != operation.table || op.row_id != operation.row_id {
16303                                    continue;
16304                                }
16305                                if let Some(values) = &op.values {
16306                                    intended.extend(values.clone());
16307                                } else {
16308                                    scope_image = intended.clone();
16309                                    intended.clear();
16310                                }
16311                            }
16312                            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)",
16313                                rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
16314                                    serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
16315                                    serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
16316                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16317                        }
16318                        let journal_results = results
16319                            .iter()
16320                            .map(|result| {
16321                                let op_index = match result {
16322                                    OpResult::Applied { op_index }
16323                                    | OpResult::Conflict { op_index, .. }
16324                                    | OpResult::Error { op_index, .. } => *op_index,
16325                                };
16326                                CommitOperationOutcome::Applied { op_index }
16327                            })
16328                            .collect::<Vec<_>>();
16329                        let outcome_status = if status == PushStatus::Applied {
16330                            CommitOutcomeStatus::Applied
16331                        } else {
16332                            CommitOutcomeStatus::Cached
16333                        };
16334                        let persisted = self
16335                            .persist_commit_outcome(
16336                                client_commit_id,
16337                                outcome_status,
16338                                &journal_results,
16339                                None,
16340                            )
16341                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
16342                        persisted?;
16343                        for operation in &operations {
16344                            self.overlay_dirty.table(&operation.table);
16345                        }
16346                        self.sync_needed = true;
16347                        batch.status = true;
16348                        batch.outcomes = true;
16349                    }
16350                    PushStatus::Rejected => {
16351                        if results.iter().any(|result| {
16352                            matches!(
16353                                result,
16354                                OpResult::Error {
16355                                    code,
16356                                    retryable: true,
16357                                    ..
16358                                } if code == "sync.idempotency_cache_miss"
16359                            )
16360                        }) {
16361                            // §6.3/§7.2: a serving failure, not an outcome — keep the
16362                            // exact commit queued for an identical retry.
16363                            report.retryable.push(client_commit_id.to_owned());
16364                            continue;
16365                        }
16366
16367                        let mut journal_results = Vec::with_capacity(results.len());
16368                        let mut conflicts = Vec::new();
16369                        let mut rejections = Vec::new();
16370                        for result in results {
16371                            match result {
16372                                OpResult::Applied { op_index } => {
16373                                    journal_results.push(CommitOperationOutcome::Applied {
16374                                        op_index: *op_index,
16375                                    });
16376                                }
16377                                OpResult::Conflict {
16378                                    op_index,
16379                                    code,
16380                                    message,
16381                                    server_version,
16382                                    server_row,
16383                                    conflict_columns: conflict_columns_bitmap,
16384                                } => {
16385                                    let operation = operations
16386                                        .get(*op_index as usize)
16387                                        .map(CommitOperation::from);
16388                                    let (table, row_id) = operation
16389                                        .as_ref()
16390                                        .map(|op| (op.table.clone(), op.row_id.clone()))
16391                                        .unwrap_or_default();
16392                                    let server_row_json = self
16393                                        .schema
16394                                        .table(&table)
16395                                        .and_then(|t| {
16396                                            decode_row_bytes(t, server_row, &self.encryption)
16397                                                .ok()
16398                                                .map(|row| (t, row))
16399                                        })
16400                                        .map(|(t, row)| {
16401                                            let mut map = Map::new();
16402                                            for (i, column) in t.columns.iter().enumerate() {
16403                                                map.insert(
16404                                                    column.name.clone(),
16405                                                    column_value_to_json(
16406                                                        row.get(i).unwrap_or(&None),
16407                                                    ),
16408                                                );
16409                                            }
16410                                            map
16411                                        })
16412                                        .unwrap_or_default();
16413                                    // §6.3: decode the presence-layout bitmap
16414                                    // into the marked column names.
16415                                    let conflict_columns = self
16416                                        .schema
16417                                        .table(&table)
16418                                        .map(|t| {
16419                                            t.columns
16420                                                .iter()
16421                                                .enumerate()
16422                                                .filter(|(index, _)| {
16423                                                    conflict_columns_bitmap
16424                                                        .get(index / 8)
16425                                                        .is_some_and(|byte| {
16426                                                            byte & (1 << (index % 8)) != 0
16427                                                        })
16428                                                })
16429                                                .map(|(_, column)| column.name.clone())
16430                                                .collect::<Vec<_>>()
16431                                        })
16432                                        .unwrap_or_default();
16433                                    let conflict = ConflictRecord {
16434                                        client_commit_id: client_commit_id.to_owned(),
16435                                        op_index: *op_index,
16436                                        table,
16437                                        row_id,
16438                                        code: code.clone(),
16439                                        message: message.clone(),
16440                                        server_version: *server_version,
16441                                        server_row: server_row_json,
16442                                        conflict_columns,
16443                                        operation,
16444                                    };
16445                                    journal_results.push(CommitOperationOutcome::Conflict {
16446                                        conflict: conflict.clone(),
16447                                    });
16448                                    conflicts.push(conflict);
16449                                }
16450                                OpResult::Error {
16451                                    op_index,
16452                                    code,
16453                                    message,
16454                                    retryable,
16455                                } => {
16456                                    let rejection = RejectionRecord {
16457                                        client_commit_id: client_commit_id.to_owned(),
16458                                        op_index: *op_index,
16459                                        code: code.clone(),
16460                                        message: message.clone(),
16461                                        retryable: *retryable,
16462                                        details: rejection_details
16463                                            .and_then(|details| details.get(op_index))
16464                                            .cloned(),
16465                                        operation: operations
16466                                            .get(*op_index as usize)
16467                                            .map(CommitOperation::from),
16468                                    };
16469                                    journal_results.push(CommitOperationOutcome::Error {
16470                                        rejection: rejection.clone(),
16471                                    });
16472                                    rejections.push(rejection);
16473                                }
16474                            }
16475                        }
16476                        if self.retain_failed_commits {
16477                            self.retain_failed_commit(client_commit_id, &operations)?;
16478                        }
16479                        let outcome_status = if conflicts.is_empty() {
16480                            CommitOutcomeStatus::Rejected
16481                        } else {
16482                            CommitOutcomeStatus::Conflict
16483                        };
16484                        let persisted = self
16485                            .persist_commit_outcome(
16486                                client_commit_id,
16487                                outcome_status,
16488                                &journal_results,
16489                                Some(&operations),
16490                            )
16491                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
16492                        persisted?;
16493                        batch.conflicts = !conflicts.is_empty();
16494                        batch.rejections = !rejections.is_empty();
16495                        batch.status = true;
16496                        batch.outcomes = true;
16497                        self.conflicts.extend(conflicts);
16498                        self.rejections.extend(rejections);
16499                        for operation in &operations {
16500                            self.overlay_dirty.table(&operation.table);
16501                        }
16502                    }
16503                }
16504                removed.push((index, self.outbox.remove(index)));
16505                if batch.status {
16506                    for operation in &operations {
16507                        if !self.record_row_scopes(
16508                            &mut batch,
16509                            &operation.table,
16510                            &operation.row_id,
16511                            false,
16512                        ) {
16513                            batch.table(&operation.table);
16514                        }
16515                    }
16516                }
16517                match status {
16518                    PushStatus::Applied | PushStatus::Cached => {
16519                        report.applied.push(client_commit_id.to_owned())
16520                    }
16521                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
16522                }
16523            }
16524            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)) {
16525                self.prune_commit_outcomes()?;
16526            }
16527            self.rebuild_overlay_if_dirty()?;
16528            self.finish_observation("syncular_push_results", batch)
16529        })();
16530        if let Err(error) = persisted {
16531            self.rollback_observation("syncular_push_results");
16532            self.failed_commits = previous_failed;
16533            self.sync_needed = previous_sync_needed;
16534            for (index, commit) in removed.into_iter().rev() {
16535                self.outbox.insert(index, commit);
16536            }
16537            self.conflicts.truncate(conflict_count);
16538            self.rejections.truncate(rejection_count);
16539            self.overlay_dirty.restore(was_dirty.clone());
16540            report.applied.truncate(applied_count);
16541            report.rejected.truncate(rejected_count);
16542            report.retryable.truncate(retryable_count);
16543            return Err(error);
16544        }
16545        if !previous_sync_needed && self.sync_needed {
16546            self.sync_intent_queue.push_back(SyncIntent::Interactive);
16547        }
16548        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
16549        Ok(())
16550    }
16551
16552    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
16553        status != PushStatus::Rejected
16554            || !results.iter().any(|result| {
16555                matches!(
16556                    result,
16557                    OpResult::Error {
16558                        code,
16559                        retryable: true,
16560                        ..
16561                    } if code == "sync.idempotency_cache_miss"
16562                )
16563            })
16564    }
16565
16566    // -- subscription sections ------------------------------------------------------
16567
16568    #[allow(clippy::too_many_arguments)]
16569    fn process_section(
16570        &mut self,
16571        transport: &mut dyn Transport,
16572        id: &str,
16573        status: SubStatus,
16574        reason_code: &str,
16575        effective_scopes: Vec<(String, Vec<String>)>,
16576        body: Vec<Frame>,
16577        sub_end: Option<(i64, Option<String>)>,
16578        meta: &RequestMeta,
16579        report: &mut SyncReport,
16580    ) -> Result<(), SectionError> {
16581        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
16582            return Ok(()); // unknown echo: ignore
16583        };
16584        if status != SubStatus::Active && sub_end.is_none() {
16585            return Err(SectionError::Abort(
16586                "sync.invalid_request".into(),
16587                "subscription section without SUB_END".into(),
16588            ));
16589        }
16590        match status {
16591            SubStatus::Revoked => {
16592                self.begin_observation("syncular_revocation")
16593                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
16594                let previous = self.subs[sub_index].clone();
16595                let was_dirty = self.overlay_dirty.snapshot();
16596                let previous_outbox = self.outbox.clone();
16597                let previous_failed = self.failed_commits.clone();
16598                let previous_rejections = self.rejections.clone();
16599                let previous_conflicts = self.conflicts.clone();
16600                let revoked_count = report.revoked.len();
16601                let failed_count = report.failed.len();
16602                let result = (|| {
16603                    let mut batch = ChangeAccumulator::default();
16604                    let registered = self.window_unit_by_sub_id(id);
16605                    // §3.3: stop pulling, purge exactly the last effective grant.
16606                    let (table, effective) = {
16607                        let sub = &self.subs[sub_index];
16608                        (sub.table.clone(), sub.effective.clone().unwrap_or_default())
16609                    };
16610                    let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
16611                        SectionError::Abort(
16612                            "sync.unknown_table".to_owned(),
16613                            "revoked scope references an unknown table".to_owned(),
16614                        )
16615                    })?;
16616                    let dropped = self
16617                        .drop_failed_commits(|name, values| {
16618                            name == table
16619                                && !effective.is_empty()
16620                                && effective.iter().all(|(variable, allowed)| {
16621                                    scoped_table
16622                                        .scope_column(variable)
16623                                        .and_then(|column| values.get(column))
16624                                        .and_then(Value::as_str)
16625                                        .is_some_and(|value| {
16626                                            allowed.iter().any(|allowed| allowed == value)
16627                                        })
16628                                })
16629                        })
16630                        .map_err(|message| {
16631                            SectionError::Abort("storage.failed".to_owned(), message)
16632                        })?;
16633                    for commit in dropped {
16634                        batch.conflicts = true;
16635                        batch.rejections = true;
16636                        batch.outcomes = true;
16637                        for operation in commit.ops {
16638                            batch.table(&operation.table);
16639                        }
16640                    }
16641                    let purged = self.purge_scope_rows(&table, &effective);
16642                    match purged {
16643                        Ok(()) => {
16644                            self.record_scope_map(&mut batch, &table, &effective);
16645                            let sub = &mut self.subs[sub_index];
16646                            sub.state = SubState::Revoked;
16647                            sub.reason_code = Some(if reason_code.is_empty() {
16648                                "sync.scope_revoked".to_owned()
16649                            } else {
16650                                reason_code.to_owned()
16651                            });
16652                            report.revoked.push(id.to_owned());
16653                            let doomed_effective = effective;
16654                            let sub_table = table;
16655                            self.persist_sub(&self.subs[sub_index].clone())
16656                                .map_err(|message| {
16657                                    SectionError::Abort("storage.failed".into(), message)
16658                                })?;
16659                            let dropped = self
16660                                .drop_doomed_outbox(&sub_table, &doomed_effective)
16661                                .map_err(|message| {
16662                                    SectionError::Abort("storage.failed".to_owned(), message)
16663                                })?;
16664                            if dropped {
16665                                batch.status = true;
16666                                batch.rejections = true;
16667                                batch.outcomes = true;
16668                            }
16669                            // §5.9.7 B2: revocation deletes now-unauthorized blob
16670                            // bodies (evicted ≠ revoked).
16671                            self.delete_unreferenced_cached_blobs().map_err(|message| {
16672                                SectionError::Abort("storage.failed".into(), message)
16673                            })?;
16674                        }
16675                        Err(()) => {
16676                            // §3.3 fail closed: no local mapping — never clear by
16677                            // approximation; fatal configuration error.
16678                            let sub = &mut self.subs[sub_index];
16679                            sub.state = SubState::Failed;
16680                            sub.reason_code = Some("sync.scope_revoked".to_owned());
16681                            report.failed.push(id.to_owned());
16682                            self.persist_sub(&self.subs[sub_index].clone())
16683                                .map_err(|message| {
16684                                    SectionError::Abort("storage.failed".into(), message)
16685                                })?;
16686                        }
16687                    }
16688                    if let Some((base_key, unit)) = registered {
16689                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
16690                    }
16691                    self.rebuild_overlay_if_dirty()
16692                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
16693                    self.finish_observation("syncular_revocation", batch)
16694                        .map_err(|message| {
16695                            SectionError::Abort("storage.failed".to_owned(), message)
16696                        })?;
16697                    Ok(())
16698                })();
16699                if result.is_err() {
16700                    self.rollback_observation("syncular_revocation");
16701                    self.subs[sub_index] = previous;
16702                    self.overlay_dirty.restore(was_dirty.clone());
16703                    self.outbox = previous_outbox;
16704                    self.failed_commits = previous_failed;
16705                    self.rejections = previous_rejections;
16706                    self.conflicts = previous_conflicts;
16707                    report.revoked.truncate(revoked_count);
16708                    report.failed.truncate(failed_count);
16709                }
16710                result
16711            }
16712            SubStatus::Reset => {
16713                self.begin_observation("syncular_reset")
16714                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
16715                let previous = self.subs[sub_index].clone();
16716                let was_dirty = self.overlay_dirty.snapshot();
16717                let reset_count = report.resets.len();
16718                let result = (|| {
16719                    let mut batch = ChangeAccumulator::default();
16720                    let registered = self.window_unit_by_sub_id(id);
16721                    // §4.6: discard cursor + bootstrap state, keep local rows —
16722                    // reset is a staleness signal, not a purge signal.
16723                    let sub = &mut self.subs[sub_index];
16724                    sub.cursor = -1;
16725                    sub.bootstrap_state = None;
16726                    sub.reason_code = Some(if reason_code.is_empty() {
16727                        "sync.cursor_expired".to_owned()
16728                    } else {
16729                        reason_code.to_owned()
16730                    });
16731                    report.resets.push(id.to_owned());
16732                    self.persist_sub(&self.subs[sub_index].clone())
16733                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
16734                    if let Some((base_key, unit)) = registered {
16735                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
16736                    }
16737                    self.finish_observation("syncular_reset", batch)
16738                        .map_err(|message| {
16739                            SectionError::Abort("storage.failed".to_owned(), message)
16740                        })?;
16741                    Ok(())
16742                })();
16743                if result.is_err() {
16744                    self.rollback_observation("syncular_reset");
16745                    self.subs[sub_index] = previous;
16746                    self.overlay_dirty.restore(was_dirty.clone());
16747                    report.resets.truncate(reset_count);
16748                }
16749                result
16750            }
16751            SubStatus::Active => {
16752                let fresh = meta
16753                    .fresh
16754                    .iter()
16755                    .find(|(fid, _)| fid == id)
16756                    .map(|(_, fresh)| *fresh)
16757                    .unwrap_or(false);
16758                let previous = self.subs[sub_index].clone();
16759                self.subs[sub_index].effective = Some(effective_scopes);
16760                let outcome = self
16761                    .apply_section_body(transport, sub_index, body, fresh, meta, report)
16762                    .and_then(|()| {
16763                        let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
16764                            SectionError::Abort(
16765                                "sync.invalid_request".into(),
16766                                "subscription section without SUB_END".into(),
16767                            )
16768                        })?;
16769                        self.apply_sub_end(sub_index, cursor, bootstrap_state)
16770                            .map_err(|message| {
16771                                SectionError::Abort("storage.failed".into(), message)
16772                            })
16773                    });
16774                match outcome {
16775                    Ok(()) => {
16776                        if self.subs[sub_index].bootstrap_state.is_some() {
16777                            report.bootstrapping.push(id.to_owned());
16778                        }
16779                        Ok(())
16780                    }
16781                    Err(SectionError::FailClosed) => {
16782                        self.subs[sub_index] = previous.clone();
16783                        self.begin_observation("syncular_section_failure")
16784                            .map_err(|message| {
16785                                SectionError::Abort("storage.failed".into(), message)
16786                            })?;
16787                        let persisted = (|| {
16788                            let mut batch = ChangeAccumulator::default();
16789                            if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
16790                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
16791                            }
16792                            self.subs[sub_index].state = SubState::Failed;
16793                            self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
16794                            self.persist_sub(&self.subs[sub_index])?;
16795                            self.finish_observation("syncular_section_failure", batch)
16796                        })();
16797                        if let Err(message) = persisted {
16798                            self.rollback_observation("syncular_section_failure");
16799                            self.subs[sub_index] = previous;
16800                            return Err(SectionError::Abort("storage.failed".into(), message));
16801                        }
16802                        report.failed.push(id.to_owned());
16803                        Ok(())
16804                    }
16805                    Err(error) => {
16806                        // Frame/block transactions already completed. Preserve their
16807                        // rows and revisions, but leave this subscription cursor unchanged.
16808                        self.subs[sub_index] = previous;
16809                        Err(error)
16810                    }
16811                }
16812            }
16813        }
16814    }
16815
16816    fn apply_sub_end(
16817        &mut self,
16818        sub_index: usize,
16819        cursor: i64,
16820        bootstrap_state: Option<String>,
16821    ) -> Result<(), String> {
16822        #[cfg(feature = "bench-internals")]
16823        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
16824        let previous = self.subs[sub_index].clone();
16825        self.begin_observation("syncular_sub_end")?;
16826        let persisted = (|| {
16827            let mut batch = ChangeAccumulator::default();
16828            let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
16829                && cursor >= 0
16830                && bootstrap_state.is_none();
16831            if completed {
16832                let table = self
16833                    .schema
16834                    .table(&previous.table)
16835                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
16836                let effective = previous.effective.as_deref().unwrap_or(&[]);
16837                let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
16838                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16839                let rows = statement
16840                    .query_map(rusqlite::params![previous.table, cursor], |row| {
16841                        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
16842                    })
16843                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16844                let mut covered = HashSet::new();
16845                for row in rows {
16846                    let (id, intent) =
16847                        row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16848                    let values: Map<String, Value> = serde_json::from_str(&intent)
16849                        .map_err(|_| "sync.local_corrupt".to_owned())?;
16850                    if !effective.is_empty()
16851                        && effective.iter().all(|(variable, allowed)| {
16852                            table
16853                                .scope_column(variable)
16854                                .and_then(|column| values.get(column))
16855                                .is_some_and(|value| {
16856                                    allowed.contains(
16857                                        &value
16858                                            .as_str()
16859                                            .map_or_else(|| value.to_string(), str::to_owned),
16860                                    )
16861                                })
16862                        })
16863                    {
16864                        covered.insert(id);
16865                    }
16866                }
16867                drop(statement);
16868                for id in covered {
16869                    self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
16870                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16871                    self.overlay_dirty.table(&previous.table);
16872                    batch.table(&previous.table);
16873                }
16874                self.rebuild_overlay_if_dirty()?;
16875                if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
16876                    batch.window(&base_key, &previous.table, &unit);
16877                }
16878            }
16879            let sub = &mut self.subs[sub_index];
16880            sub.cursor = cursor;
16881            sub.bootstrap_state = bootstrap_state;
16882            // §4.6: a completed (or continuing) active end clears the reset /
16883            // revocation reason, matching the TS core's active-state persist.
16884            sub.reason_code = None;
16885            sub.synced_once = true;
16886            self.persist_sub(&self.subs[sub_index])?;
16887            self.finish_observation("syncular_sub_end", batch)
16888        })();
16889        if persisted.is_err() {
16890            self.rollback_observation("syncular_sub_end");
16891            self.subs[sub_index] = previous;
16892        }
16893        persisted
16894    }
16895
16896    fn apply_section_body(
16897        &mut self,
16898        transport: &mut dyn Transport,
16899        sub_index: usize,
16900        body: Vec<Frame>,
16901        fresh: bool,
16902        meta: &RequestMeta,
16903        report: &mut SyncReport,
16904    ) -> Result<(), SectionError> {
16905        let mut saw_segment = false;
16906        for frame in body {
16907            match frame {
16908                Frame::Commit {
16909                    tables,
16910                    changes,
16911                    commit_seq,
16912                    ..
16913                } => {
16914                    self.apply_commit_frame(&tables, &changes, Some(commit_seq))
16915                        .map_err(|(c, m)| SectionError::Abort(c, m))?;
16916                    report.commits_applied += 1;
16917                }
16918                Frame::SegmentInline { payload } => {
16919                    let segment = decode_rows_segment(&payload)
16920                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
16921                    self.progress.update(|p| {
16922                        p.phase = ProgressPhase::Import;
16923                        p.subscription_id = Some(self.subs[sub_index].id.clone());
16924                        p.table = Some(self.subs[sub_index].table.clone());
16925                        p.segment_id = None;
16926                        p.bytes_received = payload.len() as u64;
16927                        p.bytes_total = Some(payload.len() as u64);
16928                        p.rows_processed = 0;
16929                        p.rows_total =
16930                            Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
16931                    });
16932                    let first = !saw_segment;
16933                    saw_segment = true;
16934                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
16935                    report.segment_rows_applied += applied;
16936                }
16937                Frame::SegmentRef {
16938                    segment_id,
16939                    byte_length,
16940                    media_type,
16941                    table,
16942                    row_count,
16943                    as_of_commit_seq,
16944                    scope_digest,
16945                    row_cursor,
16946                    next_row_cursor,
16947                    url,
16948                    url_expires_at_ms,
16949                    ..
16950                } => {
16951                    // §4.2: reject a descriptor whose mediaType was not
16952                    // advertised — never skip or guess.
16953                    let advertised = match media_type {
16954                        MediaType::Rows => {
16955                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
16956                                || meta.accept & ACCEPT_INLINE_ROWS != 0
16957                        }
16958                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
16959                    };
16960                    if !advertised {
16961                        return Err(SectionError::Abort(
16962                            "sync.invalid_request".to_owned(),
16963                            format!(
16964                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
16965                                media_type.name()
16966                            ),
16967                        ));
16968                    }
16969                    self.progress.update(|p| {
16970                        p.phase = ProgressPhase::Download;
16971                        p.subscription_id = Some(self.subs[sub_index].id.clone());
16972                        p.table = Some(table.clone());
16973                        p.segment_id = Some(segment_id.clone());
16974                        p.bytes_received = 0;
16975                        p.bytes_total = u64::try_from(byte_length).ok();
16976                        p.rows_processed = 0;
16977                        p.rows_total = u64::try_from(row_count).ok();
16978                    });
16979                    let observer = self.progress.clone();
16980                    let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
16981                    let bytes = if let Some(url) = url {
16982                        // §5.4: a url-carrying descriptor MUST be fetched
16983                        // from that URL; failure invalidates the whole
16984                        // descriptor (no fall-through to §5.5 — re-pull
16985                        // recovers, §1.4 rule 5).
16986                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
16987                            return Err(SectionError::Abort(
16988                                "sync.invalid_request".to_owned(),
16989                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
16990                                    .to_owned(),
16991                            ));
16992                        }
16993                        // §5.4: MUST NOT start a fetch at/past expiry.
16994                        if url_expires_at_ms.is_some_and(|exp| {
16995                            exp <= transport
16996                                .segment_fetch_started_at(&url)
16997                                .unwrap_or_else(|| self.clock_now_ms())
16998                        }) {
16999                            return Err(SectionError::Abort(
17000                                "sync.segment_expired".to_owned(),
17001                                format!(
17002                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
17003                                ),
17004                            ));
17005                        }
17006                        transport
17007                            .fetch_url(&url, &mut on_progress)
17008                            .map_err(|e| SectionError::Abort(e.code, e.message))?
17009                    } else {
17010                        let requested_scopes_json =
17011                            canonical_scope_json(&self.subs[sub_index].requested);
17012                        transport
17013                            .download_segment(
17014                                &SegmentRequest {
17015                                    segment_id: segment_id.clone(),
17016                                    table,
17017                                    requested_scopes_json,
17018                                },
17019                                &mut on_progress,
17020                            )
17021                            .map_err(|e| SectionError::Abort(e.code, e.message))?
17022                    };
17023                    // §5.1: verify the content address before applying.
17024                    on_progress(bytes.len() as u64);
17025                    let digest = Sha256::digest(&bytes);
17026                    let expected = segment_id
17027                        .strip_prefix("sha256:")
17028                        .unwrap_or(segment_id.as_str());
17029                    if bytes_to_hex(&digest) != expected {
17030                        // §5.1: the client discards the segment and re-pulls.
17031                        self.schedule_background_retry();
17032                        return Err(SectionError::Abort(
17033                            "sync.invalid_request".to_owned(),
17034                            "segment bytes do not match the content address (§5.1)".to_owned(),
17035                        ));
17036                    }
17037                    self.progress.update(|p| p.phase = ProgressPhase::Import);
17038                    if media_type == MediaType::Sqlite {
17039                        // §5.3: images are whole-table — a paged sqlite
17040                        // descriptor is invalid.
17041                        if row_cursor.is_some() || next_row_cursor.is_some() {
17042                            return Err(SectionError::Abort(
17043                                "sync.invalid_request".to_owned(),
17044                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
17045                                    .to_owned(),
17046                            ));
17047                        }
17048                        let first = !saw_segment;
17049                        saw_segment = true;
17050                        let applied = self.apply_sqlite_segment(
17051                            sub_index,
17052                            &bytes,
17053                            fresh && first,
17054                            row_count,
17055                            as_of_commit_seq,
17056                            &scope_digest,
17057                        )?;
17058                        report.segment_rows_applied += applied;
17059                    } else {
17060                        let segment = decode_rows_segment(&bytes).map_err(|e| {
17061                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
17062                        })?;
17063                        let first = row_cursor.is_none();
17064                        saw_segment = true;
17065                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
17066                        report.segment_rows_applied += applied;
17067                    }
17068                }
17069                Frame::Unknown { .. } => {}
17070                Frame::Error { code, message, .. } => {
17071                    return Err(SectionError::Abort(code, message))
17072                }
17073                _ => {
17074                    return Err(SectionError::Abort(
17075                        "sync.invalid_request".to_owned(),
17076                        "unexpected frame inside a subscription section".to_owned(),
17077                    ));
17078                }
17079            }
17080        }
17081        Ok(())
17082    }
17083
17084    // Both pull and realtime frames use the same durable observation boundary.
17085    fn apply_commit_frame(
17086        &mut self,
17087        tables: &[String],
17088        changes: &[ssp2::model::Change],
17089        commit_seq: Option<i64>,
17090    ) -> Result<(), (String, String)> {
17091        #[cfg(feature = "bench-internals")]
17092        let _phase = self.benchmark_phases.start(Phase::CommitApply);
17093        let was_dirty = self.overlay_dirty.snapshot();
17094        self.begin_observation("syncular_commit")
17095            .map_err(|message| ("storage.failed".into(), message))?;
17096        let applied = (|| {
17097            let mut batch = ChangeAccumulator::default();
17098            self.record_commit_changes(&mut batch, tables, changes);
17099            if let Some(sequence) = commit_seq {
17100                for change in changes {
17101                    let table = tables.get(change.table_index as usize).ok_or_else(|| {
17102                        (
17103                            "sync.invalid_request".to_owned(),
17104                            "change tableIndex out of range".to_owned(),
17105                        )
17106                    })?;
17107                    if self
17108                        .outbox
17109                        .iter()
17110                        .flat_map(|commit| &commit.ops)
17111                        .any(|op| op.table == *table && op.row_id == change.row_id)
17112                    {
17113                        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])
17114                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
17115                    }
17116                    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])
17117                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
17118                }
17119            }
17120            self.apply_commit_changes(tables, changes)?;
17121            self.rebuild_overlay_if_dirty()
17122                .map_err(|message| ("storage.failed".into(), message))?;
17123            self.finish_observation("syncular_commit", batch)
17124                .map_err(|message| ("storage.failed".into(), message))
17125        })();
17126        if applied.is_err() {
17127            self.rollback_observation("syncular_commit");
17128            self.overlay_dirty.restore(was_dirty.clone());
17129        }
17130        applied
17131    }
17132
17133    fn apply_commit_changes(
17134        &mut self,
17135        tables: &[String],
17136        changes: &[ssp2::model::Change],
17137    ) -> Result<(), (String, String)> {
17138        // A clean overlay can reconcile changed primary keys independently
17139        // when their tables have no secondary unique constraints. Such a
17140        // constraint can change whether another row's pending write succeeds,
17141        // so those frames still require the complete FIFO replay below.
17142        let mirror_visible = !self.overlay_dirty.get()
17143            && self.failed_commits.is_empty()
17144            && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
17145                || changes.iter().all(|change| {
17146                    tables
17147                        .get(change.table_index as usize)
17148                        .and_then(|name| self.schema.table(name))
17149                        .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
17150                }));
17151        for change in changes {
17152            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
17153                (
17154                    "sync.invalid_request".to_owned(),
17155                    "change tableIndex out of range".to_owned(),
17156                )
17157            })?;
17158            let table = self.schema.table(table_name).ok_or_else(|| {
17159                (
17160                    "sync.schema_mismatch".to_owned(),
17161                    format!("change targets unknown table {table_name:?}"),
17162                )
17163            })?;
17164            match change.op {
17165                Op::Upsert => {
17166                    let payload = change.row.as_ref().ok_or_else(|| {
17167                        (
17168                            "sync.invalid_request".to_owned(),
17169                            "upsert change without row payload".to_owned(),
17170                        )
17171                    })?;
17172                    // §5.11: decrypt encrypted columns on apply.
17173                    let row = {
17174                        #[cfg(feature = "bench-internals")]
17175                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
17176                        decode_row_bytes(table, payload, &self.encryption)
17177                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?
17178                    };
17179                    let version = change.row_version.unwrap_or(0);
17180                    let table_name = table.name.clone();
17181                    self.write_base_row(&table_name, &row, version)
17182                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
17183                    if mirror_visible {
17184                        self.write_row(&visible_table(&table_name), &table_name, &row, version)
17185                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
17186                    }
17187                }
17188                Op::Delete => {
17189                    self.delete_base_row(table_name, &change.row_id)
17190                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
17191                    if mirror_visible {
17192                        let sql = format!(
17193                            "DELETE FROM {} WHERE {}",
17194                            visible_table(table_name),
17195                            row_id_predicate(table)
17196                        );
17197                        self.conn
17198                            .prepare_cached(&sql)
17199                            .and_then(|mut statement| {
17200                                statement.execute(rusqlite::params![change.row_id])
17201                            })
17202                            .map_err(|error| {
17203                                ("sync.invalid_request".to_owned(), error.to_string())
17204                            })?;
17205                    }
17206                }
17207            }
17208        }
17209        if mirror_visible {
17210            {
17211                let changed_rows: HashSet<(&str, &str)> = changes
17212                    .iter()
17213                    .map(|change| {
17214                        (
17215                            tables[change.table_index as usize].as_str(),
17216                            change.row_id.as_str(),
17217                        )
17218                    })
17219                    .collect();
17220                let acknowledged = self
17221                    .acknowledged_operations(Some(&changed_rows), None)
17222                    .map_err(|error| ("storage.failed".to_owned(), error))?;
17223                self.apply_outbox_ops(&acknowledged)
17224                    .map_err(|message| ("storage.failed".into(), message))?;
17225                self.apply_outbox_ops(
17226                    self.outbox
17227                        .iter()
17228                        .flat_map(|commit| &commit.ops)
17229                        .filter(|op| {
17230                            changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
17231                        }),
17232                )
17233                .map_err(|message| ("storage.failed".into(), message))?;
17234            }
17235            self.overlay_dirty.set(false);
17236        }
17237        Ok(())
17238    }
17239
17240    /// §5.6 segment application: validate against the generated schema,
17241    /// clear the grant on a fresh bootstrap's first page (fail closed
17242    /// without a mapping), then replace-or-upsert each row with its
17243    /// segment-carried server version (§5.2).
17244    fn apply_segment(
17245        &mut self,
17246        sub_index: usize,
17247        segment: &RowsSegment,
17248        first_fresh_page: bool,
17249    ) -> Result<u32, SectionError> {
17250        let (sub_table, effective) = {
17251            let sub = &self.subs[sub_index];
17252            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
17253        };
17254        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
17255            SectionError::Abort(
17256                "sync.schema_mismatch".to_owned(),
17257                format!("subscription table {sub_table:?} is not in the client schema"),
17258            )
17259        })?;
17260        // §5.2: the column table validates against the generated schema —
17261        // order, names, types, nullability; mismatch is fatal. §5.11: the
17262        // server sends the WIRE types (bytes for an encrypted column), so
17263        // validate against wire_columns.
17264        let matches = segment.table == table.name
17265            && segment.schema_version == self.schema.version
17266            && segment.columns.len() == table.wire_columns.len()
17267            && segment
17268                .columns
17269                .iter()
17270                .zip(table.wire_columns.iter())
17271                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
17272        if !matches {
17273            return Err(SectionError::Abort(
17274                "sync.schema_mismatch".to_owned(),
17275                "segment column table does not match the generated schema (§5.2)".to_owned(),
17276            ));
17277        }
17278        let mut applied = 0u32;
17279        let empty = Vec::new();
17280        let blocks = if segment.blocks.is_empty() {
17281            std::slice::from_ref(&empty)
17282        } else {
17283            &segment.blocks
17284        };
17285        for (index, block) in blocks.iter().enumerate() {
17286            let clear = first_fresh_page && index == 0;
17287            if !clear && block.is_empty() {
17288                continue;
17289            }
17290            let was_dirty = self.overlay_dirty.snapshot();
17291            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
17292                && self
17293                    .outbox
17294                    .iter()
17295                    .flat_map(|commit| &commit.ops)
17296                    .any(|op| op.table == table.name);
17297            let mirror_visible = !reconcile_pending
17298                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
17299            self.begin_observation("syncular_segment_block")
17300                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17301            let outcome = (|| {
17302                self.rebuild_overlay_if_dirty()
17303                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17304                let mut changed_keys = Vec::new();
17305                let mut batch = ChangeAccumulator::default();
17306                if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
17307                    batch.table(&table.name);
17308                }
17309                if clear {
17310                    self.purge_scope_rows(&table.name, &effective)
17311                        .map_err(|()| SectionError::FailClosed)?;
17312                }
17313                for (row_index, row) in block.iter().enumerate() {
17314                    let decrypted;
17315                    let values = if table.has_encrypted_columns() {
17316                        let mut values = row.values.clone();
17317                        crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
17318                            .map_err(|message| {
17319                            SectionError::Abort("client.decrypt_failed".into(), message)
17320                        })?;
17321                        decrypted = values;
17322                        &decrypted
17323                    } else {
17324                        &row.values
17325                    };
17326                    self.write_base_row(&table.name, values, row.server_version)
17327                        .map_err(|message| {
17328                            SectionError::Abort("sync.invalid_request".into(), message)
17329                        })?;
17330                    if reconcile_pending {
17331                        changed_keys.push(
17332                            owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
17333                                |message| SectionError::Abort("storage.failed".into(), message),
17334                            )?,
17335                        );
17336                    }
17337                    if mirror_visible {
17338                        self.write_row(
17339                            &visible_table(&table.name),
17340                            &table.name,
17341                            values,
17342                            row.server_version,
17343                        )
17344                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17345                    }
17346                    let processed = u64::from(applied) + row_index as u64 + 1;
17347                    if processed.is_multiple_of(1024) {
17348                        self.progress.update(|p| p.rows_processed = processed);
17349                    }
17350                }
17351                if reconcile_pending {
17352                    self.reconcile_imported_rows(&table, &mut changed_keys)
17353                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17354                }
17355                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
17356                    self.apply_outbox_ops(
17357                        self.outbox
17358                            .iter()
17359                            .flat_map(|commit| &commit.ops)
17360                            .filter(|op| op.table == table.name),
17361                    )
17362                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17363                    self.overlay_dirty.set(false);
17364                } else {
17365                    self.rebuild_overlay_if_dirty()
17366                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17367                }
17368                self.finish_observation("syncular_segment_block", batch)
17369                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
17370            })();
17371            if let Err(error) = outcome {
17372                self.rollback_observation("syncular_segment_block");
17373                self.overlay_dirty.restore(was_dirty.clone());
17374                return Err(error);
17375            }
17376            applied += block.len() as u32;
17377            self.progress
17378                .update(|p| p.rows_processed = u64::from(applied));
17379            std::thread::yield_now();
17380        }
17381        self.progress
17382            .update(|p| p.rows_processed = u64::from(applied));
17383        Ok(applied)
17384    }
17385
17386    /// §5.3 sqlite-image application: validate the in-file metadata
17387    /// against the descriptor, validate column names/order against the
17388    /// generated schema, run the §5.6 first-page clear when fresh, then
17389    /// replace-or-upsert every image row with its `_syncular_version`.
17390    /// Mechanics: the image lands in a temp file read through a second
17391    /// rusqlite connection, equivalent to ATTACH + INSERT…SELECT.
17392    /// Each image chunk commits its writes and revision before readers continue.
17393    fn apply_sqlite_segment(
17394        &mut self,
17395        sub_index: usize,
17396        bytes: &[u8],
17397        first_fresh_page: bool,
17398        row_count: i64,
17399        as_of_commit_seq: i64,
17400        scope_digest: &str,
17401    ) -> Result<u32, SectionError> {
17402        let invalid = |detail: &str| {
17403            SectionError::Abort(
17404                "sync.invalid_request".to_owned(),
17405                format!("sqlite segment rejected: {detail} (§5.3)"),
17406            )
17407        };
17408        let (sub_table, effective) = {
17409            let sub = &self.subs[sub_index];
17410            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
17411        };
17412        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
17413            SectionError::Abort(
17414                "sync.schema_mismatch".to_owned(),
17415                format!("subscription table {sub_table:?} is not in the client schema"),
17416            )
17417        })?;
17418
17419        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
17420        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
17421        let img = match rusqlite::Connection::open_with_flags(
17422            &path,
17423            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
17424        ) {
17425            Ok(conn) => conn,
17426            Err(_) => {
17427                let _ = std::fs::remove_file(&path);
17428                return Err(invalid("bytes do not open as a SQLite database"));
17429            }
17430        };
17431        let outcome = self.apply_sqlite_image(
17432            &img,
17433            &table,
17434            first_fresh_page,
17435            &effective,
17436            row_count,
17437            as_of_commit_seq,
17438            scope_digest,
17439        );
17440        drop(img);
17441        let _ = std::fs::remove_file(&path);
17442        outcome
17443    }
17444
17445    #[allow(clippy::too_many_arguments)]
17446    fn apply_sqlite_image(
17447        &mut self,
17448        img: &rusqlite::Connection,
17449        table: &crate::schema::TableSchema,
17450        first_fresh_page: bool,
17451        effective: &[(String, Vec<String>)],
17452        row_count: i64,
17453        as_of_commit_seq: i64,
17454        scope_digest: &str,
17455    ) -> Result<u32, SectionError> {
17456        let invalid = |detail: String| {
17457            SectionError::Abort(
17458                "sync.invalid_request".to_owned(),
17459                format!("sqlite segment rejected: {detail} (§5.3)"),
17460            )
17461        };
17462
17463        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
17464        //    one row).
17465        type MetaRow = (i64, String, i64, i64, String, i64, i64);
17466        let meta: MetaRow = img
17467            .query_row(
17468                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
17469                        \"scopeDigest\", \"rowCount\",
17470                        (SELECT count(*) FROM _syncular_segment)
17471                 FROM _syncular_segment",
17472                [],
17473                |row| {
17474                    Ok((
17475                        row.get(0)?,
17476                        row.get(1)?,
17477                        row.get(2)?,
17478                        row.get(3)?,
17479                        row.get(4)?,
17480                        row.get(5)?,
17481                        row.get(6)?,
17482                    ))
17483                },
17484            )
17485            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
17486        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
17487        if meta_count != 1 {
17488            return Err(invalid(format!(
17489                "_syncular_segment must contain exactly one row, found {meta_count}"
17490            )));
17491        }
17492        if format != 1 {
17493            return Err(invalid(format!("format {format}")));
17494        }
17495        if meta_table != table.name {
17496            return Err(invalid(format!("image table {meta_table:?}")));
17497        }
17498        if schema_version != i64::from(self.schema.version) {
17499            return Err(invalid(format!("schemaVersion {schema_version}")));
17500        }
17501        if pin != as_of_commit_seq {
17502            return Err(invalid(format!("asOfCommitSeq {pin}")));
17503        }
17504        if digest != scope_digest {
17505            return Err(invalid("scopeDigest mismatch".to_owned()));
17506        }
17507        if meta_rows != row_count {
17508            return Err(invalid(format!("rowCount {meta_rows}")));
17509        }
17510
17511        // 2. Column names and order vs the generated schema (§5.3 rule 3).
17512        let mut names: Vec<String> = Vec::new();
17513        let mut primary_keys: Vec<String> = Vec::new();
17514        {
17515            let mut stmt = img
17516                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
17517                .map_err(|_| invalid("image data table missing".to_owned()))?;
17518            let mut rows = stmt
17519                .query([])
17520                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
17521            while let Some(row) = rows
17522                .next()
17523                .map_err(|_| invalid("image data table unreadable".to_owned()))?
17524            {
17525                if row
17526                    .get::<_, i64>(5)
17527                    .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
17528                    > 0
17529                {
17530                    primary_keys.push(row.get(1).map_err(|error| {
17531                        invalid(Self::sqlite_failure(&self.storage_failure, error))
17532                    })?);
17533                }
17534                names.push(
17535                    row.get::<_, String>(1)
17536                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
17537                );
17538            }
17539        }
17540        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
17541        expected.push("_syncular_version");
17542        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
17543            return Err(SectionError::Abort(
17544                "sync.schema_mismatch".to_owned(),
17545                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
17546            ));
17547        }
17548
17549        if primary_keys != vec![table.primary_key.clone()] {
17550            return Err(invalid(
17551                "image primary key does not match generated schema".into(),
17552            ));
17553        }
17554        let actual_count: i64 = img
17555            .query_row(
17556                &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
17557                [],
17558                |row| row.get(0),
17559            )
17560            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
17561        if actual_count != row_count {
17562            return Err(invalid("image row count does not match descriptor".into()));
17563        }
17564        let insert = self.insert_row_sql(&base_table(&table.name), table);
17565        let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
17566        let column_list = names
17567            .iter()
17568            .map(|n| quote_ident(n))
17569            .collect::<Vec<_>>()
17570            .join(", ");
17571        let mut stmt = img
17572            .prepare(&format!(
17573                "SELECT {column_list} FROM {} ORDER BY {}",
17574                quote_ident(&table.name),
17575                quote_ident(&table.primary_key)
17576            ))
17577            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
17578        let mut rows = stmt
17579            .query([])
17580            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
17581        let mut applied = 0u32;
17582        loop {
17583            let clear = applied == 0 && first_fresh_page;
17584            let was_dirty = self.overlay_dirty.snapshot();
17585            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
17586                && self
17587                    .outbox
17588                    .iter()
17589                    .flat_map(|commit| &commit.ops)
17590                    .any(|op| op.table == table.name);
17591            let mirror_visible = !reconcile_pending
17592                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
17593            self.begin_observation("syncular_image_chunk")
17594                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17595            let result = (|| {
17596                self.rebuild_overlay_if_dirty()
17597                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17598                let mut changed_keys = Vec::new();
17599                let mut batch = ChangeAccumulator::default();
17600                if clear {
17601                    if self.scoped_rows_exist(&table.name, effective) {
17602                        batch.table(&table.name);
17603                    }
17604                    self.purge_scope_rows(&table.name, effective)
17605                        .map_err(|()| SectionError::FailClosed)?;
17606                }
17607                let mut processed = 0u32;
17608                {
17609                    let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
17610                        invalid(Self::sqlite_failure(&self.storage_failure, error))
17611                    })?;
17612                    let mut mirror = if mirror_visible {
17613                        Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
17614                            invalid(Self::sqlite_failure(&self.storage_failure, error))
17615                        })?)
17616                    } else {
17617                        None
17618                    };
17619                    for _ in 0..1024 {
17620                        let Some(row) = rows.next().map_err(|error| {
17621                            invalid(Self::sqlite_failure(&self.storage_failure, error))
17622                        })?
17623                        else {
17624                            break;
17625                        };
17626                        for (i, column) in table.columns.iter().enumerate() {
17627                            let cell = row.get_ref(i).map_err(|error| {
17628                                invalid(Self::sqlite_failure(&self.storage_failure, error))
17629                            })?;
17630                            let param = image_cell_param(column, cell).map_err(invalid)?;
17631                            ins.raw_bind_parameter(i + 1, &param).map_err(|error| {
17632                                invalid(Self::sqlite_failure(&self.storage_failure, error))
17633                            })?;
17634                            if let Some(mirror) = &mut mirror {
17635                                mirror.raw_bind_parameter(i + 1, &param).map_err(|error| {
17636                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
17637                                })?;
17638                            }
17639                        }
17640                        let version_index = table.columns.len();
17641                        let version: i64 = row.get(version_index).map_err(|error| {
17642                            invalid(Self::sqlite_failure(&self.storage_failure, error))
17643                        })?;
17644                        if version < 1 {
17645                            return Err(invalid("image row version must be positive".into()));
17646                        }
17647                        ins.raw_bind_parameter(version_index + 1, version)
17648                            .map_err(|error| {
17649                                invalid(Self::sqlite_failure(&self.storage_failure, error))
17650                            })?;
17651                        ins.raw_execute().map_err(|error| {
17652                            invalid(Self::sqlite_failure(&self.storage_failure, error))
17653                        })?;
17654                        if let Some(mirror) = &mut mirror {
17655                            mirror
17656                                .raw_bind_parameter(version_index + 1, version)
17657                                .map_err(|error| {
17658                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
17659                                })?;
17660                            mirror.raw_execute().map_err(|error| {
17661                                invalid(Self::sqlite_failure(&self.storage_failure, error))
17662                            })?;
17663                        }
17664                        if reconcile_pending {
17665                            changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
17666                                |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
17667                            )?);
17668                        }
17669                        processed += 1;
17670                    }
17671                }
17672                if processed > 0 {
17673                    batch.table(&table.name);
17674                    self.overlay_dirty.table(&table.name);
17675                }
17676                if reconcile_pending {
17677                    self.reconcile_imported_rows(table, &mut changed_keys)
17678                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17679                }
17680                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
17681                    self.apply_outbox_ops(
17682                        self.outbox
17683                            .iter()
17684                            .flat_map(|commit| &commit.ops)
17685                            .filter(|op| op.table == table.name),
17686                    )
17687                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17688                    self.overlay_dirty.set(false);
17689                } else {
17690                    self.rebuild_overlay_if_dirty()
17691                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17692                }
17693                self.finish_observation("syncular_image_chunk", batch)
17694                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17695                Ok(processed)
17696            })();
17697            let processed = match result {
17698                Ok(processed) => processed,
17699                Err(error) => {
17700                    self.rollback_observation("syncular_image_chunk");
17701                    self.overlay_dirty.restore(was_dirty.clone());
17702                    return Err(error);
17703                }
17704            };
17705            applied += processed;
17706            self.progress
17707                .update(|p| p.rows_processed = u64::from(applied));
17708            std::thread::yield_now();
17709            if i64::from(applied) == actual_count {
17710                break;
17711            }
17712        }
17713        Ok(applied)
17714    }
17715
17716    /// Restore only imported and pending row identities before FIFO replay. All pending
17717    /// identities participate: a server row can free a unique value for a different edit.
17718    /// The caller owns the chunk transaction and publishes its revision after replay.
17719    fn reconcile_imported_rows(
17720        &self,
17721        table: &TableSchema,
17722        keys: &mut Vec<SqlValue>,
17723    ) -> Result<(), String> {
17724        let visible = visible_table(&table.name);
17725        let base = base_table(&table.name);
17726        let pk = quote_ident(&table.primary_key);
17727        let predicate = row_id_predicate(table);
17728        let mut lookup = self.conn.prepare_cached(&format!(
17729            "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
17730        )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17731        let mut deleted = HashSet::new();
17732        for op in self
17733            .outbox
17734            .iter()
17735            .flat_map(|commit| &commit.ops)
17736            .filter(|op| op.table == table.name)
17737        {
17738            if op.upsert {
17739                if let Some(values) = &op.values {
17740                    let key = json_to_column_value(
17741                        &table.columns[table.pk_index],
17742                        values.get(&table.primary_key),
17743                    )?;
17744                    keys.push(owned_sql_value(RowParam::Cell(&key))?);
17745                }
17746            } else if deleted.insert(&op.row_id) {
17747                keys.extend(
17748                    lookup
17749                        .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
17750                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
17751                        .collect::<Result<Vec<_>, _>>()
17752                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
17753                );
17754            }
17755        }
17756        // Delete the complete affected set before restoring any base value, so
17757        // old optimistic unique values cannot collide with the new server rows.
17758        for chunk in keys.chunks(400) {
17759            let holes = vec!["?"; chunk.len()].join(",");
17760            self.conn
17761                .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
17762                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
17763                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17764        }
17765        let updates = table
17766            .columns
17767            .iter()
17768            .map(|column| column.name.as_str())
17769            .chain(std::iter::once("_syncular_version"))
17770            .filter(|name| *name != table.primary_key)
17771            .map(|name| {
17772                let name = quote_ident(name);
17773                format!("{name}=excluded.{name}")
17774            })
17775            .collect::<Vec<_>>()
17776            .join(",");
17777        for chunk in keys.chunks(400) {
17778            let holes = vec!["?"; chunk.len()].join(",");
17779            self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
17780                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
17781                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17782        }
17783        Ok(())
17784    }
17785
17786    // -- application-authorized local purge ---------------------------------------
17787
17788    fn compile_local_data_purge(
17789        &self,
17790        input: &LocalDataPurgeInput,
17791    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
17792        let invalid = |message: String| format!("sync.invalid_request: {message}");
17793        if input.purge_id.is_empty()
17794            || input.purge_id.len() > 128
17795            || !is_local_operation_code_like(&input.purge_id)
17796        {
17797            return Err(invalid(
17798                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
17799            ));
17800        }
17801        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
17802            return Err(invalid(format!(
17803                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
17804            )));
17805        }
17806
17807        let mut deduplicated: BTreeMap<
17808            String,
17809            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
17810        > = BTreeMap::new();
17811        for target in &input.targets {
17812            let table = self.schema.table(&target.table).ok_or_else(|| {
17813                invalid(format!(
17814                    "local purge names unknown table {:?}",
17815                    target.table
17816                ))
17817            })?;
17818            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
17819                return Err(invalid(format!(
17820                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
17821                    target.table
17822                )));
17823            }
17824            let mut selectors = Vec::with_capacity(target.selectors.len());
17825            let mut canonical_selectors = BTreeMap::new();
17826            for (column_name, raw_values) in &target.selectors {
17827                let Some((column_index, column)) = table
17828                    .columns
17829                    .iter()
17830                    .enumerate()
17831                    .find(|(_, column)| column.name == *column_name)
17832                else {
17833                    return Err(invalid(format!(
17834                        "local purge target {:?} names unknown column {:?}",
17835                        target.table, column_name
17836                    )));
17837                };
17838                let encrypted = table
17839                    .encrypted_columns
17840                    .iter()
17841                    .any(|candidate| candidate.index == column_index);
17842                if column.ty != ColumnType::String || encrypted {
17843                    return Err(invalid(format!(
17844                        "local purge selector {:?}.{:?} must be a plaintext string column",
17845                        target.table, column_name
17846                    )));
17847                }
17848                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
17849                    return Err(invalid(format!(
17850                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
17851                        target.table, column_name
17852                    )));
17853                }
17854                let mut values = raw_values.clone();
17855                values.sort();
17856                values.dedup();
17857                if values.iter().any(|value| {
17858                    value.is_empty()
17859                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
17860                        || !is_local_operation_code_like(value)
17861                }) {
17862                    return Err(invalid(format!(
17863                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
17864                    )));
17865                }
17866                selectors.push((column_name.clone(), values.clone()));
17867                canonical_selectors.insert(column_name.clone(), values);
17868            }
17869            let canonical = LocalDataPurgeTarget {
17870                table: target.table.clone(),
17871                selectors: canonical_selectors,
17872            };
17873            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
17874            deduplicated.insert(
17875                key,
17876                (
17877                    CompiledLocalDataPurgeTarget {
17878                        table: target.table.clone(),
17879                        selectors,
17880                    },
17881                    canonical,
17882                ),
17883            );
17884        }
17885        let targets = deduplicated
17886            .values()
17887            .map(|(compiled, _)| compiled.clone())
17888            .collect::<Vec<_>>();
17889        let canonical = deduplicated
17890            .values()
17891            .map(|(_, target)| target.clone())
17892            .collect::<Vec<_>>();
17893        let canonical_plan =
17894            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
17895        Ok((targets, canonical_plan))
17896    }
17897
17898    fn local_purge_base_row_ids(
17899        &self,
17900        targets: &[CompiledLocalDataPurgeTarget],
17901    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
17902        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
17903        for target in targets {
17904            let table = self
17905                .schema
17906                .table(&target.table)
17907                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
17908            let mut clauses = Vec::with_capacity(target.selectors.len());
17909            let mut params = Vec::new();
17910            for (column, values) in &target.selectors {
17911                clauses.push(format!(
17912                    "{} IN ({})",
17913                    quote_ident(column),
17914                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
17915                ));
17916                params.extend(values.iter().cloned().map(SqlValue::Text));
17917            }
17918            let sql = format!(
17919                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
17920                quote_ident(&table.primary_key),
17921                base_table(&target.table),
17922                clauses.join(" AND ")
17923            );
17924            let mut statement = self
17925                .conn
17926                .prepare(&sql)
17927                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17928            let rows = statement
17929                .query_map(rusqlite::params_from_iter(params), |row| {
17930                    let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
17931                    render_row_id_json(Some(&value))
17932                        .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
17933                })
17934                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17935            let ids = by_table.entry(target.table.clone()).or_default();
17936            for row in rows {
17937                ids.insert(
17938                    row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
17939                );
17940            }
17941        }
17942        Ok(by_table)
17943    }
17944
17945    fn local_purge_values_match(
17946        target: &CompiledLocalDataPurgeTarget,
17947        values: &Map<String, Value>,
17948    ) -> bool {
17949        target.selectors.iter().all(|(column, allowed)| {
17950            values
17951                .get(column)
17952                .and_then(Value::as_str)
17953                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
17954        })
17955    }
17956
17957    /// Apply one host-authorized local security purge. The host MUST gate the
17958    /// corresponding subscriptions first; this primitive owns local SQLite
17959    /// cleanup and never grants/revokes server authority by itself.
17960    pub fn purge_local_data(
17961        &mut self,
17962        input: &LocalDataPurgeInput,
17963    ) -> Result<LocalDataPurgeResult, String> {
17964        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
17965        let meta_key = format!("localPurge:{}", input.purge_id);
17966        let applied_plan = self
17967            .conn
17968            .query_row(
17969                "SELECT value FROM _syncular_meta WHERE key = ?1",
17970                rusqlite::params![meta_key],
17971                |row| row.get::<_, String>(0),
17972            )
17973            .optional()
17974            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17975        if let Some(applied_plan) = applied_plan {
17976            if applied_plan != canonical_plan {
17977                return Err(format!(
17978                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
17979                    input.purge_id
17980                ));
17981            }
17982            return Ok(LocalDataPurgeResult {
17983                already_applied: true,
17984                purged_rows: 0,
17985                dropped_commits: 0,
17986            });
17987        }
17988
17989        // RFC 0005 D7: the container file cannot be a purge target (its name is
17990        // not in the running schema), so purge drops it as a fixed step, before
17991        // any mirror row is touched and unconditionally.
17992        let _ = self.drop_previous_version();
17993
17994        let prior_outbox = self.outbox.clone();
17995        let prior_failed = self.failed_commits.clone();
17996        let prior_rejections = self.rejections.clone();
17997        let prior_conflicts = self.conflicts.clone();
17998        let prior_overlay_dirty = self.overlay_dirty.snapshot();
17999        self.begin_observation("syncular_local_purge")?;
18000        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
18001            let row_ids = self.local_purge_base_row_ids(&targets)?;
18002            let retained_dropped = self.drop_failed_commits(|table, values| {
18003                targets.iter().any(|target| {
18004                    target.table == table && Self::local_purge_values_match(target, values)
18005                })
18006            })?;
18007            let doomed = self
18008                .outbox
18009                .iter()
18010                .filter(|commit| {
18011                    commit.ops.iter().any(|operation| {
18012                        let matching_targets = targets
18013                            .iter()
18014                            .filter(|target| target.table == operation.table)
18015                            .collect::<Vec<_>>();
18016                        if matching_targets.is_empty() {
18017                            return false;
18018                        }
18019                        if row_ids
18020                            .get(&operation.table)
18021                            .is_some_and(|ids| ids.contains(&operation.row_id))
18022                        {
18023                            return true;
18024                        }
18025                        operation.values.as_ref().is_some_and(|values| {
18026                            matching_targets
18027                                .iter()
18028                                .any(|target| Self::local_purge_values_match(target, values))
18029                        })
18030                    })
18031                })
18032                .cloned()
18033                .collect::<Vec<_>>();
18034            let doomed_ids = doomed
18035                .iter()
18036                .map(|commit| commit.client_commit_id.clone())
18037                .collect::<BTreeSet<_>>();
18038            let mut batch = ChangeAccumulator::default();
18039            for commit in &retained_dropped {
18040                batch.conflicts = true;
18041                batch.rejections = true;
18042                batch.outcomes = true;
18043                for operation in &commit.ops {
18044                    batch.table(&operation.table);
18045                }
18046            }
18047            let mut rejections = Vec::new();
18048            for commit in &doomed {
18049                for operation in &commit.ops {
18050                    batch.table(&operation.table);
18051                }
18052                let results = commit
18053                    .ops
18054                    .iter()
18055                    .enumerate()
18056                    .map(|(op_index, operation)| {
18057                        let rejection = RejectionRecord {
18058                            client_commit_id: commit.client_commit_id.clone(),
18059                            op_index: op_index as i32,
18060                            code: "client.local_data_purged".to_owned(),
18061                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
18062                            retryable: false,
18063                            details: None,
18064                            operation: Some(CommitOperation::from(operation)),
18065                        };
18066                        rejections.push(rejection.clone());
18067                        CommitOperationOutcome::Error { rejection }
18068                    })
18069                    .collect::<Vec<_>>();
18070                self.persist_commit_outcome(
18071                    &commit.client_commit_id,
18072                    CommitOutcomeStatus::Rejected,
18073                    &results,
18074                    Some(&commit.ops),
18075                )?;
18076                self.delete_outbox_persisted(&commit.client_commit_id)?;
18077            }
18078            if !doomed.is_empty() {
18079                self.outbox
18080                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
18081                self.rejections.extend(rejections);
18082                self.prune_commit_outcomes()?;
18083                batch.status = true;
18084                batch.rejections = true;
18085                batch.outcomes = true;
18086            }
18087
18088            let mut purged_rows = 0usize;
18089            for (table, ids) in &row_ids {
18090                if ids.is_empty() {
18091                    continue;
18092                }
18093                batch.table(table);
18094                for row_id in ids {
18095                    self.delete_base_row(table, row_id)?;
18096                    purged_rows += 1;
18097                }
18098            }
18099            self.rebuild_overlay_if_dirty()?;
18100            self.delete_unreferenced_cached_blobs()?;
18101            self.conn
18102                .execute(
18103                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
18104                    rusqlite::params![meta_key, canonical_plan],
18105                )
18106                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18107            Ok((
18108                batch,
18109                LocalDataPurgeResult {
18110                    already_applied: false,
18111                    purged_rows,
18112                    dropped_commits: doomed.len(),
18113                },
18114            ))
18115        })();
18116        let (batch, result) = match applied {
18117            Ok(value) => value,
18118            Err(error) => {
18119                self.rollback_observation("syncular_local_purge");
18120                self.outbox = prior_outbox;
18121                self.failed_commits = prior_failed;
18122                self.rejections = prior_rejections;
18123                self.conflicts = prior_conflicts;
18124                self.overlay_dirty.restore(prior_overlay_dirty.clone());
18125                return Err(error);
18126            }
18127        };
18128        if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
18129            self.rollback_observation("syncular_local_purge");
18130            self.outbox = prior_outbox;
18131            self.failed_commits = prior_failed;
18132            self.rejections = prior_rejections;
18133            self.conflicts = prior_conflicts;
18134            self.overlay_dirty.restore(prior_overlay_dirty.clone());
18135            return Err(error);
18136        }
18137        self.cancel_sync_round();
18138        Ok(result)
18139    }
18140
18141    /// Application-authorized recovery of the replicated projection. Unlike a
18142    /// local security purge, this retains the entire outbox, device identity,
18143    /// lease, outcomes, subscription registrations, and protected bookkeeping.
18144    /// The projection reset, subscription rewind, optimistic replay, and
18145    /// idempotency marker share one savepoint for interruption safety.
18146    pub fn rebootstrap_local_data(
18147        &mut self,
18148        input: &LocalDataRebootstrapInput,
18149    ) -> Result<LocalDataRebootstrapResult, String> {
18150        if input.rebootstrap_id.is_empty()
18151            || input.rebootstrap_id.len() > 128
18152            || !is_local_operation_code_like(&input.rebootstrap_id)
18153        {
18154            return Err(
18155                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
18156                    .to_owned(),
18157            );
18158        }
18159        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
18160        if let Some(persisted) = self.get_meta_strict(&meta_key)? {
18161            let (retained_commits, reset_subscriptions) =
18162                decode_local_rebootstrap_receipt(&persisted)?;
18163            return Ok(LocalDataRebootstrapResult {
18164                already_applied: true,
18165                retained_commits,
18166                reset_subscriptions,
18167            });
18168        }
18169        if self.stopped || self.schema_floor.is_some() {
18170            return Err(
18171                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
18172                    .to_owned(),
18173            );
18174        }
18175
18176        let retained_commits = self.outbox.len();
18177        let reset_subscriptions = self.subs.len();
18178        let prior_subs = self.subs.clone();
18179        let prior_upgrading = self.upgrading;
18180        let prior_stopped = self.stopped;
18181        let prior_schema_floor = self.schema_floor.clone();
18182        let prior_overlay_dirty = self.overlay_dirty.snapshot();
18183        let prior_sync_needed = self.sync_needed;
18184        let prior_sync_intents = self.sync_intent_queue.clone();
18185        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
18186
18187        let prior_active_round = self.active_round;
18188        self.begin_observation("syncular_local_rebootstrap")?;
18189        let mut batch = ChangeAccumulator::default();
18190        let applied = (|| -> Result<(), String> {
18191            self.run_schema_reset_observed(&mut batch, false)?;
18192            self.conn
18193                .execute(
18194                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
18195                    rusqlite::params![meta_key, receipt],
18196                )
18197                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18198            self.sync_needed = true;
18199            self.sync_intent_queue.push_back(SyncIntent::Interactive);
18200            batch.status = true;
18201            Ok(())
18202        })();
18203        if let Err(error) = applied {
18204            self.rollback_observation("syncular_local_rebootstrap");
18205            self.active_round = prior_active_round;
18206            self.subs = prior_subs;
18207            self.upgrading = prior_upgrading;
18208            self.stopped = prior_stopped;
18209            self.schema_floor = prior_schema_floor;
18210            self.overlay_dirty.restore(prior_overlay_dirty.clone());
18211            self.sync_needed = prior_sync_needed;
18212            self.sync_intent_queue = prior_sync_intents;
18213            return Err(error);
18214        }
18215        if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
18216            self.rollback_observation("syncular_local_rebootstrap");
18217            self.active_round = prior_active_round;
18218            self.subs = prior_subs;
18219            self.upgrading = prior_upgrading;
18220            self.stopped = prior_stopped;
18221            self.schema_floor = prior_schema_floor;
18222            self.overlay_dirty.restore(prior_overlay_dirty.clone());
18223            self.sync_needed = prior_sync_needed;
18224            self.sync_intent_queue = prior_sync_intents;
18225            return Err(error);
18226        }
18227
18228        Ok(LocalDataRebootstrapResult {
18229            already_applied: false,
18230            retained_commits,
18231            reset_subscriptions,
18232        })
18233    }
18234
18235    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
18236
18237    /// Delete base rows matching the effective scopes; `Err(())` = no local
18238    /// scope-column mapping for a key (the fail-closed case).
18239    fn purge_scope_rows(
18240        &mut self,
18241        table_name: &str,
18242        effective: &[(String, Vec<String>)],
18243    ) -> Result<(), ()> {
18244        if effective.is_empty() {
18245            return Ok(());
18246        }
18247        let table = self.schema.table(table_name).ok_or(())?.clone();
18248        let mut clauses = Vec::new();
18249        let mut params: Vec<SqlValue> = Vec::new();
18250        for (variable, values) in effective {
18251            let column = table.scope_column(variable).ok_or(())?;
18252            let placeholders: Vec<String> = values
18253                .iter()
18254                .map(|v| {
18255                    params.push(SqlValue::Text(v.clone()));
18256                    "?".to_owned()
18257                })
18258                .collect();
18259            clauses.push(format!(
18260                "{} IN ({})",
18261                quote_ident(column),
18262                placeholders.join(", ")
18263            ));
18264        }
18265        let sql = format!(
18266            "DELETE FROM {} WHERE {}",
18267            base_table(table_name),
18268            clauses.join(" AND ")
18269        );
18270        self.overlay_dirty.table(table_name);
18271        self.conn
18272            .execute(&sql, rusqlite::params_from_iter(&params))
18273            .map_err(|_| ())?;
18274        self.conn
18275            .execute(
18276                &format!(
18277                    "DELETE FROM {} WHERE {}",
18278                    visible_table(table_name),
18279                    clauses.join(" AND ")
18280                ),
18281                rusqlite::params_from_iter(&params),
18282            )
18283            .map_err(|_| ())?;
18284        Ok(())
18285    }
18286
18287    /// §3.3: drop pending commits whose upserts provably land in the
18288    /// revoked effective scopes — whole-commit, never per-operation.
18289    fn drop_doomed_outbox(
18290        &mut self,
18291        table_name: &str,
18292        effective: &[(String, Vec<String>)],
18293    ) -> Result<bool, String> {
18294        if effective.is_empty() {
18295            return Ok(false);
18296        }
18297        let Some(table) = self.schema.table(table_name).cloned() else {
18298            return Ok(false);
18299        };
18300        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
18301        for (variable, values) in effective {
18302            match table.scope_column(variable) {
18303                Some(column) => mappings.push((column, values)),
18304                None => return Ok(false), // not provable without a mapping
18305            }
18306        }
18307        let doomed: Vec<OutboxCommit> = self
18308            .outbox
18309            .iter()
18310            .filter(|commit| {
18311                commit.ops.iter().any(|op| {
18312                    op.upsert
18313                        && op.table == table_name
18314                        && op.values.as_ref().is_some_and(|values| {
18315                            mappings
18316                                .iter()
18317                                .all(|(column, allowed)| match values.get(*column) {
18318                                    Some(Value::String(s)) => allowed.contains(s),
18319                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
18320                                    Some(Value::Bool(b)) => allowed.contains(&b.to_string()),
18321                                    _ => false,
18322                                })
18323                        })
18324                })
18325            })
18326            .cloned()
18327            .collect();
18328        if doomed.is_empty() {
18329            return Ok(false);
18330        }
18331        let mut rejections = Vec::new();
18332        for commit in &doomed {
18333            let results = commit
18334                .ops
18335                .iter()
18336                .enumerate()
18337                .map(|(op_index, operation)| {
18338                    let rejection = RejectionRecord {
18339                        client_commit_id: commit.client_commit_id.clone(),
18340                        op_index: op_index as i32,
18341                        code: "sync.scope_revoked".to_owned(),
18342                        message: "the commit was dropped because its effective scope was revoked"
18343                            .to_owned(),
18344                        retryable: false,
18345                        details: None,
18346                        operation: Some(CommitOperation::from(operation)),
18347                    };
18348                    rejections.push(rejection.clone());
18349                    CommitOperationOutcome::Error { rejection }
18350                })
18351                .collect::<Vec<_>>();
18352            self.persist_commit_outcome(
18353                &commit.client_commit_id,
18354                CommitOutcomeStatus::Rejected,
18355                &results,
18356                Some(&commit.ops),
18357            )?;
18358            self.delete_outbox_persisted(&commit.client_commit_id)?;
18359        }
18360        self.prune_commit_outcomes()?;
18361        let doomed_ids = doomed
18362            .iter()
18363            .map(|commit| commit.client_commit_id.as_str())
18364            .collect::<BTreeSet<_>>();
18365        self.outbox
18366            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
18367        self.rejections.extend(rejections);
18368        Ok(true)
18369    }
18370
18371    // -- blobs (§5.9) ----------------------------------------------------------------
18372
18373    /// §5.9.7: hash bytes into the content address, cache them, queue the
18374    /// upload (flushed before the next push, B4). Returns the canonical
18375    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
18376    /// column value.
18377    pub fn upload_blob(
18378        &mut self,
18379        bytes: &[u8],
18380        media_type: Option<String>,
18381        name: Option<String>,
18382    ) -> Result<Value, String> {
18383        let blob_id = blob_id_for(bytes);
18384        let now = self.clock_now_ms();
18385        let tx = self
18386            .conn
18387            .transaction()
18388            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18389        tx.execute(
18390            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
18391             ON CONFLICT(blob_id) DO NOTHING",
18392            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
18393        )
18394        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18395        tx.execute(
18396            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
18397            rusqlite::params![blob_id, media_type, now],
18398        )
18399        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18400        tx.commit()
18401            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18402        self.enforce_blob_cache_cap();
18403        let mut obj = Map::new();
18404        obj.insert("blobId".to_owned(), Value::from(blob_id));
18405        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
18406        if let Some(mt) = media_type {
18407            obj.insert("mediaType".to_owned(), Value::from(mt));
18408        }
18409        if let Some(n) = name {
18410            obj.insert("name".to_owned(), Value::from(n));
18411        }
18412        Ok(Value::Object(obj))
18413    }
18414
18415    /// §5.9.7: resolve an owned blob body without driver encoding. A
18416    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
18417    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
18418    pub fn fetch_blob_bytes(
18419        &mut self,
18420        transport: &mut dyn Transport,
18421        blob_id_or_ref: &str,
18422    ) -> Result<FetchedBlob, (String, String)> {
18423        let simple = |m: String| ("client.failed".to_owned(), m);
18424        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
18425            blob_id_or_ref.to_owned()
18426        } else {
18427            let value: Value = serde_json::from_str(blob_id_or_ref)
18428                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
18429            value
18430                .get("blobId")
18431                .and_then(Value::as_str)
18432                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
18433                .to_owned()
18434        };
18435        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
18436            return Ok(cached);
18437        }
18438        if !self.transport_enabled {
18439            return Err(("sync.offline".into(), "transport gate is closed".into()));
18440        }
18441        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
18442        // blob.not_found) verbatim so the harness can assert on it. The
18443        // authorized endpoint serves bytes inline OR (always-issue, presign
18444        // configured) a signed url the client fetches directly — no host auth,
18445        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
18446        #[cfg(feature = "bench-internals")]
18447        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
18448        let bytes = match transport
18449            .blob_download(&blob_id)
18450            .map_err(|e| (e.code, e.message))?
18451        {
18452            BlobDownload::Bytes(bytes) => bytes,
18453            BlobDownload::Url {
18454                url,
18455                url_expires_at_ms,
18456            } => {
18457                // §5.9.5: MUST NOT start a fetch at/past expiry.
18458                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
18459                    return Err((
18460                        "sync.segment_expired".to_owned(),
18461                        format!(
18462                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
18463                        ),
18464                    ));
18465                }
18466                transport
18467                    .fetch_blob_url(&url)
18468                    .map_err(|e| (e.code, e.message))?
18469            }
18470        };
18471        #[cfg(feature = "bench-internals")]
18472        drop(download_phase);
18473        #[cfg(feature = "bench-internals")]
18474        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
18475        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
18476        if blob_id_for(&bytes) != blob_id {
18477            return Err(simple(format!(
18478                "blob content address mismatch for {blob_id}"
18479            )));
18480        }
18481        #[cfg(feature = "bench-internals")]
18482        drop(validate_phase);
18483        #[cfg(feature = "bench-internals")]
18484        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
18485        let now = self.clock_now_ms();
18486        self.conn
18487            .execute(
18488                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
18489                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
18490            )
18491            .map_err(|e| simple(e.to_string()))?;
18492        self.conn
18493            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
18494            .map_err(|e| simple(e.to_string()))?;
18495        self.enforce_blob_cache_cap();
18496        #[cfg(feature = "bench-internals")]
18497        drop(insert_phase);
18498        self.get_cached_blob_bytes(&blob_id)
18499            .map_err(simple)?
18500            .ok_or_else(|| simple("blob cache write failed".to_owned()))
18501    }
18502
18503    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
18504        #[cfg(feature = "bench-internals")]
18505        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
18506        let mut stmt = self
18507            .conn
18508            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
18509            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18510        let mut rows = stmt
18511            .query(rusqlite::params![blob_id])
18512            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18513        if let Some(row) = rows
18514            .next()
18515            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
18516        {
18517            let bytes: Vec<u8> = row
18518                .get(0)
18519                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18520            let byte_length: i64 = row
18521                .get(1)
18522                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18523            let media_type: Option<String> = row
18524                .get(2)
18525                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18526            return Ok(Some(FetchedBlob {
18527                blob_id: blob_id.to_owned(),
18528                byte_length,
18529                bytes,
18530                media_type,
18531            }));
18532        }
18533        Ok(None)
18534    }
18535
18536    /// §5.9.7 B4: upload every queued blob before push.
18537    fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
18538        let pending: Vec<(String, Option<String>)> = {
18539            let mut stmt = self
18540                .conn
18541                .prepare(
18542                    "SELECT blob_id, media_type FROM (
18543                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
18544                      UNION ALL
18545                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
18546                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
18547                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
18548                    ) ORDER BY created_at_ms, blob_id",
18549                )
18550                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18551            let rows = stmt
18552                .query_map([], |row| {
18553                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
18554                })
18555                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18556            rows.collect::<Result<_, _>>()
18557                .map_err(|error| match error {
18558                    rusqlite::Error::InvalidColumnType(..)
18559                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
18560                        "sync.local_corrupt",
18561                        "Pending blob upload metadata is invalid",
18562                    ),
18563                    _ => TransportError::new("client.failed", error.to_string()),
18564                })?
18565        };
18566        let mut uploads = Vec::new();
18567        for (blob_id, media_type) in pending {
18568            let bytes = (|| {
18569                let stored: Option<(SqlValue, SqlValue)> = self
18570                    .conn
18571                    .query_row(
18572                        "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
18573                        rusqlite::params![blob_id],
18574                        |row| Ok((row.get(0)?, row.get(1)?)),
18575                    )
18576                    .optional()
18577                    .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18578                let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
18579                    return Err(TransportError::new(
18580                        "sync.local_corrupt",
18581                        "Pending blob upload body is missing or corrupt",
18582                    ));
18583                };
18584                if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
18585                    return Err(TransportError::new(
18586                        "sync.local_corrupt",
18587                        "Pending blob upload body is missing or corrupt",
18588                    ));
18589                }
18590                Ok(bytes)
18591            })();
18592            let failed = bytes.is_err();
18593            uploads.push(crate::round::PendingUpload {
18594                id: blob_id,
18595                bytes,
18596                media_type,
18597            });
18598            if failed {
18599                break;
18600            }
18601        }
18602        Ok(uploads)
18603    }
18604
18605    #[cfg(test)]
18606    fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
18607        for upload in self.pending_blob_uploads()? {
18608            self.upload_one(
18609                transport,
18610                &upload.id,
18611                &upload.bytes?,
18612                upload.media_type.as_deref(),
18613            )?;
18614            self.conn
18615                .execute(
18616                    "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
18617                    rusqlite::params![upload.id],
18618                )
18619                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18620        }
18621        Ok(())
18622    }
18623
18624    /// §5.9.3: upload one blob, preferring the presigned direct-to-storage
18625    /// grant when the transport supports it, else streaming through the direct
18626    /// host-authenticated endpoint (capability, not fallback). A `Url` grant
18627    /// PUTs direct with no host auth; on a grant PUT failure the client streams
18628    /// through the direct endpoint — a *different, host-authenticated
18629    /// capability*, not a fall-through of the grant's authority.
18630    #[cfg(test)]
18631    fn upload_one(
18632        &self,
18633        transport: &mut dyn Transport,
18634        blob_id: &str,
18635        bytes: &[u8],
18636        media_type: Option<&str>,
18637    ) -> Result<(), TransportError> {
18638        crate::round::upload_one(transport, blob_id, bytes, media_type, self.now_ms)
18639    }
18640
18641    fn visible_blob_ids_sql(&self) -> String {
18642        let mut selects = Vec::new();
18643        for table in &self.schema.tables {
18644            for column in table
18645                .columns
18646                .iter()
18647                .filter(|column| column.ty == ColumnType::BlobRef)
18648            {
18649                let identifier = quote_ident(&column.name);
18650                selects.push(format!(
18651                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
18652                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
18653                     AND json_type({identifier}, '$.blobId') = 'text'",
18654                    quote_ident(&table.name),
18655                ));
18656            }
18657        }
18658        if selects.is_empty() {
18659            "SELECT NULL AS blob_id WHERE 0".to_owned()
18660        } else {
18661            selects.join(" UNION ")
18662        }
18663    }
18664
18665    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
18666    /// staged uploads pin bodies directly; no derived refcount is stored.
18667    fn enforce_blob_cache_cap(&self) {
18668        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
18669            return;
18670        };
18671        let mut total: i64 = self
18672            .conn
18673            .query_row(
18674                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
18675                [],
18676                |row| row.get(0),
18677            )
18678            .unwrap_or(0);
18679        if total <= max_bytes {
18680            return;
18681        }
18682        let query = format!(
18683            "SELECT blob_id, byte_length FROM _syncular_blobs
18684             WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
18685               AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
18686               AND blob_id NOT IN ({})
18687             ORDER BY created_at_ms ASC, blob_id ASC",
18688            self.visible_blob_ids_sql(),
18689        );
18690        let candidates: Vec<(String, i64)> = {
18691            let Ok(mut stmt) = self.conn.prepare(&query) else {
18692                return;
18693            };
18694            let Ok(rows) = stmt.query_map([], |row| {
18695                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
18696            }) else {
18697                return;
18698            };
18699            rows.filter_map(Result::ok).collect()
18700        };
18701        for (blob_id, byte_length) in candidates {
18702            if total <= max_bytes {
18703                break;
18704            }
18705            let _ = self.conn.execute(
18706                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
18707                rusqlite::params![blob_id],
18708            );
18709            total -= byte_length;
18710        }
18711    }
18712
18713    fn delete_unreferenced_cached_blobs(&mut self) -> Result<(), String> {
18714        #[cfg(feature = "bench-internals")]
18715        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
18716        if !self.schema_has_blobs() {
18717            return Ok(());
18718        }
18719        self.rebuild_overlay_if_dirty()?;
18720        self.conn
18721            .execute(
18722                &format!(
18723                    "DELETE FROM _syncular_blobs
18724                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
18725                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
18726                   AND blob_id NOT IN ({})",
18727                    self.visible_blob_ids_sql(),
18728                ),
18729                [],
18730            )
18731            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18732        Ok(())
18733    }
18734
18735    // -- local row storage ----------------------------------------------------------
18736
18737    /// The cached per-table primary-key upsert SQL for `full_table` (see
18738    /// the `insert_sql` field: built once, reused per row).
18739    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
18740        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
18741            return sql.clone();
18742        }
18743        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
18744        columns.push(quote_ident("_syncular_version"));
18745        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
18746        let primary_key = quote_ident(&table.primary_key);
18747        let updates = columns
18748            .iter()
18749            .filter(|column| **column != primary_key)
18750            .map(|column| format!("{column}=excluded.{column}"))
18751            .collect::<Vec<_>>()
18752            .join(", ");
18753        let sql = format!(
18754            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
18755            columns.join(", "),
18756            placeholders.join(", ")
18757        );
18758        self.insert_sql
18759            .borrow_mut()
18760            .insert(full_table.to_owned(), sql.clone());
18761        sql
18762    }
18763
18764    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
18765        self.overlay_dirty.table(table_name);
18766        self.write_row(&base_table(table_name), table_name, row, version)
18767    }
18768
18769    fn write_row(
18770        &self,
18771        full_table: &str,
18772        table_name: &str,
18773        row: &Row,
18774        version: i64,
18775    ) -> Result<(), String> {
18776        #[cfg(feature = "bench-internals")]
18777        let _phase = self.benchmark_phases.start(Phase::RowWrite);
18778        let table = self
18779            .schema
18780            .table(table_name)
18781            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
18782        let sql = self.insert_row_sql(full_table, table);
18783        let mut stmt = self
18784            .conn
18785            .prepare_cached(&sql)
18786            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18787        let params = row
18788            .iter()
18789            .map(RowParam::Cell)
18790            .chain(std::iter::once(RowParam::Version(version)));
18791        stmt.execute(rusqlite::params_from_iter(params))
18792            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18793        Ok(())
18794    }
18795
18796    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
18797        let table = self
18798            .schema
18799            .table(table_name)
18800            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
18801        self.overlay_dirty.table(table_name);
18802        let sql = format!(
18803            "DELETE FROM {} WHERE {}",
18804            base_table(table_name),
18805            row_id_predicate(table)
18806        );
18807        let mut stmt = self
18808            .conn
18809            .prepare_cached(&sql)
18810            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18811        stmt.execute(rusqlite::params![row_id])
18812            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18813        Ok(())
18814    }
18815
18816    fn has_acknowledged_rows(&self) -> bool {
18817        self.conn
18818            .query_row(
18819                "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
18820                [],
18821                |row| row.get(0),
18822            )
18823            .expect("read acknowledged intent")
18824    }
18825
18826    fn acknowledged_operations(
18827        &self,
18828        rows: Option<&HashSet<(&str, &str)>>,
18829        tables: Option<&BTreeSet<&str>>,
18830    ) -> Result<Vec<OutboxOp>, String> {
18831        let keys = rows.map(|rows| {
18832            serde_json::to_string(
18833                &rows
18834                    .iter()
18835                    .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
18836                    .collect::<Vec<_>>(),
18837            )
18838            .expect("row keys")
18839        });
18840        let mut statement = self
18841            .conn
18842            .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")
18843            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18844        let rows = statement
18845            .query_map(
18846                rusqlite::params![
18847                    keys,
18848                    tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
18849                ],
18850                |row| {
18851                    #[cfg(test)]
18852                    self.acknowledged_replay_count
18853                        .set(self.acknowledged_replay_count.get() + 1);
18854                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
18855                },
18856            )
18857            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18858        rows.map(|row| {
18859            let (op, intent) =
18860                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18861            let op: CommitOperation =
18862                serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
18863            let table = self
18864                .schema
18865                .table(&op.table)
18866                .ok_or_else(|| "sync.unknown_table".to_owned())?;
18867            let values = if op.op == "upsert" && self.visible_row(table, &op.row_id)?.is_none() {
18868                Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
18869            } else {
18870                op.values
18871            };
18872            Ok(OutboxOp {
18873                table: op.table,
18874                row_id: op.row_id,
18875                upsert: op.op == "upsert",
18876                base_version: op.base_version,
18877                values,
18878            })
18879        })
18880        .collect()
18881    }
18882
18883    fn drop_acknowledged_rows(
18884        &self,
18885        matches: impl Fn(&str, &Map<String, Value>) -> bool,
18886    ) -> Result<Vec<OutboxCommit>, String> {
18887        let mut statement = self
18888            .conn
18889            .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
18890            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18891        let rows = statement
18892            .query_map([], |row| {
18893                Ok((
18894                    row.get::<_, String>(0)?,
18895                    row.get::<_, String>(1)?,
18896                    row.get::<_, String>(2)?,
18897                    row.get::<_, String>(3)?,
18898                ))
18899            })
18900            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18901        let mut doomed = HashSet::new();
18902        for row in rows {
18903            let (id, table, intent, row_id) =
18904                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18905            let values =
18906                serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
18907            let base_matches = self
18908                .schema
18909                .table(&table)
18910                .map(|schema| self.stored_row(schema, &row_id, true))
18911                .transpose()?
18912                .flatten()
18913                .is_some_and(|(base, _)| {
18914                    let schema = self.schema.table(&table).expect("acknowledged table");
18915                    let values = schema
18916                        .columns
18917                        .iter()
18918                        .zip(base.iter())
18919                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
18920                        .collect();
18921                    matches(&table, &values)
18922                });
18923            if matches(&table, &values) || base_matches {
18924                doomed.insert(id);
18925            }
18926        }
18927        let mut dropped = Vec::new();
18928        for id in doomed {
18929            let mut statement = self
18930                .conn
18931                .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
18932                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18933            let operations = statement
18934                .query_map([&id], |row| row.get::<_, String>(0))
18935                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18936            let mut ops = Vec::new();
18937            for operation in operations {
18938                let json = operation
18939                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18940                let op: CommitOperation =
18941                    serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
18942                self.overlay_dirty.table(&op.table);
18943                ops.push(OutboxOp {
18944                    table: op.table,
18945                    row_id: op.row_id,
18946                    upsert: op.op == "upsert",
18947                    base_version: op.base_version,
18948                    values: op.values,
18949                });
18950            }
18951            dropped.push(OutboxCommit {
18952                client_commit_id: id.clone(),
18953                ops,
18954            });
18955            self.conn
18956                .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
18957                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18958        }
18959        Ok(dropped)
18960    }
18961
18962    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
18963    /// the outbox changed since the last rebuild (the no-op sync round).
18964    fn rebuild_overlay_if_dirty(&mut self) -> Result<(), String> {
18965        if self.overlay_dirty.get() {
18966            self.rebuild_overlay()?;
18967        }
18968        Ok(())
18969    }
18970
18971    /// §7.1: local reads see outbox state applied optimistically — rebuild
18972    /// every visible table as (base server state) + (pending outbox replay
18973    /// on top). Optimistic rows carry version `-1`.
18974    fn rebuild_overlay(&mut self) -> Result<(), String> {
18975        #[cfg(feature = "bench-internals")]
18976        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
18977        #[cfg(test)]
18978        self.overlay_rebuild_count
18979            .set(self.overlay_rebuild_count.get() + 1);
18980        let dirty = self.overlay_dirty.snapshot();
18981        let tables: Vec<_> = self
18982            .schema
18983            .tables
18984            .iter()
18985            .filter(|table| {
18986                dirty
18987                    .as_ref()
18988                    .is_none_or(|names| names.is_empty() || names.contains(&table.name))
18989            })
18990            .cloned()
18991            .collect();
18992        let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
18993        self.begin_observation("syncular_overlay")?;
18994        let rebuilt = (|| {
18995            for table in &tables {
18996                for index in &table.fts_indexes {
18997                    self.drop_fts_triggers(index)?;
18998                }
18999                let visible = visible_table(&table.name);
19000                let base = base_table(&table.name);
19001                self.conn
19002                    .execute_batch(&format!(
19003                        "DELETE FROM {visible}; INSERT INTO {visible} SELECT * FROM {base}"
19004                    ))
19005                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19006            }
19007            let acknowledged = self.acknowledged_operations(None, Some(&names))?;
19008            self.apply_outbox_ops(
19009                acknowledged
19010                    .iter()
19011                    .filter(|op| names.contains(op.table.as_str())),
19012            )?;
19013            for (commit, initial) in &self.failed_commits {
19014                for operation in &commit.ops {
19015                    if !names.contains(operation.table.as_str()) || !operation.upsert {
19016                        continue;
19017                    }
19018                    if let Some(table) = self.schema.table(&operation.table) {
19019                        if self.visible_row(table, &operation.row_id)?.is_none()
19020                            && operation.values.as_ref().is_some_and(|values| {
19021                                table
19022                                    .columns
19023                                    .iter()
19024                                    .all(|column| values.contains_key(&column.name))
19025                            })
19026                        {
19027                            let key = serde_json::to_string(&(
19028                                operation.table.clone(),
19029                                operation.row_id.clone(),
19030                            ))
19031                            .expect("row key serialization");
19032                            if let Some(Value::Object(values)) = initial.get(&key) {
19033                                self.apply_outbox_ops(&[OutboxOp {
19034                                    values: Some(values.clone()),
19035                                    ..operation.clone()
19036                                }])?;
19037                            }
19038                        }
19039                    }
19040                }
19041                self.apply_outbox_ops(
19042                    commit
19043                        .ops
19044                        .iter()
19045                        .filter(|op| names.contains(op.table.as_str())),
19046                )?;
19047            }
19048            for commit in &self.outbox {
19049                self.apply_outbox_ops(
19050                    commit
19051                        .ops
19052                        .iter()
19053                        .filter(|op| names.contains(op.table.as_str())),
19054                )?;
19055            }
19056            for table in &tables {
19057                for index in &table.fts_indexes {
19058                    self.rebuild_fts_projection(table, index)?;
19059                    self.create_fts_triggers(table, index)?;
19060                }
19061            }
19062            self.conn
19063                .execute_batch("RELEASE syncular_overlay")
19064                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19065            Ok(())
19066        })();
19067        if rebuilt.is_err() {
19068            self.conn
19069                .execute_batch("ROLLBACK TO syncular_overlay; RELEASE syncular_overlay")
19070                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19071        } else {
19072            self.overlay_dirty.set(false);
19073        }
19074        rebuilt
19075    }
19076
19077    /// §7.1 replay: every operation is applied over the current visible
19078    /// state. An operation that no longer applies (a server row now holds its
19079    /// unique value) stays pending and invisible until a later replay admits
19080    /// it or the server answers its push.
19081    fn apply_outbox_ops<'a>(
19082        &self,
19083        ops: impl IntoIterator<Item = &'a OutboxOp>,
19084    ) -> Result<(), String> {
19085        #[cfg(feature = "bench-internals")]
19086        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
19087        for op in ops {
19088            #[cfg(feature = "bench-internals")]
19089            if let Some(phase) = &mut phase {
19090                phase.unit();
19091            }
19092            if op.upsert {
19093                if let (Some(table), Some(values)) = (
19094                    self.schema
19095                        .table(&op.table)
19096                        .filter(|table| table.indexes.iter().any(|index| index.unique)),
19097                    op.values.as_ref(),
19098                ) {
19099                    let mut intended: Map<String, Value> = self
19100                        .visible_row(table, &op.row_id)?
19101                        .map(|(row, _)| {
19102                            table
19103                                .columns
19104                                .iter()
19105                                .zip(&row)
19106                                .map(|(column, value)| {
19107                                    (column.name.clone(), column_value_to_json(value))
19108                                })
19109                                .collect()
19110                        })
19111                        .unwrap_or_default();
19112                    intended.extend(values.clone());
19113                    if !self.unique_conflicts(table, &intended, false)?.is_empty() {
19114                        continue;
19115                    }
19116                }
19117            }
19118            self.apply_outbox_op(op)?;
19119        }
19120        Ok(())
19121    }
19122
19123    fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
19124        let Some(table) = self.schema.table(&op.table) else {
19125            return Ok(());
19126        };
19127        let visible = visible_table(&table.name);
19128        if !op.upsert {
19129            let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
19130            self.conn
19131                .prepare_cached(&sql)
19132                .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
19133                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19134            return Ok(());
19135        }
19136        let Some(values) = op.values.as_ref() else {
19137            return Ok(());
19138        };
19139        // §7.1: the overlay applies the operation's PRESENT columns
19140        // over the current local row. A partial operation over an
19141        // absent local row leaves it absent — the server answers it
19142        // with sync.row_deleted or sync.row_missing (§6.2).
19143        let full = table
19144            .columns
19145            .iter()
19146            .all(|column| values.contains_key(&column.name));
19147        let local = self.visible_row(table, &op.row_id)?;
19148        if local.is_none() && !full {
19149            return Ok(());
19150        }
19151        let mut row: Row = Vec::with_capacity(table.columns.len());
19152        for (index, column) in table.columns.iter().enumerate() {
19153            let incoming = match values.get(&column.name) {
19154                Some(raw) => json_to_column_value(
19155                    if raw.get("$bytes").is_some()
19156                        && table
19157                            .encrypted_columns
19158                            .iter()
19159                            .any(|encrypted| encrypted.index == index)
19160                    {
19161                        &table.wire_columns[index]
19162                    } else {
19163                        column
19164                    },
19165                    Some(raw),
19166                ),
19167                None => Ok(local
19168                    .as_ref()
19169                    .and_then(|(values, _)| values.get(index).cloned())
19170                    .flatten()),
19171            };
19172            row.push(incoming.map_err(|_| {
19173                "sync.local_corrupt: persisted optimistic row value is invalid".to_owned()
19174            })?);
19175        }
19176        let version = local.as_ref().map_or(-1, |(_, version)| *version);
19177        self.write_row(&visible, &table.name, &row, version)
19178            .map_err(|error| {
19179                if error.starts_with("UNIQUE constraint failed:") {
19180                    "sync.constraint_violation: local write violates a unique constraint".to_owned()
19181                } else {
19182                    error
19183                }
19184            })
19185    }
19186
19187    /// The visible (optimistic) row's values plus version, or `None` when the
19188    /// row is absent from the overlay.
19189    fn visible_row(&self, table: &TableSchema, row_id: &str) -> Result<Option<(Row, i64)>, String> {
19190        self.stored_row(table, row_id, false)
19191    }
19192
19193    fn stored_row(
19194        &self,
19195        table: &TableSchema,
19196        row_id: &str,
19197        base: bool,
19198    ) -> Result<Option<(Row, i64)>, String> {
19199        let sql = format!(
19200            "SELECT * FROM {} WHERE {}",
19201            if base {
19202                base_table(&table.name)
19203            } else {
19204                visible_table(&table.name)
19205            },
19206            row_id_predicate(table)
19207        );
19208        let mut stmt = self
19209            .conn
19210            .prepare_cached(&sql)
19211            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19212        let mut rows = stmt
19213            .query(rusqlite::params![row_id])
19214            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19215        let Some(row) = rows
19216            .next()
19217            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
19218        else {
19219            return Ok(None);
19220        };
19221        let mut values = Vec::with_capacity(table.columns.len());
19222        for (index, column) in table.columns.iter().enumerate() {
19223            let raw = row
19224                .get_ref(index)
19225                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19226            if let rusqlite::types::ValueRef::Blob(bytes) = raw {
19227                if table
19228                    .encrypted_columns
19229                    .iter()
19230                    .any(|encrypted| encrypted.index == index)
19231                {
19232                    values.push(Some(ColumnValue::Bytes(bytes.to_vec())));
19233                    continue;
19234                }
19235            }
19236            let json = sql_ref_to_json(column, raw);
19237            values.push(json_to_column_value(column, Some(&json)).map_err(|_| {
19238                "sync.local_corrupt: persisted local row value is invalid".to_owned()
19239            })?);
19240        }
19241        let version: i64 = row
19242            .get(table.columns.len())
19243            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19244        Ok(Some((values, version)))
19245    }
19246
19247    // -- realtime (§8) ---------------------------------------------------------------
19248
19249    /// §8.8: the configured realtime policy.
19250    #[must_use]
19251    pub fn realtime_policy(&self) -> RealtimePolicy {
19252        self.realtime_policy
19253    }
19254
19255    /// §8.8: the explicit realtime availability state.
19256    #[must_use]
19257    pub fn realtime_state(&self) -> RealtimeState {
19258        self.realtime_state
19259    }
19260
19261    /// §8.8: set the realtime policy. Refused while the socket is connected:
19262    /// a policy change never silently re-routes an ownership state.
19263    pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
19264        if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
19265            return Err(
19266                "sync.invalid_request: disconnect realtime before changing the realtime policy"
19267                    .to_owned(),
19268            );
19269        }
19270        self.realtime_policy = policy;
19271        match policy {
19272            RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
19273            RealtimePolicy::Required | RealtimePolicy::Optional
19274                if self.realtime_state == RealtimeState::Disabled =>
19275            {
19276                self.set_realtime_state(RealtimeState::Disconnected, None);
19277            }
19278            RealtimePolicy::Required | RealtimePolicy::Optional => {}
19279        }
19280        Ok(())
19281    }
19282
19283    /// Record an availability transition, keeping the reason code bounded and
19284    /// code-like (§7.6) and clearing it where it cannot apply.
19285    fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
19286        self.realtime_state = state;
19287        self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
19288        if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
19289            self.realtime_reason_code = None;
19290            self.realtime_retry_delay_ms = None;
19291        }
19292    }
19293
19294    #[must_use]
19295    fn realtime_connected(&self) -> bool {
19296        self.realtime_state == RealtimeState::Connected
19297    }
19298
19299    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
19300        if !self.transport_enabled {
19301            return Err("sync.offline: transport gate is closed".into());
19302        }
19303        if self.realtime_policy == RealtimePolicy::Off {
19304            return Err(
19305                "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
19306            );
19307        }
19308        if self.realtime_connected() {
19309            return Ok(());
19310        }
19311        self.realtime_state = RealtimeState::Connecting;
19312        match transport.realtime_connect_for_client(&self.client_id) {
19313            Ok(()) => {
19314                self.set_realtime_state(RealtimeState::Connected, None);
19315                Ok(())
19316            }
19317            Err(error) => {
19318                self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
19319                Err(format!("{}: {}", error.code, error.message))
19320            }
19321        }
19322    }
19323
19324    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
19325        self.cancel_sync_round();
19326        // A deliberately disconnected or policy-disabled client has nothing
19327        // to release; a `lost` socket still does (the host owns the handle).
19328        if self.realtime_state == RealtimeState::Disconnected
19329            || self.realtime_state == RealtimeState::Disabled
19330        {
19331            return;
19332        }
19333        let _ = transport.realtime_close();
19334        self.set_realtime_state(
19335            if self.realtime_policy == RealtimePolicy::Off {
19336                RealtimeState::Disabled
19337            } else {
19338                RealtimeState::Disconnected
19339            },
19340            None,
19341        );
19342        self.presence.clear(); // §8.6.1: presence is per-connection
19343    }
19344
19345    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
19346    /// document for `scope_key`. Requires a live socket; the document is
19347    /// ephemeral (lost on disconnect). Authorization is the connection's
19348    /// registration (§8.6.3) — an unheld key is rejected loudly by the
19349    /// server with `presence.forbidden`.
19350    pub fn set_presence(
19351        &mut self,
19352        transport: &mut dyn Transport,
19353        scope_key: &str,
19354        doc: Option<&Value>,
19355    ) -> Result<(), String> {
19356        if !self.transport_enabled {
19357            return Err("sync.offline: transport gate is closed".into());
19358        }
19359        if !self.realtime_connected() {
19360            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
19361        }
19362        let text = encode_presence_publish(scope_key, doc);
19363        transport
19364            .realtime_send(&text)
19365            .map_err(|e| format!("{}: {}", e.code, e.message))
19366    }
19367
19368    /// §8.6: the peers currently present on a scope key (ephemeral).
19369    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
19370        self.presence
19371            .get(scope_key)
19372            .map(|peers| peers.values().cloned().collect())
19373            .unwrap_or_default()
19374    }
19375
19376    /// §8.6 apply an inbound presence fanout to the local map.
19377    fn apply_presence(
19378        &mut self,
19379        scope_key: String,
19380        kind: Option<PresenceKind>,
19381        actor_id: Option<String>,
19382        client_id: Option<String>,
19383        doc: Option<Value>,
19384        error: Option<String>,
19385    ) {
19386        // The publisher-directed error variant is out-of-band; nothing to
19387        // record in the peer map.
19388        if error.is_some() {
19389            return;
19390        }
19391        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
19392            return;
19393        };
19394        let peer_key = format!("{actor_id} {client_id}");
19395        match kind {
19396            PresenceKind::Leave => {
19397                if let Some(peers) = self.presence.get_mut(&scope_key) {
19398                    peers.remove(&peer_key);
19399                    if peers.is_empty() {
19400                        self.presence.remove(&scope_key);
19401                    }
19402                }
19403            }
19404            _ => {
19405                let doc = match doc {
19406                    Some(Value::Object(_)) => doc.unwrap(),
19407                    _ => return,
19408                };
19409                self.presence.entry(scope_key).or_default().insert(
19410                    peer_key,
19411                    PresencePeer {
19412                        actor_id,
19413                        client_id,
19414                        doc,
19415                    },
19416                );
19417            }
19418        }
19419    }
19420
19421    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
19422    pub fn on_realtime_text(&mut self, text: &str) {
19423        if self.stopped || !self.transport_enabled || self.security_preflight {
19424            return;
19425        }
19426        match parse_control(text) {
19427            Ok(ControlMessage::Hello { requires_sync, .. }) => {
19428                if requires_sync {
19429                    // §8.1: pull before trusting the socket for continuity.
19430                    self.set_sync_needed(true, true);
19431                }
19432            }
19433            Ok(ControlMessage::Presence {
19434                scope_key,
19435                kind,
19436                actor_id,
19437                client_id,
19438                doc,
19439                error,
19440                ..
19441            }) => {
19442                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
19443            }
19444            Ok(ControlMessage::Wake { .. }) => {
19445                // §8.3: any wake-up means "run a pull soon", never data.
19446                self.set_sync_needed(true, true);
19447            }
19448            _ => {}
19449        }
19450    }
19451
19452    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
19453    /// a pull response per section; an unapplied delta is a wake-up.
19454    /// Mailbox hosts send durable acknowledgements on their I/O executor.
19455    pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
19456        let mut transport = crate::round::DownloadResults::default();
19457        self.on_realtime_binary(&mut transport, bytes);
19458        transport.controls
19459    }
19460
19461    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
19462        if self.stopped || !self.transport_enabled || self.security_preflight {
19463            return;
19464        }
19465        #[cfg(feature = "bench-internals")]
19466        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
19467        let message = match decode_message(bytes) {
19468            Ok(m) if m.msg_kind == MsgKind::Response => m,
19469            _ => {
19470                self.set_sync_needed(true, true);
19471                return;
19472            }
19473        };
19474        #[cfg(feature = "bench-internals")]
19475        drop(decode_phase);
19476        let mut frames = message.frames.into_iter();
19477        let mut applied_cursor: Option<i64> = None;
19478        let mut any_covered = false;
19479        let mut dropped = false;
19480        while let Some(frame) = frames.next() {
19481            let Frame::SubStart {
19482                id,
19483                status,
19484                effective_scopes,
19485                ..
19486            } = frame
19487            else {
19488                continue;
19489            };
19490            let mut body = Vec::new();
19491            let mut next_cursor: Option<i64> = None;
19492            for inner in frames.by_ref() {
19493                match inner {
19494                    Frame::SubEnd {
19495                        next_cursor: nc, ..
19496                    } => {
19497                        next_cursor = Some(nc);
19498                        break;
19499                    }
19500                    Frame::Unknown { .. } => {}
19501                    other => body.push(other),
19502                }
19503            }
19504            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
19505                dropped = true;
19506                continue;
19507            };
19508            let sub = &self.subs[sub_index];
19509            // §8.2: only locally active, not mid-bootstrap subscriptions
19510            // apply; skipped sections are repaired by the next pull.
19511            if status != SubStatus::Active
19512                || sub.state != SubState::Active
19513                || sub.bootstrap_state.is_some()
19514                || !sub.synced_once
19515            {
19516                dropped = true;
19517                continue;
19518            }
19519            if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
19520                // Idempotent redelivery of an already-covered window.
19521                any_covered = true;
19522                continue;
19523            }
19524            let previous_effective = self.subs[sub_index].effective.clone();
19525            let previous_cursor = self.subs[sub_index].cursor;
19526            self.subs[sub_index].effective = Some(effective_scopes);
19527            let mut failed = false;
19528            for inner in body {
19529                match inner {
19530                    Frame::Commit {
19531                        tables,
19532                        changes,
19533                        commit_seq,
19534                        ..
19535                    } => {
19536                        if self
19537                            .apply_commit_frame(&tables, &changes, Some(commit_seq))
19538                            .is_err()
19539                        {
19540                            failed = true;
19541                            break;
19542                        }
19543                    }
19544                    _ => {
19545                        failed = true;
19546                        break;
19547                    }
19548                }
19549            }
19550            let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
19551                self.apply_sub_end(sub_index, cursor, None)
19552                    .ok()
19553                    .map(|()| cursor)
19554            });
19555            let Some(next_cursor) = committed else {
19556                self.subs[sub_index].effective = previous_effective;
19557                self.subs[sub_index].cursor = previous_cursor;
19558                dropped = true;
19559                continue;
19560            };
19561            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
19562        }
19563        if let Some(cursor) = applied_cursor {
19564            // §8.2 ack point: the highest applied SUB_END.nextCursor.
19565            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
19566            let _ = transport.realtime_send(&ack);
19567        } else if !any_covered || dropped {
19568            // §8.2: a delta not applied at all is treated as a wake-up.
19569            self.set_sync_needed(true, true);
19570        }
19571    }
19572
19573    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
19574    /// cursor across active, non-bootstrapping subscriptions that have
19575    /// synced at least once. No such subscription, no ack.
19576    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
19577        if !self.realtime_connected() {
19578            return;
19579        }
19580        let floor = self
19581            .subs
19582            .iter()
19583            .filter(|s| {
19584                s.state == SubState::Active
19585                    && s.bootstrap_state.is_none()
19586                    && s.synced_once
19587                    && s.cursor >= 0
19588            })
19589            .map(|s| s.cursor)
19590            .min();
19591        if let Some(cursor) = floor {
19592            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
19593            let _ = transport.realtime_send(&ack);
19594        }
19595    }
19596}
19597
19598#[cfg(test)]
19599mod previous_version_wiring_tests {
19600    use super::*;
19601    use serde_json::{json, Value};
19602
19603    fn previous_version_schema(version: i32) -> Value {
19604        json!({
19605            "version": version,
19606            "tables": [{
19607                "name": "tasks",
19608                "primaryKey": "id",
19609                "columns": [
19610                    { "name": "id", "type": "string", "nullable": false },
19611                    { "name": "note", "type": "string", "nullable": true }
19612                ],
19613                "scopes": []
19614            }]
19615        })
19616    }
19617
19618    fn previous_version_limits() -> crate::api::ClientLimits {
19619        crate::api::ClientLimits {
19620            previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
19621                enabled: true,
19622                ..Default::default()
19623            }),
19624            ..Default::default()
19625        }
19626    }
19627
19628    /// The configuration the live client carries, for a direct capture call.
19629    fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
19630        previous_version_limits()
19631            .previous_version_context
19632            .expect("configured")
19633    }
19634
19635    /// Capture the container the way a bump would, so a read test starts from a
19636    /// container this client's own state will then decide about.
19637    fn recapture_previous_version(client: &SyncClient) {
19638        let replica_path = client
19639            .previous_version_replica_path()
19640            .expect("file-backed replica");
19641        let outcome = crate::previous_version::capture_previous_version_from_replica(
19642            &client.conn,
19643            &replica_path,
19644            2,
19645            2,
19646            &previous_version_config(),
19647            1,
19648        )
19649        .expect("capture");
19650        assert!(matches!(
19651            outcome,
19652            crate::previous_version::CaptureOutcome::Captured(_)
19653        ));
19654    }
19655
19656    fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
19657        Subscription {
19658            id: "wiring-sub".to_owned(),
19659            table: "tasks".to_owned(),
19660            requested: Vec::new(),
19661            params: None,
19662            cursor,
19663            bootstrap_state: bootstrap_state.map(str::to_owned),
19664            state,
19665            reason_code: None,
19666            effective: None,
19667            synced_once: false,
19668        }
19669    }
19670
19671    #[test]
19672    fn schema_downgrade_refuses_all_opens_and_recreate_without_mutation() {
19673        let path =
19674            std::env::temp_dir().join(format!("syncular-downgrade-{}.db", uuid::Uuid::new_v4()));
19675        let path_str = path.to_str().expect("temp path");
19676        let schema = previous_version_schema(3);
19677        let mut older = previous_version_schema(2);
19678        older["tables"][0]["columns"] =
19679            json!([{ "name": "id", "type": "string", "nullable": false }]);
19680        let mut client = SyncClient::open_path(
19681            "downgrade".into(),
19682            &previous_version_schema(1),
19683            previous_version_limits(),
19684            path_str,
19685        )
19686        .expect("fresh");
19687        client
19688            .mutate(vec![Mutation::Upsert {
19689                table: "tasks".into(),
19690                values: Map::from_iter([
19691                    ("id".into(), json!("accepted")),
19692                    ("note".into(), json!("old")),
19693                ]),
19694                base_version: None,
19695            }])
19696            .expect("v1 write");
19697        client
19698            .recreate_with_schema(&schema)
19699            .expect("upgrade captures context");
19700        let commit = client
19701            .mutate(vec![Mutation::Upsert {
19702                table: "tasks".into(),
19703                values: Map::from_iter([
19704                    ("id".into(), json!("queued")),
19705                    ("note".into(), json!("v3-only")),
19706                ]),
19707                base_version: None,
19708            }])
19709            .expect("queued v3 write");
19710        let changes = client.conn.total_changes();
19711        let outbox = client.pending_commit_ids();
19712        assert!(outbox.contains(&commit));
19713        let descriptor = client.get_meta("localSchemaDescriptor");
19714        let container = format!(
19715            "{path_str}{}",
19716            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
19717        );
19718        let context = std::fs::read(&container).expect("captured context");
19719        assert!(client
19720            .recreate_with_schema(&older)
19721            .expect_err("refuse recreate")
19722            .starts_with("client.schema_downgrade:"));
19723        assert_eq!(client.schema.version, 3);
19724        assert_eq!(client.conn.total_changes(), changes);
19725        assert_eq!(client.pending_commit_ids(), outbox);
19726        assert_eq!(client.get_meta("localSchemaDescriptor"), descriptor);
19727        assert_eq!(
19728            client
19729                .conn
19730                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
19731                    row.get::<_, String>(0)
19732                })
19733                .expect("queued row"),
19734            "v3-only"
19735        );
19736        assert_eq!(std::fs::read(&container).expect("context remains"), context);
19737        drop(client);
19738        let before = std::fs::read(&path).expect("replica bytes");
19739        // open_path uses DELETE journaling. The identity open must refuse
19740        // before its normal WAL configuration changes this database.
19741        for entry in 0..3 {
19742            let error = match entry {
19743                0 => SyncClient::open_path(
19744                    "downgrade".into(),
19745                    &older,
19746                    previous_version_limits(),
19747                    path_str,
19748                ),
19749                1 => SyncClient::open_path_with_identity(
19750                    None,
19751                    &older,
19752                    previous_version_limits(),
19753                    path_str,
19754                ),
19755                _ => SyncClient::with_connection(
19756                    "downgrade".into(),
19757                    &older,
19758                    previous_version_limits(),
19759                    Connection::open(&path).expect("connection"),
19760                ),
19761            }
19762            .err()
19763            .expect("refuse open");
19764            assert!(error.starts_with("client.schema_downgrade:"));
19765            let conn = Connection::open(&path).expect("inspect");
19766            assert_eq!(
19767                conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
19768                    .expect("journal mode"),
19769                "delete"
19770            );
19771            drop(conn);
19772            assert_eq!(std::fs::read(&path).expect("replica unchanged"), before);
19773            assert_eq!(
19774                std::fs::read(&container).expect("context unchanged"),
19775                context
19776            );
19777        }
19778        let reopened = SyncClient::open_path(
19779            "downgrade".into(),
19780            &schema,
19781            previous_version_limits(),
19782            path_str,
19783        )
19784        .expect("compatible reopen");
19785        assert_eq!(reopened.pending_commit_ids(), outbox);
19786        assert_eq!(
19787            reopened
19788                .conn
19789                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
19790                    row.get::<_, String>(0)
19791                })
19792                .expect("queued row remains"),
19793            "v3-only"
19794        );
19795        reopened.delete_meta(LOCAL_SCHEMA_VERSION_KEY);
19796        reopened.delete_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
19797        drop(reopened);
19798        let legacy = SyncClient::open_path(
19799            "downgrade".into(),
19800            &schema,
19801            previous_version_limits(),
19802            path_str,
19803        )
19804        .expect("legacy no-marker reopen");
19805        assert_eq!(legacy.pending_commit_ids(), outbox);
19806        assert!(!legacy.upgrading());
19807        assert_eq!(
19808            legacy
19809                .conn
19810                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
19811                    row.get::<_, String>(0)
19812                })
19813                .expect("legacy row remains"),
19814            "v3-only"
19815        );
19816        drop(legacy);
19817        std::fs::remove_file(path).expect("remove replica");
19818        let _ = std::fs::remove_file(container);
19819    }
19820
19821    #[test]
19822    fn schema_marker_errors_refuse_open_and_recreate_without_mutation() {
19823        for marker in [
19824            "''",
19825            "'bad'",
19826            "'0'",
19827            "'-1'",
19828            "'2.5'",
19829            "'03'",
19830            "'3\n'",
19831            "' 3'",
19832            "'+3'",
19833            "'3e0'",
19834            "'2147483648'",
19835            "NULL",
19836            "X'33'",
19837        ] {
19838            let conn = Connection::open_in_memory().expect("connection");
19839            conn.execute_batch(&format!("CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value); INSERT INTO _syncular_meta VALUES ('localSchemaVersion', {marker});")).expect("invalid marker fixture");
19840            let error = SyncClient::with_connection(
19841                "marker".into(),
19842                &previous_version_schema(3),
19843                ClientLimits::default(),
19844                conn,
19845            )
19846            .err()
19847            .expect("refuse marker");
19848            assert!(
19849                error.starts_with("sync.local_corrupt:"),
19850                "{marker}: {error}"
19851            );
19852        }
19853        for fault in [
19854            "UPDATE _syncular_meta SET value = 'bad' WHERE key = 'localSchemaVersion'",
19855            "ALTER TABLE _syncular_meta RENAME COLUMN value TO unreadable_value",
19856        ] {
19857            let mut client = SyncClient::new(
19858                "marker".into(),
19859                &previous_version_schema(3),
19860                ClientLimits::default(),
19861            )
19862            .expect("fresh");
19863            let commit = client
19864                .mutate(vec![Mutation::Upsert {
19865                    table: "tasks".into(),
19866                    values: Map::from_iter([("id".into(), json!("queued"))]),
19867                    base_version: None,
19868                }])
19869                .expect("queued write");
19870            client.conn.execute_batch(fault).expect("marker fault");
19871            let changes = client.conn.total_changes();
19872            let error = client
19873                .recreate_with_schema(&previous_version_schema(4))
19874                .expect_err("refuse corrupt marker");
19875            assert!(error.starts_with("sync.local_corrupt:"));
19876            assert_eq!(client.conn.total_changes(), changes);
19877            assert_eq!(client.schema.version, 3);
19878            assert_eq!(client.pending_commit_ids(), vec![commit]);
19879        }
19880        for setup in [
19881            "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY)",
19882            "CREATE VIEW _syncular_meta AS SELECT 'localSchemaVersion' AS key, '3' AS value",
19883        ] {
19884            let path =
19885                std::env::temp_dir().join(format!("syncular-marker-{}.db", uuid::Uuid::new_v4()));
19886            let conn = Connection::open(&path).expect("connection");
19887            conn.execute_batch(setup)
19888                .expect("unreadable metadata fixture");
19889            drop(conn);
19890            let before = std::fs::read(&path).expect("before");
19891            let error = SyncClient::open_path_with_identity(
19892                None,
19893                &previous_version_schema(3),
19894                ClientLimits::default(),
19895                path.to_str().expect("path"),
19896            )
19897            .err()
19898            .expect("refuse unreadable marker");
19899            assert!(error.starts_with("sync.local_corrupt:"));
19900            assert_eq!(std::fs::read(&path).expect("after"), before);
19901            std::fs::remove_file(path).expect("remove fixture");
19902        }
19903    }
19904
19905    std::thread_local! {
19906        /// The pending concurrent upgrade a busy handler commits, so a lock
19907        /// collision deterministically becomes the marker race under test.
19908        static CONCURRENT_UPGRADE: RefCell<Option<Connection>> =
19909            const { RefCell::new(None) };
19910    }
19911
19912    fn commit_concurrent_upgrade(attempts: i32) -> bool {
19913        CONCURRENT_UPGRADE.with(|slot| {
19914            if let Some(banner) = slot.borrow_mut().take() {
19915                banner
19916                    .execute_batch(
19917                        "UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion'; COMMIT",
19918                    )
19919                    .expect("commit the concurrent upgrade");
19920            }
19921        });
19922        attempts < 10
19923    }
19924
19925    fn journal_mode(conn: &Connection) -> String {
19926        conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
19927            .expect("journal mode")
19928    }
19929
19930    fn temp_replica(label: &str) -> std::path::PathBuf {
19931        std::env::temp_dir().join(format!("syncular-{label}-{}.db", uuid::Uuid::new_v4()))
19932    }
19933
19934    #[test]
19935    fn file_identity_constructor_requires_the_returned_wal_mode() {
19936        for path in [":memory:", ""] {
19937            let error = SyncClient::open_path_with_identity(
19938                None,
19939                &previous_version_schema(1),
19940                ClientLimits::default(),
19941                path,
19942            )
19943            .err()
19944            .expect("temporary SQLite databases cannot enter WAL mode");
19945            assert_eq!(
19946                error,
19947                "sync.invalid_request: file-backed client requires WAL journal mode"
19948            );
19949        }
19950        let path = temp_replica("confirmed-wal");
19951        let client = SyncClient::open_path_with_identity(
19952            None,
19953            &previous_version_schema(1),
19954            ClientLimits::default(),
19955            path.to_str().unwrap(),
19956        )
19957        .unwrap();
19958        assert_eq!(journal_mode(&client.conn), "wal");
19959        drop(client);
19960        std::fs::remove_file(path).unwrap();
19961    }
19962
19963    #[test]
19964    fn non_positive_generated_schema_versions_are_refused_before_storage() {
19965        for version in [json!(0), json!(-1), json!(i32::MIN), json!(2147483648i64)] {
19966            let mut schema = previous_version_schema(1);
19967            schema["version"] = version.clone();
19968            let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
19969                .err()
19970                .expect("refuse an out-of-range generated version");
19971            assert_eq!(
19972                error,
19973                "sync.invalid_request: generated schema version must be an integer in 1..=2147483647"
19974            );
19975            // The file-backed seam refuses before the replica gains a table.
19976            let path = temp_replica("bad-version");
19977            let path_str = path.to_str().expect("path");
19978            for identity in [false, true] {
19979                let error = if identity {
19980                    SyncClient::open_path_with_identity(
19981                        None,
19982                        &schema,
19983                        ClientLimits::default(),
19984                        path_str,
19985                    )
19986                } else {
19987                    SyncClient::open_path(
19988                        "bad-version".into(),
19989                        &schema,
19990                        ClientLimits::default(),
19991                        path_str,
19992                    )
19993                }
19994                .err()
19995                .expect("refuse before storage");
19996                assert!(
19997                    error.starts_with("sync.invalid_request:"),
19998                    "{version}: {error}"
19999                );
20000                assert!(
20001                    !path.exists(),
20002                    "{version}: a refused constructor creates no replica file"
20003                );
20004            }
20005        }
20006        // A missing version is the same request error, not a parse failure.
20007        let mut schema = previous_version_schema(1);
20008        schema
20009            .as_object_mut()
20010            .expect("schema object")
20011            .remove("version");
20012        let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
20013            .err()
20014            .expect("refuse a missing version");
20015        assert!(error.starts_with("sync.invalid_request:"), "{error}");
20016    }
20017
20018    #[test]
20019    fn a_duplicated_schema_marker_is_refused_without_a_reset() {
20020        let path = temp_replica("duplicate-marker");
20021        let path_str = path.to_str().expect("path");
20022        {
20023            // A hand-built/legacy metadata table without its primary key, with
20024            // the older marker first: `query_row` would pick it and reset.
20025            let conn = Connection::open(path_str).expect("fixture");
20026            conn.execute_batch(
20027                "CREATE TABLE _syncular_meta(key TEXT, value);
20028                 INSERT INTO _syncular_meta VALUES
20029                   ('localSchemaVersion', '1'),
20030                   ('localSchemaVersion', '2'),
20031                   ('clientId', 'duplicate');",
20032            )
20033            .expect("duplicate marker fixture");
20034        }
20035        let error = SyncClient::open_path_with_identity(
20036            None,
20037            &previous_version_schema(3),
20038            ClientLimits::default(),
20039            path_str,
20040        )
20041        .err()
20042        .expect("refuse a duplicated marker");
20043        assert!(error.starts_with("sync.local_corrupt:"), "{error}");
20044        let probe = Connection::open(path_str).expect("probe");
20045        assert_eq!(
20046            probe
20047                .query_row(
20048                    "SELECT COUNT(*) FROM _syncular_meta WHERE key = 'localSchemaVersion'",
20049                    [],
20050                    |row| row.get::<_, i64>(0),
20051                )
20052                .expect("marker rows"),
20053            2,
20054            "the duplicated rows are left for the operator, not collapsed"
20055        );
20056        assert_eq!(
20057            probe
20058                .query_row(
20059                    "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'",
20060                    [],
20061                    |row| row.get::<_, i64>(0),
20062                )
20063                .expect("synced tables"),
20064            0,
20065            "a duplicated marker never drives the reset that recreates v3 tables"
20066        );
20067        assert_eq!(
20068            journal_mode(&probe),
20069            "delete",
20070            "the refusal leaves the journal mode alone"
20071        );
20072        drop(probe);
20073        std::fs::remove_file(path).expect("remove replica");
20074    }
20075
20076    #[test]
20077    fn a_refused_open_does_not_persist_wal_or_mutate_the_replica() {
20078        let path = temp_replica("refused-wal");
20079        let path_str = path.to_str().expect("path");
20080        // `open_path` leaves the default journal mode, so a refused open is
20081        // the only thing that could switch this replica to WAL.
20082        drop(
20083            SyncClient::open_path(
20084                "owner".into(),
20085                &previous_version_schema(2),
20086                ClientLimits::default(),
20087                path_str,
20088            )
20089            .expect("v2 replica"),
20090        );
20091        let before = std::fs::read(&path).expect("before");
20092        let error = SyncClient::open_path_with_identity(
20093            Some("intruder".into()),
20094            &previous_version_schema(2),
20095            ClientLimits::default(),
20096            path_str,
20097        )
20098        .err()
20099        .expect("refuse the identity mismatch");
20100        assert!(error.starts_with("client.identity_mismatch:"), "{error}");
20101        let probe = Connection::open(path_str).expect("probe");
20102        assert_eq!(
20103            journal_mode(&probe),
20104            "delete",
20105            "a refused open must not persist WAL"
20106        );
20107        assert_eq!(std::fs::read(&path).expect("after"), before);
20108        assert_eq!(
20109            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
20110            Some("2")
20111        );
20112        drop(probe);
20113        std::fs::remove_file(path).expect("remove replica");
20114    }
20115
20116    #[test]
20117    fn a_concurrent_marker_upgrade_refuses_a_stale_open() {
20118        let path = temp_replica("marker-race");
20119        let path_str = path.to_str().expect("path");
20120        drop(
20121            SyncClient::open_path_with_identity(
20122                Some("stale".into()),
20123                &previous_version_schema(1),
20124                ClientLimits::default(),
20125                path_str,
20126            )
20127            .expect("v1 replica"),
20128        );
20129        // Another process holds the write lock with an uncommitted upgrade; a
20130        // stale open reads the v1 marker first and only then meets the lock.
20131        // The first busy therefore lands after this replica became v2.
20132        let banner = Connection::open(path_str).expect("banner");
20133        banner
20134            .execute_batch(
20135                "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
20136            )
20137            .expect("pending upgrade");
20138        CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
20139        let conn = Connection::open(path_str).expect("stale connection");
20140        conn.busy_handler(Some(commit_concurrent_upgrade))
20141            .expect("busy handler");
20142        let error = SyncClient::with_connection(
20143            "stale".into(),
20144            &previous_version_schema(1),
20145            ClientLimits::default(),
20146            conn,
20147        )
20148        .err()
20149        .expect("a concurrent upgrade must refuse the stale open");
20150        assert!(error.starts_with("client.schema_downgrade:"), "{error}");
20151        let probe = Connection::open(path_str).expect("probe");
20152        assert_eq!(
20153            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
20154            Some("2"),
20155            "the concurrent upgrade survives"
20156        );
20157        drop(probe);
20158        std::fs::remove_file(path).expect("remove replica");
20159    }
20160
20161    #[test]
20162    fn a_concurrent_marker_upgrade_refuses_a_stale_recreate() {
20163        let path = temp_replica("recreate-race");
20164        let path_str = path.to_str().expect("path");
20165        let mut client = SyncClient::open_path_with_identity(
20166            Some("stale".into()),
20167            &previous_version_schema(1),
20168            ClientLimits::default(),
20169            path_str,
20170        )
20171        .expect("v1 replica");
20172        client
20173            .mutate(vec![Mutation::Upsert {
20174                table: "tasks".into(),
20175                values: Map::from_iter([("id".into(), json!("queued"))]),
20176                base_version: None,
20177            }])
20178            .expect("queued write");
20179        let commit = client.pending_commit_ids();
20180        let banner = Connection::open(path_str).expect("banner");
20181        banner
20182            .execute_batch(
20183                "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
20184            )
20185            .expect("pending upgrade");
20186        CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
20187        client
20188            .conn
20189            .busy_handler(Some(commit_concurrent_upgrade))
20190            .expect("busy handler");
20191        let error = client
20192            .recreate_with_schema(&previous_version_schema(1))
20193            .expect_err("a concurrent upgrade must refuse the stale recreate");
20194        assert!(error.starts_with("client.schema_downgrade:"), "{error}");
20195        assert_eq!(client.schema.version, 1, "the compiled schema is restored");
20196        assert_eq!(
20197            client.pending_commit_ids(),
20198            commit,
20199            "the outbox is preserved"
20200        );
20201        let probe = Connection::open(path_str).expect("probe");
20202        assert_eq!(
20203            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
20204            Some("2")
20205        );
20206        drop(probe);
20207        drop(client);
20208        std::fs::remove_file(path).expect("remove replica");
20209    }
20210
20211    #[test]
20212    fn failed_schema_record_writes_roll_back_recreation() {
20213        for key in [
20214            LOCAL_SCHEMA_VERSION_KEY,
20215            crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY,
20216        ] {
20217            let mut client = SyncClient::new(
20218                "schema-record".into(),
20219                &previous_version_schema(1),
20220                ClientLimits::default(),
20221            )
20222            .expect("v1");
20223            let marker = client.get_meta(LOCAL_SCHEMA_VERSION_KEY);
20224            let descriptor = client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
20225            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");
20226            client
20227                .recreate_with_schema(&previous_version_schema(2))
20228                .expect_err("both record writes must propagate failure");
20229            assert_eq!(client.schema.version, 1);
20230            assert_eq!(client.get_meta(LOCAL_SCHEMA_VERSION_KEY), marker);
20231            assert_eq!(
20232                client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY),
20233                descriptor
20234            );
20235            client
20236                .conn
20237                .execute_batch("DROP TRIGGER refuse_schema_record")
20238                .expect("remove fault");
20239            client
20240                .recreate_with_schema(&previous_version_schema(2))
20241                .expect("retry");
20242            assert_eq!(client.schema.version, 2);
20243        }
20244    }
20245
20246    #[test]
20247    fn a_failed_outer_commit_restores_every_reset_side_effect() {
20248        let path = temp_replica("outer-commit");
20249        let path_str = path.to_str().expect("path");
20250        // A rollback-journal replica (no WAL), so a second read connection can
20251        // hold SHARED across the recreated transaction's COMMIT.
20252        let conn = Connection::open(path_str).expect("replica connection");
20253        let mut client = SyncClient::with_connection(
20254            "outer-commit".into(),
20255            &previous_version_schema(1),
20256            previous_version_limits(),
20257            conn,
20258        )
20259        .expect("v1 replica");
20260        assert_eq!(journal_mode(&client.conn), "delete");
20261        let commit = client
20262            .mutate(vec![Mutation::Upsert {
20263                table: "tasks".into(),
20264                values: Map::from_iter([
20265                    ("id".into(), json!("queued")),
20266                    ("note".into(), json!("dropped-by-v2")),
20267                ]),
20268                base_version: None,
20269            }])
20270            .expect("queued write");
20271        client.drain_change_batches();
20272        // v2 drops `note`, so the queued upsert cannot re-encode.
20273        let mut dropped = previous_version_schema(2);
20274        dropped["tables"][0]["columns"] =
20275            json!([{ "name": "id", "type": "string", "nullable": false }]);
20276        // Prior database and memory state: the refused COMMIT must restore all
20277        // of it.
20278        let marker = meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY);
20279        let revision = client.local_revision();
20280        let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
20281        let columns: i64 = client
20282            .conn
20283            .query_row(
20284                "SELECT count(*) FROM pragma_table_info('tasks')",
20285                [],
20286                |row| row.get(0),
20287            )
20288            .expect("column count");
20289        let rebuilds = client.overlay_rebuild_count.get();
20290        let prior_subs = client.subs.len();
20291        let prior_changes = client.change_queue.len();
20292        let prior_intents = client.sync_intent_queue.len();
20293        let prior_last_change = client.last_change.clone();
20294        let prior_overlay_dirty = client.overlay_dirty.get();
20295        let prior_sync_needed = client.sync_needed;
20296        let prior_upgrading = client.upgrading;
20297        let prior_stopped = client.stopped;
20298        let prior_schema_floor = client.schema_floor.clone();
20299        let prior_active_round = client.active_round;
20300        // The reader holds SHARED for the whole attempt: the writer takes
20301        // RESERVED, runs the reset, and fails when COMMIT needs EXCLUSIVE.
20302        let reader = Connection::open(path_str).expect("reader");
20303        reader
20304            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
20305            .expect("reader snapshot");
20306        client
20307            .conn
20308            .busy_timeout(std::time::Duration::ZERO)
20309            .expect("busy timeout");
20310        let error = client
20311            .recreate_with_schema(&dropped)
20312            .expect_err("a blocked COMMIT must refuse the recreation");
20313        assert!(error.starts_with("client.storage_busy:"), "{error}");
20314        assert!(
20315            client.overlay_rebuild_count.get() > rebuilds,
20316            "the reset ran to its overlay rebuild inside the transaction"
20317        );
20318        assert!(client.conn.is_autocommit(), "the transaction is released");
20319        // Database: the rolled-back reset left nothing behind.
20320        assert_eq!(meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY), marker);
20321        assert_eq!(client.local_revision(), revision);
20322        assert_eq!(
20323            client.query("SELECT * FROM tasks", &[]).expect("rows"),
20324            rows
20325        );
20326        assert_eq!(
20327            client
20328                .conn
20329                .query_row(
20330                    "SELECT count(*) FROM pragma_table_info('tasks')",
20331                    [],
20332                    |row| { row.get::<_, i64>(0) }
20333                )
20334                .expect("column count"),
20335            columns,
20336            "the previous table layout survives the rollback"
20337        );
20338        assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
20339        // Memory: every side effect of the successful reset is restored with
20340        // the rolled-back database.
20341        assert_eq!(client.schema.version, 1, "compiled schema");
20342        assert_eq!(client.subs.len(), prior_subs);
20343        assert_eq!(client.change_queue.len(), prior_changes);
20344        assert_eq!(client.sync_intent_queue.len(), prior_intents);
20345        assert_eq!(client.last_change, prior_last_change);
20346        assert_eq!(client.overlay_dirty.get(), prior_overlay_dirty);
20347        assert_eq!(client.sync_needed, prior_sync_needed);
20348        assert_eq!(client.upgrading, prior_upgrading);
20349        assert_eq!(client.stopped, prior_stopped);
20350        assert_eq!(client.schema_floor, prior_schema_floor);
20351        assert_eq!(client.active_round, prior_active_round);
20352        assert!(
20353            client.rejections.is_empty(),
20354            "the dropped-column rejection is rolled back too"
20355        );
20356        // The capture committed in its separate file before the failed replica
20357        // COMMIT. It belongs to v2 and cannot be exposed by the restored v1 core.
20358        let container = crate::previous_version::previous_version_container_path(path_str);
20359        assert!(std::path::Path::new(&container).exists());
20360        // The reader no longer needs its snapshot, so discard can clean the
20361        // replica's audit records along with the stale sibling file.
20362        drop(reader);
20363        let snapshot = client
20364            .previous_version_snapshot(&PreviousVersionReadSpec {
20365                table: "tasks".into(),
20366                ..Default::default()
20367            })
20368            .expect("refuse stale capture");
20369        assert!(!snapshot.available);
20370        assert_eq!(snapshot.current_version, 1);
20371        assert!(!std::path::Path::new(&container).exists());
20372        // The client remains usable: releasing the reader lets the identical
20373        // recreation run to completion, and the reset preserves the
20374        // incompatible intent until the send-time prepass classifies it
20375        // (§7.4.3/§7.4.4: the reset never touches the outbox).
20376        client
20377            .conn
20378            .busy_timeout(std::time::Duration::from_secs(1))
20379            .expect("busy timeout");
20380        client
20381            .recreate_with_schema(&dropped)
20382            .expect("the recreation succeeds once the reader releases");
20383        assert_eq!(client.schema.version, 2);
20384        assert_eq!(
20385            client.pending_commit_ids(),
20386            vec![commit.clone()],
20387            "the reset kept the incompatible commit pending"
20388        );
20389        assert!(
20390            client.rejections.is_empty(),
20391            "no rejection exists before the send-time prepass"
20392        );
20393        // The send boundary classifies and drops it.
20394        client
20395            .prepare_sync_round(false)
20396            .expect("the send prepass runs during round preparation");
20397        assert!(client.pending_commit_ids().is_empty());
20398        assert!(
20399            client
20400                .rejections
20401                .iter()
20402                .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE),
20403            "the dropped-column commit surfaced as a rejection at send time"
20404        );
20405        client.previous_version_discard();
20406        drop(client);
20407        std::fs::remove_file(path).expect("remove replica");
20408    }
20409
20410    #[test]
20411    fn the_send_time_incompatible_drop_is_atomic_and_publishes_once() {
20412        let v1 = previous_version_schema(1);
20413        let mut v2 = previous_version_schema(2);
20414        // v2 drops `note`, so the queued `dropped` upsert cannot re-encode.
20415        v2["tables"][0]["columns"] = json!([{ "name": "id", "type": "string", "nullable": false }]);
20416        let mut client = SyncClient::new("drop".to_string(), &v1, ClientLimits::default()).unwrap();
20417        let dropped = client
20418            .mutate(vec![Mutation::Upsert {
20419                table: "tasks".into(),
20420                values: Map::from_iter([
20421                    ("id".into(), json!("dropped")),
20422                    ("note".into(), json!("gone")),
20423                ]),
20424                base_version: None,
20425            }])
20426            .unwrap();
20427        // A full-row v1 upsert always carries the v1 columns, so the surviving
20428        // commit is a value-free delete (it encodes under any schema that still
20429        // has the table).
20430        let survivor = client
20431            .mutate(vec![Mutation::Delete {
20432                table: "tasks".into(),
20433                row_id: "gone".into(),
20434                base_version: None,
20435            }])
20436            .unwrap();
20437        client
20438            .recreate_with_schema(&v2)
20439            .expect("the reset preserves the outbox");
20440        assert_eq!(
20441            client.pending_commit_ids(),
20442            vec![dropped.clone(), survivor.clone()],
20443            "the reset preserves both commits"
20444        );
20445        assert!(client.rejections.is_empty(), "the reset classifies nothing");
20446        client.drain_change_batches();
20447        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
20448        assert_eq!(rows.len(), 1, "only the dropped upsert has a visible row");
20449
20450        // A fault anywhere in the atomic drop leaves the replica, the outbox,
20451        // the durable outcomes, the visible projection, the revision, and the
20452        // publish surface untouched. Clearing `syncNeeded` first keeps the
20453        // round's own status flip out of the assertion, so any published change
20454        // can only be the drop's.
20455        for fault in [
20456            "outcome-insert",
20457            "outbox-delete",
20458            "overlay-rebuild",
20459            "publish-revision",
20460        ] {
20461            client.set_sync_needed(false, false);
20462            client.drain_change_batches();
20463            let revision = client.local_revision();
20464            let trigger = match fault {
20465                "outcome-insert" => {
20466                    "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20467                }
20468                "outbox-delete" => {
20469                    "CREATE TRIGGER fail_drop BEFORE DELETE ON _syncular_outbox BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20470                }
20471                // The optimized replay removes only the dropped projection, so
20472                // the replay fault belongs on the DELETE; a publish fault lands
20473                // on the revision write that `finish_observation` performs after
20474                // every durable effect already succeeded.
20475                "publish-revision" => {
20476                    "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20477                }
20478                _ => {
20479                    "CREATE TRIGGER fail_drop BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20480                }
20481            };
20482            client.conn.execute_batch(trigger).unwrap();
20483            let outcome = client
20484                .prepare_sync_round(false)
20485                .err()
20486                .unwrap_or_else(|| panic!("{fault}: a fault in the drop must fail the round"));
20487            assert!(
20488                matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "storage.failed"),
20489                "{fault}: {outcome:?}"
20490            );
20491            client.conn.execute_batch("DROP TRIGGER fail_drop").unwrap();
20492            assert!(client.conn.is_autocommit(), "{fault}: no leaked savepoint");
20493            assert_eq!(
20494                client.pending_commit_ids(),
20495                vec![dropped.clone(), survivor.clone()],
20496                "{fault}: the outbox is restored"
20497            );
20498            assert!(
20499                client.rejections.is_empty(),
20500                "{fault}: no in-memory rejection survives"
20501            );
20502            assert!(
20503                client.commit_outcome(&dropped).unwrap().is_none(),
20504                "{fault}: the durable outcome rolled back"
20505            );
20506            assert_eq!(client.local_revision(), revision, "{fault}: revision");
20507            assert_eq!(
20508                client.query("SELECT * FROM tasks", &[]).unwrap(),
20509                rows,
20510                "{fault}: the visible projection is unchanged"
20511            );
20512            assert!(
20513                client.drain_change_batches().is_empty(),
20514                "{fault}: nothing is published"
20515            );
20516        }
20517
20518        // A nested failure leaves the caller's transaction and earlier writes
20519        // intact; only the drop's savepoint is rolled back.
20520        client
20521            .conn
20522            .execute_batch(
20523                "BEGIN; INSERT INTO _syncular_meta(key,value) VALUES ('outerProbe','kept');
20524             CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes
20525             BEGIN SELECT RAISE(ABORT,'nested drop fault'); END;",
20526            )
20527            .unwrap();
20528        assert!(client.drop_incompatible_outbox().is_err());
20529        assert!(!client.conn.is_autocommit());
20530        assert_eq!(client.get_meta("outerProbe").as_deref(), Some("kept"));
20531        assert_eq!(
20532            client.pending_commit_ids(),
20533            vec![dropped.clone(), survivor.clone()]
20534        );
20535        assert!(client.commit_outcome(&dropped).unwrap().is_none());
20536        client
20537            .conn
20538            .execute_batch("DROP TRIGGER fail_drop; COMMIT")
20539            .unwrap();
20540
20541        // The successful drop publishes exactly once and leaves the surviving
20542        // intent queued for the same round. A log epoch is required before a
20543        // round builds push frames at all, so the classification provably runs
20544        // before any operation of the dropped commit is sent.
20545        client.set_meta(LOG_EPOCH_KEY, "epoch");
20546        client.set_sync_needed(false, false);
20547        client.drain_change_batches();
20548        let revision = client.local_revision();
20549        let prepared = client.prepare_sync_round(false).expect("the drop succeeds");
20550        assert_eq!(client.pending_commit_ids(), vec![survivor.clone()]);
20551        assert_eq!(
20552            prepared.meta.pushed_ids,
20553            vec![survivor.clone()],
20554            "the incompatible commit is classified before any operation is sent"
20555        );
20556        let rejection = client
20557            .rejections
20558            .iter()
20559            .find(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE)
20560            .expect("a sync.outbox_incompatible rejection");
20561        assert_eq!(rejection.client_commit_id, dropped);
20562        assert!(
20563            !rejection.retryable,
20564            "a schema-incompatible commit is final"
20565        );
20566        assert_eq!(
20567            client
20568                .commit_outcome(&dropped)
20569                .unwrap()
20570                .expect("a durable rejected outcome")
20571                .status,
20572            CommitOutcomeStatus::Rejected
20573        );
20574        assert_eq!(client.local_revision(), revision + 1, "one revision");
20575        let batches = client.drain_change_batches();
20576        assert_eq!(batches.len(), 1, "one change batch");
20577        assert!(
20578            batches[0].rejections_changed
20579                && batches[0].outcomes_changed
20580                && batches[0].status.is_some(),
20581            "{:?}",
20582            batches[0]
20583        );
20584        assert!(
20585            batches[0].tables.iter().any(|entry| entry.table == "tasks"),
20586            "{:?}",
20587            batches[0]
20588        );
20589        let visible = client.query("SELECT * FROM tasks", &[]).unwrap();
20590        assert!(
20591            visible.is_empty(),
20592            "the dropped projection is undone: {visible:?}"
20593        );
20594
20595        // The observation's outer RELEASE (transaction COMMIT) is guarded too:
20596        // a rollback-journal reader holding SHARED makes it fail with the typed
20597        // busy classification, and releasing the reader lets the retry succeed.
20598        let path = temp_replica("outbox-drop-commit");
20599        let path_str = path.to_str().expect("path");
20600        let conn = Connection::open(path_str).expect("replica connection");
20601        let mut client =
20602            SyncClient::with_connection("drop-commit".into(), &v1, ClientLimits::default(), conn)
20603                .expect("v1 replica");
20604        let dropped = client
20605            .mutate(vec![Mutation::Upsert {
20606                table: "tasks".into(),
20607                values: Map::from_iter([
20608                    ("id".into(), json!("dropped")),
20609                    ("note".into(), json!("gone")),
20610                ]),
20611                base_version: None,
20612            }])
20613            .unwrap();
20614        client
20615            .recreate_with_schema(&v2)
20616            .expect("the reset preserves the outbox");
20617        assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
20618        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
20619        assert_eq!(rows.len(), 1, "the optimistic row is visible");
20620        client.set_sync_needed(false, false);
20621        client.drain_change_batches();
20622        let revision = client.local_revision();
20623        let reader = Connection::open(path_str).expect("reader");
20624        reader
20625            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
20626            .expect("reader snapshot");
20627        client
20628            .conn
20629            .busy_timeout(std::time::Duration::ZERO)
20630            .expect("busy timeout");
20631        let outcome = client
20632            .prepare_sync_round(false)
20633            .err()
20634            .expect("a blocked COMMIT must fail the round");
20635        assert!(
20636            matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "client.storage_busy"),
20637            "{outcome:?}"
20638        );
20639        assert!(client.conn.is_autocommit(), "the savepoint is released");
20640        assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
20641        assert!(client.rejections.is_empty());
20642        assert!(client.commit_outcome(&dropped).unwrap().is_none());
20643        assert_eq!(client.local_revision(), revision);
20644        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
20645        assert!(client.drain_change_batches().is_empty());
20646        // The same round succeeds once the reader releases.
20647        drop(reader);
20648        client
20649            .conn
20650            .busy_timeout(std::time::Duration::from_secs(1))
20651            .expect("busy timeout");
20652        client
20653            .prepare_sync_round(false)
20654            .expect("the drop succeeds after the reader releases");
20655        assert!(client.pending_commit_ids().is_empty());
20656        assert!(client
20657            .rejections
20658            .iter()
20659            .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE));
20660        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
20661        drop(client);
20662        std::fs::remove_file(path).expect("remove replica");
20663    }
20664
20665    #[test]
20666    fn previous_version_read_reasons_come_from_live_client_state() {
20667        use crate::previous_version::{
20668            PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
20669        };
20670        let path = std::env::temp_dir()
20671            .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
20672            .to_string_lossy()
20673            .into_owned();
20674        let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
20675        {
20676            let mut setup = SyncClient::open_path(
20677                "wiring".to_owned(),
20678                &previous_version_schema(1),
20679                previous_version_limits(),
20680                &path,
20681            )
20682            .expect("v1 install");
20683            setup
20684                .mutate(vec![Mutation::Upsert {
20685                    table: "tasks".to_owned(),
20686                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
20687                    base_version: None,
20688                }])
20689                .expect("seed a row");
20690        }
20691        let mut client = SyncClient::open_path(
20692            "wiring".to_owned(),
20693            &previous_version_schema(2),
20694            previous_version_limits(),
20695            &path,
20696        )
20697        .expect("v2 bump captures");
20698        assert!(std::path::Path::new(&container).exists());
20699        let spec = PreviousVersionReadSpec {
20700            table: "tasks".to_owned(),
20701            ..Default::default()
20702        };
20703
20704        // §7.3.5 lease error ⇒ lease-inactive, and the read discards.
20705        recapture_previous_version(&client);
20706        client.lease_state = Some(LeaseState {
20707            lease_id: None,
20708            expires_at_ms: None,
20709            error_code: Some("lease.expired".to_owned()),
20710        });
20711        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20712        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
20713        assert!(!snapshot.available);
20714        assert!(!std::path::Path::new(&container).exists());
20715
20716        // An expired lease uses the client clock (injected, no sleeps).
20717        recapture_previous_version(&client);
20718        client.set_now_ms(1_000);
20719        client.lease_state = Some(LeaseState {
20720            lease_id: Some("lease".to_owned()),
20721            expires_at_ms: Some(1_000),
20722            error_code: None,
20723        });
20724        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20725        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
20726        assert!(!std::path::Path::new(&container).exists());
20727        client.lease_state = None;
20728
20729        // A revoked subscription ⇒ scope-revoked.
20730        recapture_previous_version(&client);
20731        client.subs.push(subscription(SubState::Revoked, 0, None));
20732        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20733        assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
20734        assert!(!std::path::Path::new(&container).exists());
20735        client.subs.clear();
20736
20737        // Coverage completion: every ACTIVE subscription has a cursor and no
20738        // resume token — a still-resuming subscription is not completion.
20739        recapture_previous_version(&client);
20740        client
20741            .subs
20742            .push(subscription(SubState::Active, 0, Some("resume")));
20743        assert!(!client.previous_version_coverage_complete());
20744        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20745        assert!(snapshot.available);
20746        assert_eq!(snapshot.previous_version, Some(2));
20747        assert_eq!(snapshot.rows.len(), 1);
20748
20749        recapture_previous_version(&client);
20750        client.subs[0].bootstrap_state = None;
20751        assert!(client.previous_version_coverage_complete());
20752        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20753        assert_eq!(
20754            snapshot.reason,
20755            Some(PreviousVersionReason::CoverageComplete)
20756        );
20757        assert!(!std::path::Path::new(&container).exists());
20758        client.subs.clear();
20759
20760        // With no active subscription at all, coverage is NOT complete.
20761        assert!(!client.previous_version_coverage_complete());
20762
20763        drop(client);
20764        let _ = std::fs::remove_file(&path);
20765    }
20766
20767    /// RFC 0005 D7 lifetime drops. The read surface already proves the reasons
20768    /// come from live state; this proves the OTHER caller —
20769    /// `previous_version_lifetime_check`, the once-per-sync-round trigger —
20770    /// actually removes the container for lease-inactivity, coverage
20771    /// completion and scope revocation, and leaves it alone while the
20772    /// replacement bootstrap is still resuming.
20773    #[test]
20774    fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
20775        let path = std::env::temp_dir()
20776            .join(format!(
20777                "syncular-prev-lifetime-{}.db",
20778                uuid::Uuid::new_v4()
20779            ))
20780            .to_string_lossy()
20781            .into_owned();
20782        let container = format!(
20783            "{path}{}",
20784            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
20785        );
20786        let exists = || std::path::Path::new(&container).exists();
20787        {
20788            let mut setup = SyncClient::open_path(
20789                "lifetime".to_owned(),
20790                &previous_version_schema(1),
20791                previous_version_limits(),
20792                &path,
20793            )
20794            .expect("v1 install");
20795            setup
20796                .mutate(vec![Mutation::Upsert {
20797                    table: "tasks".to_owned(),
20798                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
20799                    base_version: None,
20800                }])
20801                .expect("seed a row");
20802        }
20803        let mut client = SyncClient::open_path(
20804            "lifetime".to_owned(),
20805            &previous_version_schema(2),
20806            previous_version_limits(),
20807            &path,
20808        )
20809        .expect("v2 bump captures");
20810        // Pin the clock so the 24h TTL never fires in this test (the TTL has
20811        // its own coverage); the capture record's createdAtMs is 1.
20812        client.set_now_ms(1_000);
20813        assert!(exists());
20814
20815        // §7.3.5 lease error ⇒ the lifetime check drops the container.
20816        client.lease_state = Some(LeaseState {
20817            lease_id: None,
20818            expires_at_ms: None,
20819            error_code: Some("lease.expired".to_owned()),
20820        });
20821        client.previous_version_lifetime_check();
20822        assert!(!exists());
20823        client.lease_state = None;
20824
20825        // §7.4.5 coverage completion (every active sub has a cursor and no
20826        // resume token) ⇒ drop.
20827        recapture_previous_version(&client);
20828        client.subs.push(subscription(SubState::Active, 0, None));
20829        client.previous_version_lifetime_check();
20830        assert!(!exists());
20831        client.subs.clear();
20832
20833        // §3.3 scope revocation ⇒ drop.
20834        recapture_previous_version(&client);
20835        client.subs.push(subscription(SubState::Revoked, 0, None));
20836        client.previous_version_lifetime_check();
20837        assert!(!exists());
20838        client.subs.clear();
20839
20840        // Negative control: a still-resuming active subscription is NOT
20841        // coverage completion, so the container survives the same call.
20842        recapture_previous_version(&client);
20843        client
20844            .subs
20845            .push(subscription(SubState::Active, 0, Some("resume")));
20846        client.previous_version_lifetime_check();
20847        assert!(exists());
20848        client.subs.clear();
20849
20850        drop(client);
20851        let _ = std::fs::remove_file(&path);
20852        let _ = std::fs::remove_file(&container);
20853        let _ = std::fs::remove_file(format!("{container}-wal"));
20854        let _ = std::fs::remove_file(format!("{container}-shm"));
20855    }
20856}