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#[derive(Debug, Clone)]
9089struct OutboxOp {
9090    upsert: bool,
9091    table: String,
9092    row_id: String,
9093    base_version: Option<i64>,
9094    /// Schema-agnostic local form (§0): driver JSON values, encoded with
9095    /// the current codec at send time. A key's presence IS the §6.1 sparse
9096    /// presence set — both the overlay and the wire payload derive from it.
9097    values: Option<Map<String, Value>>,
9098}
9099
9100impl From<&OutboxOp> for CommitOperation {
9101    fn from(operation: &OutboxOp) -> Self {
9102        Self {
9103            table: operation.table.clone(),
9104            row_id: operation.row_id.clone(),
9105            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
9106            base_version: operation.base_version,
9107            values: operation.values.clone(),
9108        }
9109    }
9110}
9111
9112#[derive(Debug, Clone)]
9113struct OutboxCommit {
9114    client_commit_id: String,
9115    ops: Vec<OutboxOp>,
9116}
9117
9118/// One commit's `(table, value keys)` per upsert operation — the classification
9119/// shape the §7.4.4 send-time drop and the RFC 0005 D6 audit share, so the two
9120/// can never disagree about which commits cannot re-encode.
9121fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
9122    commit
9123        .ops
9124        .iter()
9125        .filter(|operation| operation.upsert)
9126        .map(|operation| {
9127            (
9128                operation.table.as_str(),
9129                operation
9130                    .values
9131                    .as_ref()
9132                    .map(|values| values.keys().map(String::as_str).collect())
9133                    .unwrap_or_default(),
9134            )
9135        })
9136        .collect()
9137}
9138
9139type FailedCommit = (OutboxCommit, Map<String, Value>);
9140
9141#[derive(Debug, Clone)]
9142struct CompiledLocalDataPurgeTarget {
9143    table: String,
9144    selectors: Vec<(String, Vec<String>)>,
9145}
9146
9147struct StoredCommitOutcomeRow {
9148    sequence: i64,
9149    client_commit_id: String,
9150    status: String,
9151    recorded_at_ms: i64,
9152    results_json: String,
9153    operations_json: Option<String>,
9154    resolution: String,
9155    resolved_at_ms: Option<i64>,
9156    replacement_client_commit_id: Option<String>,
9157}
9158
9159impl StoredCommitOutcomeRow {
9160    fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
9161        Ok(Self {
9162            sequence: row.get(0)?,
9163            client_commit_id: row.get(1)?,
9164            status: row.get(2)?,
9165            recorded_at_ms: row.get(3)?,
9166            results_json: row.get(4)?,
9167            operations_json: row.get(5)?,
9168            resolution: row.get(6)?,
9169            resolved_at_ms: row.get(7)?,
9170            replacement_client_commit_id: row.get(8)?,
9171        })
9172    }
9173}
9174
9175/// Section outcome distinguishing the §5.6 subscription-local fail-closed
9176/// path from a round-aborting failure (§1.4 rule 5).
9177enum SectionError {
9178    FailClosed,
9179    Abort(String, String),
9180}
9181
9182#[derive(Debug)]
9183pub(crate) struct RequestMeta {
9184    pushed_ids: Vec<String>,
9185    /// Subscription id → the request carried `cursor < 0` and no resume
9186    /// token (§5.6 first-page detection: a *fresh* bootstrap).
9187    fresh: Vec<(String, bool)>,
9188    pub(crate) accept: u8,
9189    /// §6.1 splitBatch: outbox commits held back from THIS request because
9190    /// the running operation count reached the push cap — the next round
9191    /// pushes them (`sync_needed` stays set while any remain).
9192    deferred_commits: usize,
9193}
9194
9195#[derive(Default)]
9196struct ChangeAccumulator {
9197    /// None means table-wide; Some is the exact scoped domain.
9198    tables: BTreeMap<String, Option<BTreeSet<String>>>,
9199    windows: BTreeMap<(String, String), BTreeSet<String>>,
9200    status: bool,
9201    conflicts: bool,
9202    rejections: bool,
9203    outcomes: bool,
9204}
9205
9206impl ChangeAccumulator {
9207    fn table(&mut self, table: &str) {
9208        self.tables.insert(table.to_owned(), None);
9209    }
9210
9211    fn scope(&mut self, table: &str, key: String) {
9212        match self.tables.get_mut(table) {
9213            Some(None) => {}
9214            Some(Some(keys)) => {
9215                keys.insert(key);
9216            }
9217            None => {
9218                self.tables
9219                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
9220            }
9221        }
9222    }
9223
9224    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
9225        self.windows
9226            .entry((base_key.to_owned(), table.to_owned()))
9227            .or_default()
9228            .insert(unit.to_owned());
9229    }
9230
9231    fn touched(&self) -> bool {
9232        !self.tables.is_empty()
9233            || !self.windows.is_empty()
9234            || self.status
9235            || self.conflicts
9236            || self.rejections
9237            || self.outcomes
9238    }
9239}
9240
9241/// §6.1: the server caps total operations per request (reference default
9242/// 500) and rejects the whole batch with `sync.too_many_operations`; the
9243/// client "splits and retries". Splitting happens at build time: commits are
9244/// included IN ORDER until the operation budget is spent, the rest wait for
9245/// the next round.
9246const PUSH_OPS_PER_REQUEST: usize = 500;
9247const MAX_LOCAL_PURGE_TARGETS: usize = 64;
9248const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
9249const MAX_LOCAL_PURGE_VALUES: usize = 128;
9250const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
9251pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
9252
9253/// Dirty visible tables; `None` means a whole-replica recovery/reset.
9254struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
9255
9256impl OverlayDirty {
9257    fn clean() -> Self {
9258        Self(RefCell::new(Some(BTreeSet::new())))
9259    }
9260    fn get(&self) -> bool {
9261        self.0
9262            .borrow()
9263            .as_ref()
9264            .is_none_or(|tables| !tables.is_empty())
9265    }
9266    fn set(&self, dirty: bool) {
9267        *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
9268    }
9269    fn table(&self, table: &str) {
9270        if let Some(tables) = self.0.borrow_mut().as_mut() {
9271            tables.insert(table.to_owned());
9272        }
9273    }
9274    fn snapshot(&self) -> Option<BTreeSet<String>> {
9275        self.0.borrow().clone()
9276    }
9277    fn restore(&self, tables: Option<BTreeSet<String>>) {
9278        *self.0.borrow_mut() = tables;
9279    }
9280}
9281
9282pub struct SyncClient {
9283    #[cfg(feature = "bench-internals")]
9284    benchmark_phases: Recorder,
9285    conn: Connection,
9286    schema: ClientSchema,
9287    client_id: String,
9288    limits: ClientLimits,
9289    subs: Vec<Subscription>,
9290    outbox: Vec<OutboxCommit>,
9291    failed_commits: Vec<FailedCommit>,
9292    retain_failed_commits: bool,
9293    conflicts: Vec<ConflictRecord>,
9294    rejections: Vec<RejectionRecord>,
9295    schema_floor: Option<SchemaFloor>,
9296    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
9297    lease_state: Option<LeaseState>,
9298    /// §1.6: the schema-floor response stops syncing until an upgrade.
9299    stopped: bool,
9300    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
9301    upgrading: bool,
9302    /// §8.4 coalesced sync-needed signal.
9303    sync_needed: bool,
9304    /// §8.8: how sync rounds treat the realtime binding, and its exact
9305    /// availability state with the evidence behind a lost or refused one.
9306    realtime_policy: RealtimePolicy,
9307    realtime_state: RealtimeState,
9308    realtime_reason_code: Option<String>,
9309    realtime_retry_delay_ms: Option<u64>,
9310    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
9311    presence: HashMap<String, HashMap<String, PresencePeer>>,
9312    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
9313    /// host may pin it (conformance runs on a virtual clock).
9314    now_ms: Option<i64>,
9315    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
9316    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
9317    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
9318    encryption: crate::values::EncryptionConfig,
9319    /// Fail-closed host bootstrap gate. While set, command hosts permit only
9320    /// status/lifecycle inspection and an exact authorized local purge.
9321    security_preflight: bool,
9322    transport_enabled: bool,
9323    /// RFC 0005 D2/D8: the previous-version capture config. `None` (default)
9324    /// means the feature is off; the descriptor write and the container orphan
9325    /// sweep still happen.
9326    previous_version: Option<PreviousVersionContextConfig>,
9327    /// Per-table primary-key upsert SQL, built once per (full table name) —
9328    /// the row write path runs per row during bootstrap (§5.6), so the SQL
9329    /// string (and, via `prepare_cached`, its compiled statement) is reused
9330    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
9331    /// schema reset (the column lists may have changed).
9332    insert_sql: RefCell<HashMap<String, String>>,
9333    /// §7.1: reconcile only tables whose base/outbox changed. Whole-replica
9334    /// reset and restart explicitly invalidate every table.
9335    overlay_dirty: OverlayDirty,
9336    // First SQLite failure of the current operation, before savepoint cleanup.
9337    storage_failure: RefCell<Option<QueryReadFailure>>,
9338    /// Test-only structural performance signal: response processing must not
9339    /// turn a batch of acknowledgements into one full overlay rebuild each.
9340    #[cfg(test)]
9341    overlay_rebuild_count: Cell<usize>,
9342    #[cfg(test)]
9343    acknowledged_replay_count: Cell<usize>,
9344    /// Test-only structural performance signal: outcome retention is enforced
9345    /// once per response rather than once per acknowledgement.
9346    #[cfg(test)]
9347    outcome_prune_count: Cell<usize>,
9348    /// Exact observer-transaction output drained by command/FFI hosts.
9349    progress: ProgressObserver,
9350    change_queue: VecDeque<ClientChangeBatch>,
9351    sync_intent_queue: VecDeque<SyncIntent>,
9352    /// Explicit exponential retry policy for transient transport failures.
9353    retry_delay_ms: u64,
9354    /// §7.6: the background retry the current round scheduled, if any.
9355    round_retry_delay_ms: Option<u64>,
9356    active_round: Option<uuid::Uuid>,
9357    last_round: Option<DiagnosticLastRound>,
9358    last_change: Option<DiagnosticLastChange>,
9359    /// §7.6: latest failed owned snapshot read per owner id, oldest first.
9360    query_failures: Vec<DiagnosticQueryFailure>,
9361}
9362
9363pub(crate) fn quote_ident(name: &str) -> String {
9364    format!("\"{}\"", name.replace('"', "\"\""))
9365}
9366/// §7.4.1 `_syncular_meta` helpers. Free functions so RFC 0005's
9367/// previous-version module reads and writes the SAME records as the client
9368/// instead of growing a parallel meta layer.
9369pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
9370    conn.query_row(
9371        "SELECT value FROM _syncular_meta WHERE key = ?1",
9372        rusqlite::params![key],
9373        |row| row.get::<_, String>(0),
9374    )
9375    .ok()
9376}
9377
9378pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
9379    let _ = conn.execute(
9380        "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
9381        rusqlite::params![key, value],
9382    );
9383}
9384
9385pub(crate) fn meta_delete(conn: &Connection, key: &str) {
9386    let _ = conn.execute(
9387        "DELETE FROM _syncular_meta WHERE key = ?1",
9388        rusqlite::params![key],
9389    );
9390}
9391
9392/// Seek by the existing key while retaining the exact legacy text match.
9393fn row_id_predicate(table: &TableSchema) -> String {
9394    let key = quote_ident(&table.primary_key);
9395    let text_match = format!("CAST({key} AS TEXT) = ?1");
9396    match table.columns[table.pk_index].ty {
9397        ColumnType::String | ColumnType::Json => {
9398            format!("{key} = ?1 AND {text_match}")
9399        }
9400        // Native columns have no declared SQLite affinity. Match the stored
9401        // integer bind type explicitly. Unary + removes expression affinity so
9402        // SQLite can seek the typeless key; the text check preserves exact IDs.
9403        ColumnType::Integer | ColumnType::Boolean => {
9404            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
9405        }
9406        // REAL-to-text conversion can round distinct values to the same text.
9407        // Narrowing those matches by numeric equality would change behavior.
9408        _ => text_match,
9409    }
9410}
9411
9412fn is_local_operation_code_like(value: &str) -> bool {
9413    let bytes = value.as_bytes();
9414    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
9415        && bytes
9416            .iter()
9417            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
9418}
9419
9420fn base_table(name: &str) -> String {
9421    quote_ident(&format!("_syncular_base_{name}"))
9422}
9423
9424/// §4.8: a stable, server-opaque key for a window base — table + variable +
9425/// canonical fixed scopes. Two `set_window` calls with the same base
9426/// address the same registry rows.
9427/// §4.8 deferred eviction record: (sub id, table, effective scope map).
9428type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
9429
9430fn window_base_key(base: &WindowBase) -> String {
9431    format!(
9432        "{}\0{}\0{}",
9433        base.table,
9434        base.variable,
9435        canonical_scope_json(&base.fixed_scopes)
9436    )
9437}
9438
9439/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
9440fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
9441    let mut scopes = base.fixed_scopes.clone();
9442    scopes.retain(|(k, _)| k != &base.variable);
9443    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
9444    scopes
9445}
9446
9447/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
9448/// echoed not interpreted by the server, so the exact hash is client
9449/// convention; SHA-256 matches the SPEC's worked example.
9450fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
9451    let canonical = canonical_scope_json(&unit_scopes(base, unit));
9452    let digest = Sha256::digest(canonical.as_bytes());
9453    let hex = bytes_to_hex(&digest);
9454    format!("w:{}:{}", base.table, &hex[..16])
9455}
9456
9457fn visible_table(name: &str) -> String {
9458    quote_ident(name)
9459}
9460
9461const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
9462
9463/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
9464/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
9465fn is_synced_table_name(name: &str) -> bool {
9466    if name.starts_with("sqlite_") {
9467        return false;
9468    }
9469    if name.starts_with("_syncular_base_") {
9470        return true; // the base half of a synced table pair
9471    }
9472    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
9473    !name.starts_with("_syncular_")
9474}
9475
9476/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
9477fn blob_id_for(bytes: &[u8]) -> String {
9478    let digest = Sha256::digest(bytes);
9479    format!("sha256:{}", bytes_to_hex(&digest))
9480}
9481
9482/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
9483/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
9484/// per row on the §5.6 bootstrap path), scalars bind owned.
9485enum RowParam<'a> {
9486    Cell(&'a Option<ColumnValue>),
9487    Version(i64),
9488}
9489
9490impl rusqlite::ToSql for RowParam<'_> {
9491    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
9492        Ok(match self {
9493            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
9494            RowParam::Cell(cell) => match cell {
9495                None => ToSqlOutput::Owned(SqlValue::Null),
9496                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
9497                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
9498                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
9499                Some(ColumnValue::Boolean(b)) => {
9500                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
9501                }
9502                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
9503                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
9504                }
9505                // §5.10: crdt bytes store as BLOB, like bytes.
9506                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
9507                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
9508                }
9509            },
9510        })
9511    }
9512}
9513
9514// Retain a typed primary key across the current import chunk without copying row payloads.
9515fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
9516    match param.to_sql().map_err(|error| error.to_string())? {
9517        ToSqlOutput::Owned(value) => Ok(value),
9518        ToSqlOutput::Borrowed(value) => {
9519            SqlValue::try_from(value).map_err(|error| error.to_string())
9520        }
9521        _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
9522    }
9523}
9524
9525/// §5.3 image cell → bind parameter, strict per the declared column type
9526/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
9527/// nullable). Mismatches are image-producer violations. Returns the cell
9528/// borrowed when the stored representation already matches what the row
9529/// codec would write, or the normalized scalar (boolean → 0/1, float from
9530/// INTEGER → REAL) otherwise — the local write is byte-identical to the
9531/// old convert-then-insert path without allocating per cell.
9532fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
9533    use ssp2::segment::ColumnType;
9534    let mismatch = || {
9535        Err(format!(
9536            "image column {:?} holds a value of the wrong type",
9537            column.name
9538        ))
9539    };
9540    match value {
9541        ValueRef::Null => {
9542            if !column.nullable {
9543                return Err(format!(
9544                    "image column {:?} is NULL but not nullable",
9545                    column.name
9546                ));
9547            }
9548            Ok(ToSqlOutput::Owned(SqlValue::Null))
9549        }
9550        ValueRef::Integer(i) => match column.ty {
9551            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
9552            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
9553            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
9554            _ => mismatch(),
9555        },
9556        ValueRef::Real(_) => match column.ty {
9557            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
9558            _ => mismatch(),
9559        },
9560        ValueRef::Text(t) => {
9561            std::str::from_utf8(t)
9562                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
9563            match column.ty {
9564                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
9565                    Ok(ToSqlOutput::Borrowed(value))
9566                }
9567                _ => mismatch(),
9568            }
9569        }
9570        ValueRef::Blob(_) => match column.ty {
9571            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
9572            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
9573            _ => mismatch(),
9574        },
9575    }
9576}
9577
9578pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
9579    use rusqlite::types::ValueRef;
9580    match value {
9581        ValueRef::Null => Value::Null,
9582        ValueRef::Integer(i) => match column.ty {
9583            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
9584            ssp2::segment::ColumnType::Float => {
9585                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
9586            }
9587            _ => Value::from(i),
9588        },
9589        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
9590        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
9591        ValueRef::Blob(b) => {
9592            let mut map = Map::new();
9593            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
9594            Value::Object(map)
9595        }
9596    }
9597}
9598
9599/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
9600/// Objects are accepted in lossless `{"$bytes": hex}` and
9601/// `{"$bigint": decimal}` envelope forms.
9602fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
9603    Ok(match value {
9604        Value::Null => SqlValue::Null,
9605        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
9606        Value::Number(n) => {
9607            if let Some(i) = n.as_i64() {
9608                SqlValue::Integer(i)
9609            } else if let Some(f) = n.as_f64() {
9610                SqlValue::Real(f)
9611            } else {
9612                return Err(format!("query param number {n} is out of range"));
9613            }
9614        }
9615        Value::String(s) => SqlValue::Text(s.clone()),
9616        Value::Object(_) => {
9617            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
9618                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
9619            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
9620                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
9621                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
9622                })?)
9623            } else {
9624                return Err(
9625                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
9626                        .to_owned(),
9627                );
9628            }
9629        }
9630        Value::Array(_) => return Err("query array params are not supported".to_owned()),
9631    })
9632}
9633
9634/// Map a rusqlite value with no schema column to consult (arbitrary query
9635/// output): integers/reals/text pass through by stored affinity, blobs ride
9636/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
9637/// schema column type to recover booleans/floats/json.
9638fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
9639    use rusqlite::types::ValueRef;
9640    match value {
9641        ValueRef::Null => Value::Null,
9642        ValueRef::Integer(i) => {
9643            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
9644            // ordinary UI-sized integers ergonomic and envelope only values
9645            // beyond JavaScript's safe range.
9646            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
9647            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
9648                Value::from(i)
9649            } else {
9650                let mut map = Map::new();
9651                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
9652                Value::Object(map)
9653            }
9654        }
9655        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
9656        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
9657        ValueRef::Blob(b) => {
9658            let mut map = Map::new();
9659            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
9660            Value::Object(map)
9661        }
9662    }
9663}
9664
9665/// §7.5: SQLite failures classify by result code into [`QueryReadFailure`].
9666fn query_connection(
9667    conn: &Connection,
9668    sql: &str,
9669    params: &[QueryValue],
9670) -> Result<Vec<QueryRow>, QueryReadFailure> {
9671    crate::query_guard::assert_read_only_query(sql)?;
9672    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
9673    let bound: Vec<SqlValue> = params
9674        .iter()
9675        .map(json_param_to_sql)
9676        .collect::<Result<_, _>>()?;
9677    let mut stmt = conn.prepare(&lowered_sql)?;
9678    let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
9679    let bound_refs: Vec<&dyn rusqlite::ToSql> =
9680        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
9681    let mut sql_rows = stmt.query(bound_refs.as_slice())?;
9682    let mut out = Vec::new();
9683    while let Some(row) = sql_rows.next()? {
9684        let mut record = Map::new();
9685        for (i, name) in column_names.iter().enumerate() {
9686            let value = row.get_ref(i)?;
9687            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
9688        }
9689        out.push(record);
9690    }
9691    Ok(out)
9692}
9693
9694fn persisted_window_state(
9695    conn: &Connection,
9696    base: &WindowBase,
9697) -> Result<WindowState, QueryReadFailure> {
9698    let mut stmt = conn.prepare(
9699        "SELECT windows.unit, subscriptions.state_json
9700           FROM _syncular_windows AS windows
9701           JOIN _syncular_subscriptions AS subscriptions
9702             ON subscriptions.id = windows.sub_id
9703          WHERE windows.base = ?1
9704          ORDER BY windows.unit ASC",
9705    )?;
9706    let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
9707        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
9708    })?;
9709    let mut units = Vec::new();
9710    let mut pending = Vec::new();
9711    for row in rows {
9712        let (unit, raw) = row?;
9713        let state: Value = serde_json::from_str(&raw)
9714            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
9715        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
9716            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
9717            || state
9718                .get("bootstrapState")
9719                .is_some_and(|value| !value.is_null());
9720        if is_pending {
9721            pending.push(unit.clone());
9722        }
9723        units.push(unit);
9724    }
9725    Ok(WindowState { units, pending })
9726}
9727
9728/// One read-only statement in a §7.5 snapshot read.
9729#[derive(Debug, Clone, Copy)]
9730pub struct SnapshotStatement<'a> {
9731    pub sql: &'a str,
9732    pub params: &'a [Value],
9733}
9734
9735/// A §7.5 atomic read request: read-only statements, window coverage,
9736/// subscription catch-up states, and commit deliveries, all resolved from one
9737/// SQLite read transaction and one revision.
9738#[derive(Debug, Clone, Default)]
9739pub struct SnapshotReadRequest<'a> {
9740    pub statements: Vec<SnapshotStatement<'a>>,
9741    pub coverage: &'a [WindowCoverage],
9742    pub subscriptions: Vec<String>,
9743    pub commit_ids: Vec<String>,
9744}
9745
9746#[cfg(test)]
9747thread_local! {
9748    /// Test-only rendezvous that runs between the statements of one snapshot
9749    /// read. A test uses it to commit on another connection mid-read and prove
9750    /// the read transaction's snapshot and revision stay stable.
9751    static SNAPSHOT_READ_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
9752        std::cell::RefCell::new(None);
9753}
9754
9755#[cfg(test)]
9756fn run_snapshot_read_hook() {
9757    SNAPSHOT_READ_HOOK.with(|slot| {
9758        if let Some(hook) = slot.borrow_mut().take() {
9759            hook();
9760        }
9761    });
9762}
9763
9764/// §7.5: resolve a request over one SQLite read transaction. The revision,
9765/// every statement's rows, coverage, catch-up states, and deliveries are
9766/// captured under one `SAVEPOINT`; any statement's failure rolls the whole
9767/// read back and releases the connection.
9768fn snapshot_read_connection(
9769    conn: &Connection,
9770    request: &SnapshotReadRequest<'_>,
9771) -> Result<SnapshotRead, QueryReadFailure> {
9772    conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
9773    let corrupt = |message: &str| QueryReadFailure {
9774        code: Some("sync.local_corrupt"),
9775        sqlite_code: None,
9776        sqlite_message: None,
9777        rollback_failure: None,
9778        message: message.to_owned(),
9779    };
9780    let stored_failure = |error: rusqlite::Error, message: &str| match error {
9781        rusqlite::Error::Utf8Error(..)
9782        | rusqlite::Error::InvalidColumnType(..)
9783        | rusqlite::Error::FromSqlConversionFailure(..)
9784        | rusqlite::Error::IntegralValueOutOfRange(..) => corrupt(message),
9785        _ => QueryReadFailure::from(error),
9786    };
9787    let result: Result<SnapshotRead, QueryReadFailure> = (|| {
9788        // The durable revision as a canonical decimal `u64`; a missing marker
9789        // is the legacy zero, and any other non-canonical value is corrupt.
9790        let revision: Option<String> = conn
9791            .query_row(
9792                "SELECT value FROM _syncular_meta WHERE key = ?1",
9793                rusqlite::params![LOCAL_REVISION_KEY],
9794                |row| row.get(0),
9795            )
9796            .optional()
9797            .map_err(|error| stored_failure(error, "persisted local revision is invalid"))?;
9798        let revision = match revision {
9799            None => "0".to_owned(),
9800            Some(value)
9801                if value == "0"
9802                    || (matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
9803                        && value.bytes().all(|b| b.is_ascii_digit())
9804                        && value.parse::<u64>().is_ok()) =>
9805            {
9806                value
9807            }
9808            Some(_) => return Err(corrupt("persisted local revision is invalid")),
9809        };
9810        let mut queries = Vec::with_capacity(request.statements.len());
9811        for statement in &request.statements {
9812            queries.push(query_connection(conn, statement.sql, statement.params)?);
9813            #[cfg(test)]
9814            run_snapshot_read_hook();
9815        }
9816        let mut pending = Vec::new();
9817        let mut missing = Vec::new();
9818        for requested in request.coverage {
9819            let base_key = window_base_key(&requested.base);
9820            let state = persisted_window_state(conn, &requested.base)?;
9821            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
9822                let reference = WindowUnitRef {
9823                    base_key: base_key.clone(),
9824                    unit: unit.clone(),
9825                };
9826                if !state.units.iter().any(|held| held == &unit) {
9827                    missing.push(reference);
9828                } else if state.pending.iter().any(|held| held == &unit) {
9829                    pending.push(reference);
9830                }
9831            }
9832        }
9833        let mut subscriptions = Vec::with_capacity(request.subscriptions.len());
9834        for id in &request.subscriptions {
9835            let row: Option<(String, String)> = conn
9836                .query_row(
9837                    "SELECT tbl, state_json FROM _syncular_subscriptions WHERE id = ?1",
9838                    rusqlite::params![id],
9839                    |row| Ok((row.get(0)?, row.get(1)?)),
9840                )
9841                .optional()
9842                .map_err(|error| {
9843                    stored_failure(error, "persisted subscription state is invalid")
9844                })?;
9845            let Some((table, raw)) = row else {
9846                subscriptions.push(SubscriptionCatchup::Unknown { id: id.clone() });
9847                continue;
9848            };
9849            // Every persisted field is required to be well typed; a corrupt
9850            // record fails typed instead of inventing a state.
9851            let state: Value = serde_json::from_str(&raw)
9852                .map_err(|_| corrupt("persisted subscription state is invalid"))?;
9853            let status = match state.get("status").and_then(Value::as_str) {
9854                Some(value @ ("active" | "revoked" | "failed")) => value,
9855                _ => return Err(corrupt("persisted subscription state is invalid")),
9856            };
9857            let Some(cursor) = state.get("cursor").and_then(Value::as_i64) else {
9858                return Err(corrupt("persisted subscription state is invalid"));
9859            };
9860            if cursor.unsigned_abs() > MAX_JS_SAFE_INTEGER {
9861                return Err(corrupt("persisted subscription state is invalid"));
9862            }
9863            let effective_scopes = state
9864                .get("effectiveScopes")
9865                .filter(|value| !value.is_null());
9866            if let Some(scopes) = effective_scopes {
9867                json_to_scope_map(scopes)
9868                    .map_err(|_| corrupt("persisted subscription state is invalid"))?;
9869            }
9870            let has_resume_token = match state.get("bootstrapState") {
9871                None | Some(Value::Null) => false,
9872                Some(Value::String(_)) => true,
9873                Some(_) => return Err(corrupt("persisted subscription state is invalid")),
9874            };
9875            // A present reasonCode must be a string; a non-string value is a
9876            // corrupt record, never silently dropped.
9877            let reason_code = match state.get("reasonCode") {
9878                None | Some(Value::Null) => None,
9879                Some(Value::String(value)) => Some(value.clone()),
9880                Some(_) => return Err(corrupt("persisted subscription state is invalid")),
9881            };
9882            let active = status == "active";
9883            subscriptions.push(SubscriptionCatchup::Known {
9884                id: id.clone(),
9885                table,
9886                status: status.to_owned(),
9887                cursor,
9888                has_resume_token,
9889                bootstrap_complete: active && cursor >= 0 && !has_resume_token,
9890                known_pending_pages: active && (has_resume_token || cursor < 0),
9891                effective_scopes: effective_scopes.cloned(),
9892                reason_code,
9893            });
9894        }
9895        let mut deliveries = Vec::with_capacity(request.commit_ids.len());
9896        for id in &request.commit_ids {
9897            let pending: bool = conn.query_row(
9898                "SELECT EXISTS(SELECT 1 FROM _syncular_outbox WHERE commit_id = ?1)",
9899                rusqlite::params![id],
9900                |row| row.get(0),
9901            )?;
9902            if pending {
9903                deliveries.push(CommitDelivery::Pending {
9904                    client_commit_id: id.clone(),
9905                });
9906                continue;
9907            }
9908            let row = conn
9909                .query_row(
9910                    "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
9911                            resolution, resolved_at_ms, replacement_client_commit_id
9912                       FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
9913                    rusqlite::params![id],
9914                    StoredCommitOutcomeRow::from_row,
9915                )
9916                .optional()
9917                .map_err(|error| stored_failure(error, "persisted commit outcome is invalid"))?;
9918            deliveries.push(match row {
9919                Some(row) => CommitDelivery::Known {
9920                    client_commit_id: id.clone(),
9921                    outcome: SyncClient::outcome_from_row(row)
9922                        .map_err(|_| corrupt("persisted commit outcome is invalid"))?,
9923                },
9924                None => CommitDelivery::Unknown {
9925                    client_commit_id: id.clone(),
9926                },
9927            });
9928        }
9929        Ok(SnapshotRead {
9930            revision,
9931            queries,
9932            coverage: CoverageSnapshot {
9933                complete: pending.is_empty() && missing.is_empty(),
9934                pending,
9935                missing,
9936            },
9937            subscriptions,
9938            deliveries,
9939        })
9940    })();
9941    match result {
9942        Ok(read) => {
9943            conn.execute_batch("RELEASE syncular_snapshot_read")?;
9944            Ok(read)
9945        }
9946        Err(mut error) => {
9947            if let Err(rollback) = conn
9948                .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
9949            {
9950                error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
9951            }
9952            Err(error)
9953        }
9954    }
9955}
9956
9957fn snapshot_connection(
9958    conn: &Connection,
9959    sql: &str,
9960    params: &[Value],
9961    coverage: &[WindowCoverage],
9962) -> Result<QuerySnapshot, QueryReadFailure> {
9963    let read = snapshot_read_connection(
9964        conn,
9965        &SnapshotReadRequest {
9966            statements: vec![SnapshotStatement { sql, params }],
9967            coverage,
9968            subscriptions: Vec::new(),
9969            commit_ids: Vec::new(),
9970        },
9971    )?;
9972    Ok(QuerySnapshot {
9973        revision: read.revision,
9974        rows: read
9975            .queries
9976            .into_iter()
9977            .next()
9978            .expect("single-query snapshot returns one result"),
9979        coverage: read.coverage,
9980    })
9981}
9982
9983/// A long-lived read-only SQLite sidecar for latency-critical native views.
9984/// Network rounds stay serialized on the mutable core owner, while atomic
9985/// query snapshots use this independent connection and therefore never queue
9986/// behind HTTP/WebSocket latency.
9987pub struct FileQuerySnapshotReader {
9988    path: String,
9989    conn: Option<Connection>,
9990}
9991
9992impl FileQuerySnapshotReader {
9993    #[must_use]
9994    pub fn new(path: impl Into<String>) -> Self {
9995        Self {
9996            path: path.into(),
9997            conn: None,
9998        }
9999    }
10000
10001    fn connection(&mut self) -> Result<&Connection, QueryReadFailure> {
10002        if self.conn.is_none() {
10003            let conn = Connection::open_with_flags(
10004                &self.path,
10005                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
10006            )
10007            .map_err(QueryReadFailure::from)?;
10008            conn.busy_timeout(std::time::Duration::from_millis(250))
10009                .map_err(QueryReadFailure::from)?;
10010            self.conn = Some(conn);
10011        }
10012        self.conn
10013            .as_ref()
10014            .ok_or_else(|| QueryReadFailure::from("read sidecar connection missing".to_owned()))
10015    }
10016
10017    /// One atomic read over the shared primitive: every statement, coverage,
10018    /// catch-up, and delivery under one `SAVEPOINT` and one revision.
10019    pub fn snapshot_read(
10020        &mut self,
10021        request: &SnapshotReadRequest<'_>,
10022    ) -> Result<SnapshotRead, QueryReadFailure> {
10023        snapshot_read_connection(self.connection()?, request)
10024    }
10025
10026    /// The current durable local revision. A host can skip a read when it is
10027    /// unchanged; a corrupt or unreadable marker fails typed.
10028    pub fn local_revision(&mut self) -> Result<String, QueryReadFailure> {
10029        Ok(self
10030            .snapshot_read(&SnapshotReadRequest::default())?
10031            .revision)
10032    }
10033
10034    /// One subscription's persisted catch-up state (see the §7.5 contract).
10035    pub fn subscription_catchup(
10036        &mut self,
10037        id: &str,
10038    ) -> Result<SubscriptionCatchup, QueryReadFailure> {
10039        let read = self.snapshot_read(&SnapshotReadRequest {
10040            subscriptions: vec![id.to_owned()],
10041            ..Default::default()
10042        })?;
10043        Ok(read
10044            .subscriptions
10045            .into_iter()
10046            .next()
10047            .expect("one requested catch-up"))
10048    }
10049
10050    /// One client commit's delivery status: pending, its retained outcome, or
10051    /// an explicit unknown (never held, or pruned by retention).
10052    pub fn commit_delivery(&mut self, id: &str) -> Result<CommitDelivery, QueryReadFailure> {
10053        let read = self.snapshot_read(&SnapshotReadRequest {
10054            commit_ids: vec![id.to_owned()],
10055            ..Default::default()
10056        })?;
10057        Ok(read
10058            .deliveries
10059            .into_iter()
10060            .next()
10061            .expect("one requested delivery"))
10062    }
10063
10064    /// Hosts report an owned failure, and the first success after it, to the
10065    /// owning core through [`SyncClient::record_query_read`] (§7.6).
10066    pub fn query_snapshot(
10067        &mut self,
10068        sql: &str,
10069        params: &[Value],
10070        coverage: &[WindowCoverage],
10071    ) -> Result<QuerySnapshot, QueryReadFailure> {
10072        snapshot_connection(self.connection()?, sql, params, coverage)
10073    }
10074}
10075
10076fn validate_authority_reads(
10077    schema: &ClientSchema,
10078    declarations: &[crate::api::AuthorityReadDeclaration],
10079) -> Result<(), String> {
10080    let mut seen = BTreeSet::new();
10081    for read in declarations {
10082        let table = schema
10083            .table(&read.table)
10084            .ok_or("client.authority_read_forbidden: authority table is not declared")?;
10085        let plain = |name: &str| {
10086            table.columns.iter().enumerate().any(|(i, c)| {
10087                c.name == name
10088                    && name != "authority_version"
10089                    && !name.starts_with("_sync")
10090                    && !table.encrypted_columns.iter().any(|e| e.index == i)
10091                    && matches!(
10092                        c.ty,
10093                        ssp2::segment::ColumnType::String
10094                            | ssp2::segment::ColumnType::Integer
10095                            | ssp2::segment::ColumnType::Float
10096                            | ssp2::segment::ColumnType::Boolean
10097                            | ssp2::segment::ColumnType::Json
10098                    )
10099            })
10100        };
10101        if !seen.insert(&read.table)
10102            || read.columns.is_empty()
10103            || read.columns.iter().collect::<BTreeSet<_>>().len() != read.columns.len()
10104            || !read.columns.contains(&table.primary_key)
10105            || !read.columns.iter().all(|c| plain(c))
10106            || read.scopes.is_empty()
10107            || !read.scopes.iter().all(|(v, values)| {
10108                table
10109                    .scope_column(v)
10110                    .is_some_and(|c| read.columns.iter().any(|column| column == c) && plain(c))
10111                    && !values.is_empty()
10112                    && values.iter().all(|v| !v.is_empty() && v != "*")
10113            })
10114        {
10115            return Err("client.authority_read_forbidden: authority reads require plain columns, a primary key and nonempty plain scope selectors".into());
10116        }
10117    }
10118    Ok(())
10119}
10120
10121// §7.4.2: every open and recreation checks before any persistent writes.
10122/// A marker read that hit a recognized SQLite storage failure keeps that
10123/// classification (BUSY/LOCKED stay retryable); only an unclassified read
10124/// failure is the invalid-marker `sync.local_corrupt`.
10125fn schema_marker_read_failure(error: rusqlite::Error) -> String {
10126    let failure = QueryReadFailure::from(error);
10127    if failure.code.is_some() {
10128        failure.to_string()
10129    } else {
10130        "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned()
10131    }
10132}
10133
10134/// §7.1: reject an invalid configured client limit before any storage is
10135/// opened or written. Zero would wedge the push queue and an out-of-range
10136/// value would differ per target; the TS core enforces the same bounds.
10137fn validate_client_limits(limits: &crate::api::ClientLimits) -> Result<(), String> {
10138    if limits.outcome_retention_max_entries == Some(0) {
10139        return Err("sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned());
10140    }
10141    if [
10142        limits.max_push_commits_per_request,
10143        limits.max_push_operations_per_request,
10144        limits.max_push_request_bytes,
10145    ]
10146    .into_iter()
10147    .flatten()
10148    .any(|value| value == 0 || value > u32::MAX as usize)
10149    {
10150        return Err(
10151            "sync.invalid_request: push limit must be an integer in 1..=4294967295".to_owned(),
10152        );
10153    }
10154    Ok(())
10155}
10156
10157fn read_local_schema_version(conn: &Connection, requested: i32) -> Result<Option<i32>, String> {
10158    let corrupt =
10159        || "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned();
10160    let table_type: Option<String> = conn
10161        .query_row(
10162            "SELECT type FROM sqlite_master WHERE name = '_syncular_meta'",
10163            [],
10164            |row| row.get(0),
10165        )
10166        .optional()
10167        .map_err(schema_marker_read_failure)?;
10168    let Some(table_type) = table_type else {
10169        return Ok(None);
10170    };
10171    if table_type != "table" {
10172        return Err(corrupt());
10173    }
10174    // §7.4.2: a duplicated marker cannot be resolved. `query_row` would take
10175    // whichever row SQLite returns first, and an older one would drive a
10176    // destructive reset over a newer replica.
10177    let mut statement = conn
10178        .prepare("SELECT value FROM _syncular_meta WHERE key = ?1")
10179        .map_err(schema_marker_read_failure)?;
10180    let mut rows = statement
10181        .query([LOCAL_SCHEMA_VERSION_KEY])
10182        .map_err(schema_marker_read_failure)?;
10183    let value: Option<String> = rows
10184        .next()
10185        .map_err(schema_marker_read_failure)?
10186        .map(|row| row.get(0))
10187        .transpose()
10188        .map_err(schema_marker_read_failure)?;
10189    if rows.next().map_err(schema_marker_read_failure)?.is_some() {
10190        return Err(corrupt());
10191    }
10192    let Some(value) = value else {
10193        let has_descriptor = conn
10194            .query_row(
10195                "SELECT 1 FROM _syncular_meta WHERE key = ?1 LIMIT 1",
10196                [crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY],
10197                |_| Ok(()),
10198            )
10199            .optional()
10200            .map_err(schema_marker_read_failure)?
10201            .is_some();
10202        if has_descriptor {
10203            return Err(corrupt());
10204        }
10205        return Ok(None);
10206    };
10207    if !matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
10208        || !value.bytes().all(|b| b.is_ascii_digit())
10209    {
10210        return Err(corrupt());
10211    }
10212    let version = value.parse::<i32>().map_err(|_| corrupt())?;
10213    if version > requested {
10214        return Err(
10215            "client.schema_downgrade: persisted local schema is newer than the requested schema"
10216                .to_owned(),
10217        );
10218    }
10219    Ok(Some(version))
10220}
10221
10222impl SyncClient {
10223    pub fn new_with_identity(
10224        client_id: Option<String>,
10225        schema_json: &Value,
10226        limits: ClientLimits,
10227    ) -> Result<Self, String> {
10228        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
10229        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
10230        Self::with_connection(resolved, schema_json, limits, conn)
10231    }
10232
10233    pub fn new(
10234        client_id: String,
10235        schema_json: &Value,
10236        limits: ClientLimits,
10237    ) -> Result<Self, String> {
10238        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
10239        Self::with_connection(client_id, schema_json, limits, conn)
10240    }
10241
10242    /// Build a client backed by an on-disk SQLite database at `path` — the
10243    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
10244    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
10245    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
10246    /// of the command router's dependency set (the router only holds a path).
10247    pub fn open_path(
10248        client_id: String,
10249        schema_json: &Value,
10250        limits: ClientLimits,
10251        path: &str,
10252    ) -> Result<Self, String> {
10253        validate_client_limits(&limits)?;
10254        parse_schema_json(schema_json)?;
10255        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
10256        Self::with_connection(client_id, schema_json, limits, conn)
10257    }
10258
10259    pub fn open_path_with_identity(
10260        client_id: Option<String>,
10261        schema_json: &Value,
10262        limits: ClientLimits,
10263        path: &str,
10264    ) -> Result<Self, String> {
10265        validate_client_limits(&limits)?;
10266        // §7.4.2: an invalid requested version is refused before the replica is
10267        // even created, so a rejected constructor leaves no file behind.
10268        parse_schema_json(schema_json)?;
10269        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
10270        // A file-backed native client uses an independent read connection for
10271        // latency-critical snapshots. WAL is SQLite's intended reader/writer
10272        // concurrency mode: a view read never holds a rollback-journal lock
10273        // that delays the client's next commit. The busy timeout must precede
10274        // this replica's own startup transaction; WAL is applied only after
10275        // startup succeeded, because a refused open must leave the replica's
10276        // journal mode and contents untouched (§7.4.2).
10277        conn.busy_timeout(std::time::Duration::from_millis(250))
10278            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
10279        let client = Self::with_connection_identity(client_id, schema_json, limits, conn)?;
10280        let journal_mode: String = client
10281            .conn
10282            .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
10283            .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
10284        if journal_mode != "wal" {
10285            return Err(
10286                "sync.invalid_request: file-backed client requires WAL journal mode".to_owned(),
10287            );
10288        }
10289        Ok(client)
10290    }
10291
10292    #[must_use]
10293    pub fn client_id(&self) -> &str {
10294        &self.client_id
10295    }
10296
10297    /// Build a client over a caller-supplied rusqlite connection — the seam a
10298    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
10299    /// with an on-disk database (`Connection::open(path)`) rather than the
10300    /// default `:memory:`. The connection MUST be fresh (no pre-existing
10301    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
10302    /// the same file across process restarts reuses the persisted rows.
10303    pub fn with_connection(
10304        client_id: String,
10305        schema_json: &Value,
10306        limits: ClientLimits,
10307        conn: Connection,
10308    ) -> Result<Self, String> {
10309        Self::with_connection_identity(Some(client_id), schema_json, limits, conn)
10310    }
10311
10312    /// [`Self::with_connection`] for `open_path_with_identity`: no requested id
10313    /// means a durable replica's persisted `clientId` wins over a fresh one.
10314    fn with_connection_identity(
10315        client_id: Option<String>,
10316        schema_json: &Value,
10317        limits: ClientLimits,
10318        conn: Connection,
10319    ) -> Result<Self, String> {
10320        validate_client_limits(&limits)?;
10321        let schema = parse_schema_json(schema_json)?;
10322        validate_authority_reads(&schema, &limits.authority_reads)?;
10323        // RFC 0005 D8: resolve the previous-version config before the opening
10324        // reset runs — the capture happens inside it. Bad bounds fail loud.
10325        let previous_version = match limits.previous_version_context {
10326            Some(config) => {
10327                config.validate()?;
10328                Some(config)
10329            }
10330            None => None,
10331        };
10332        let mut client = SyncClient {
10333            #[cfg(feature = "bench-internals")]
10334            benchmark_phases: Recorder::default(),
10335            conn,
10336            schema,
10337            client_id: client_id
10338                .clone()
10339                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
10340            limits,
10341            subs: Vec::new(),
10342            outbox: Vec::new(),
10343            failed_commits: Vec::new(),
10344            retain_failed_commits: false,
10345            conflicts: Vec::new(),
10346            rejections: Vec::new(),
10347            schema_floor: None,
10348            lease_state: None,
10349            stopped: false,
10350            upgrading: false,
10351            sync_needed: false,
10352            realtime_policy: RealtimePolicy::Optional,
10353            realtime_state: RealtimeState::Disconnected,
10354            realtime_reason_code: None,
10355            realtime_retry_delay_ms: None,
10356            presence: HashMap::new(),
10357            now_ms: None,
10358            encryption: crate::values::EncryptionConfig::default(),
10359            security_preflight: false,
10360            transport_enabled: true,
10361            previous_version,
10362            insert_sql: RefCell::new(HashMap::new()),
10363            overlay_dirty: OverlayDirty::clean(),
10364            storage_failure: RefCell::new(None),
10365            #[cfg(test)]
10366            overlay_rebuild_count: Cell::new(0),
10367            #[cfg(test)]
10368            acknowledged_replay_count: Cell::new(0),
10369            #[cfg(test)]
10370            outcome_prune_count: Cell::new(0),
10371            progress: ProgressObserver::default(),
10372            change_queue: VecDeque::new(),
10373            sync_intent_queue: VecDeque::new(),
10374            retry_delay_ms: 250,
10375            round_retry_delay_ms: None,
10376            active_round: None,
10377            last_round: None,
10378            last_change: None,
10379            query_failures: Vec::new(),
10380        };
10381        // The row write path leans on the prepared-statement cache (two
10382        // insert statements per synced table, plus the bookkeeping
10383        // statements); size it so a multi-table schema never thrashes.
10384        client
10385            .conn
10386            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
10387        // §7.4.2: one write transaction (SQLite `BEGIN IMMEDIATE`, a reserved
10388        // writer) covers the marker read, the identity check, and every
10389        // bookkeeping/schema write, so a concurrent open that upgraded the
10390        // replica cannot interleave between the guard and the writes, and a
10391        // refusal leaves the replica unchanged.
10392        client
10393            .conn
10394            .execute_batch("BEGIN IMMEDIATE")
10395            .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
10396        let startup = (|| -> Result<(), String> {
10397            // The marker guard runs inside the write transaction, so the version
10398            // it validates is the version every write below commits under.
10399            let marker = read_local_schema_version(&client.conn, client.schema.version)?;
10400            // New app indexes may reference columns that only exist after a
10401            // version-bump reset.
10402            client.create_bookkeeping_tables()?;
10403            match client.get_meta(CLIENT_ID_KEY) {
10404                Some(existing) => match client_id {
10405                    Some(requested) if existing != requested => {
10406                        return Err(format!(
10407                            "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
10408                        ))
10409                    }
10410                    _ => client.client_id = existing,
10411                },
10412                None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
10413            }
10414            client.restore_persisted_state()?;
10415            client.failed_commits = client.load_failed_commits()?;
10416            match marker {
10417                None => {
10418                    client.create_synced_tables()?;
10419                    client.persist_schema_version()?;
10420                    client.save_subscription_scope_schema();
10421                }
10422                Some(version) if version == client.schema.version => {
10423                    client.create_synced_tables()?;
10424                    // RFC 0005 D1: the same-version open backfills the descriptor
10425                    // for a database first opened by an unaware binary, so the
10426                    // NEXT bump can capture without needing a schema bump first.
10427                    set_local_schema_descriptor(&client.conn, &client.schema)
10428                        .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
10429                    client.save_subscription_scope_schema();
10430                }
10431                Some(_) => client.run_schema_reset()?,
10432            }
10433            // RFC 0005 D9: the reset sweep cannot see a container whose capture
10434            // committed in its OWN file while the replica's savepoint never
10435            // released — a crash at that seam leaves a container beside a matching
10436            // OLD marker, and the next open at that version runs no reset. Discard
10437            // it before anything can read it. No-op when no container exists.
10438            client.reconcile_previous_version_at_boot()?;
10439            client.clear_satisfied_persisted_schema_floor();
10440            client.prune_unknown_subscriptions(false)?;
10441            // §7.1/§7.4.4: recover intrinsically codec-invalid persisted values at
10442            // the full startup/reset reconciliation boundary, inside this open
10443            // transaction and after the schema and identity guards, so a commit
10444            // an earlier version accepted is dropped with durable rejection
10445            // evidence before any replay. A table the current schema removed
10446            // keeps the §7.4.4 send-time classification, and an unresolved
10447            // encryption key stays with the send seam because hosts configure
10448            // keys after open.
10449            client.drop_unencodable_outbox(false, None)?;
10450            if marker == Some(client.schema.version)
10451                && (!client.outbox.is_empty()
10452                    || !client.failed_commits.is_empty()
10453                    || client.has_acknowledged_rows())
10454            {
10455                // Reconstruct the visible optimistic overlay from the durable
10456                // base plus outbox instead of trusting a process-interrupted
10457                // mirror.
10458                client.overlay_dirty.set(true);
10459                client.rebuild_overlay()?;
10460            }
10461            // Every persisted active subscription needs one catch-up pull on
10462            // open: realtime only covers changes after connection, while an
10463            // idempotent setWindow correctly creates no fresh command effect.
10464            // Pending outbox work has the same restart requirement. The core
10465            // owns this intent so native hosts never poll or require an
10466            // application-issued sync().
10467            client.enqueue_startup_sync_if_needed()?;
10468            Ok(())
10469        })();
10470        match startup {
10471            Ok(()) => match client.conn.execute_batch("COMMIT") {
10472                Ok(()) => Ok(client),
10473                Err(error) => {
10474                    let failure = Self::sqlite_failure(&client.storage_failure, error);
10475                    if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
10476                        // Keep a failed rollback's typed storage classification.
10477                        let _ = Self::sqlite_failure(&client.storage_failure, rollback);
10478                    }
10479                    Err(failure)
10480                }
10481            },
10482            Err(error) => {
10483                if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
10484                    // Keep a failed rollback's typed storage classification.
10485                    let _ = Self::sqlite_failure(&client.storage_failure, rollback);
10486                }
10487                Err(error)
10488            }
10489        }
10490    }
10491
10492    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
10493    /// against this instead of system time (conformance virtual clock).
10494    pub fn set_now_ms(&mut self, now_ms: i64) {
10495        self.now_ms = Some(now_ms);
10496    }
10497
10498    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
10499    /// The command router parses these from the `create` command's
10500    /// `encryption` config (keys as `{$bytes: hex}`).
10501    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
10502        self.encryption = encryption;
10503    }
10504
10505    #[must_use]
10506    pub fn security_lifecycle(&self) -> &'static str {
10507        if self.security_preflight {
10508            "preflight"
10509        } else {
10510            "active"
10511        }
10512    }
10513
10514    #[must_use]
10515    pub fn security_preflight(&self) -> bool {
10516        self.security_preflight
10517    }
10518
10519    /// Host-owned, non-persisted network gate. Local operations remain available.
10520    pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
10521        if self.transport_enabled != enabled {
10522            self.transport_enabled = enabled;
10523            self.sync_intent_queue.clear();
10524            self.sync_intent_queue
10525                .push_back(if enabled && !self.security_preflight {
10526                    SyncIntent::Interactive
10527                } else {
10528                    SyncIntent::None
10529                });
10530        }
10531        // A captured reply keeps its normal apply/revocation semantics.
10532        if !enabled && self.active_round.is_none() {
10533            self.disconnect_realtime(transport);
10534        }
10535    }
10536
10537    pub fn transport_enabled(&self) -> bool {
10538        self.transport_enabled
10539    }
10540
10541    /// Quarantine this replica: enter the fail-closed gate, release all
10542    /// core-owned key material, and record the gate in the database so
10543    /// reopening re-enters preflight until `activate_security` clears it.
10544    ///
10545    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
10546    /// which must NOT leave that durable mark.
10547    pub fn begin_security_preflight(&mut self) {
10548        self.seal_security_on_teardown();
10549        // Persist the quarantine so it survives handle teardown and restart:
10550        // reopening this replica re-enters preflight until activation clears it.
10551        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
10552    }
10553
10554    /// Release core-owned key material as a host tears the client down,
10555    /// WITHOUT recording a quarantine.
10556    ///
10557    /// Shutting an activated client down is not a quarantine event. Persisting
10558    /// the gate here marks every cleanly closed replica as pending, and the
10559    /// next plain `create` is then refused permanently: the reopen path
10560    /// restores the flag from the marker and the create guard rejects it. The
10561    /// in-memory flag still closes the gate for anything still holding this
10562    /// instance, which is all a teardown barrier needs.
10563    pub fn seal_security_on_teardown(&mut self) {
10564        self.cancel_sync_round();
10565        self.security_preflight = true;
10566        self.encryption = crate::values::EncryptionConfig::default();
10567        self.sync_intent_queue.clear();
10568    }
10569
10570    /// Install the post-authentication keyring and release the host loop.
10571    pub fn activate_security(
10572        &mut self,
10573        encryption: crate::values::EncryptionConfig,
10574    ) -> Result<(), String> {
10575        if !self.security_preflight {
10576            return Err(
10577                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
10578            );
10579        }
10580        self.enqueue_startup_sync_if_needed()?;
10581        self.encryption = encryption;
10582        self.security_preflight = false;
10583        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
10584        Ok(())
10585    }
10586
10587    fn clock_now_ms(&self) -> i64 {
10588        self.now_ms.unwrap_or_else(|| {
10589            std::time::SystemTime::now()
10590                .duration_since(std::time::UNIX_EPOCH)
10591                .map(|d| d.as_millis() as i64)
10592                .unwrap_or(0)
10593        })
10594    }
10595
10596    fn create_bookkeeping_tables(&self) -> Result<(), String> {
10597        // Durable client bookkeeping (outbox + subscription + meta).
10598        self.conn
10599            .execute_batch(
10600                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
10601                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
10602                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
10603                 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
10604                   tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
10605                 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
10606                   commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
10607                   commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
10608                   PRIMARY KEY(commit_id, idx));
10609                 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
10610                   seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
10611                   operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
10612                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
10613                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
10614                   client_commit_id TEXT NOT NULL UNIQUE,
10615                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
10616                   recorded_at_ms INTEGER NOT NULL,
10617                   results_json TEXT NOT NULL,
10618                   operations_json TEXT,
10619                   resolution TEXT NOT NULL DEFAULT 'active'
10620                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
10621                   resolved_at_ms INTEGER,
10622                   replacement_client_commit_id TEXT);
10623                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
10624                   ON _syncular_commit_outcomes(resolution, seq);
10625                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
10626                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
10627                 CREATE TABLE IF NOT EXISTS _syncular_meta (
10628                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
10629                 CREATE TABLE IF NOT EXISTS _syncular_windows (
10630                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
10631                   PRIMARY KEY (base, unit));
10632                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
10633                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
10634                   effective_scopes TEXT NOT NULL);
10635                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
10636                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
10637                   PRIMARY KEY(commit_id, blob_id));
10638                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
10639                   ON _syncular_blob_commit_refs(blob_id);",
10640            )
10641            .map_err(|e| e.to_string())?;
10642        // Migrate an outcome journal created before failed aggregate
10643        // envelopes were retained. Historical rows intentionally stay NULL.
10644        let _ = self
10645            .conn
10646            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
10647        if self.get_meta(LOCAL_REVISION_KEY).is_none() {
10648            self.set_meta(LOCAL_REVISION_KEY, "0");
10649        }
10650        // §5.9.7 blob cache. Mutable upload state stays off the body row so
10651        // completing a large upload does not rewrite its BLOB record.
10652        if self.schema_has_blobs() {
10653            let exists = self
10654                .conn
10655                .query_row(
10656                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
10657                    [],
10658                    |row| row.get::<_, bool>(0),
10659                )
10660                .map_err(|e| e.to_string())?;
10661            if exists {
10662                let mut statement = self
10663                    .conn
10664                    .prepare("PRAGMA table_info(_syncular_blobs)")
10665                    .map_err(|e| e.to_string())?;
10666                let mut columns = statement
10667                    .query_map([], |row| row.get::<_, String>(1))
10668                    .map_err(|e| e.to_string())?
10669                    .collect::<Result<Vec<_>, _>>()
10670                    .map_err(|e| e.to_string())?;
10671                columns.sort();
10672                let mut expected = vec![
10673                    "blob_id",
10674                    "byte_length",
10675                    "bytes",
10676                    "created_at_ms",
10677                    "media_type",
10678                ];
10679                expected.sort_unstable();
10680                if columns != expected {
10681                    return Err(
10682                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
10683                    );
10684                }
10685            } else {
10686                self.conn
10687                    .execute_batch(
10688                        "CREATE TABLE _syncular_blobs(
10689                           blob_id TEXT PRIMARY KEY,
10690                           bytes BLOB NOT NULL,
10691                           byte_length INTEGER NOT NULL,
10692                           media_type TEXT,
10693                           created_at_ms INTEGER NOT NULL);",
10694                    )
10695                    .map_err(|e| e.to_string())?;
10696            }
10697            self.conn
10698                .execute_batch(
10699                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
10700                       blob_id TEXT PRIMARY KEY,
10701                       media_type TEXT,
10702                       created_at_ms INTEGER NOT NULL);",
10703                )
10704                .map_err(|e| e.to_string())?;
10705        }
10706        Ok(())
10707    }
10708
10709    /// True iff any synced table declares a `blob_ref` column (§5.9).
10710    fn schema_has_blobs(&self) -> bool {
10711        self.schema
10712            .tables
10713            .iter()
10714            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
10715    }
10716
10717    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
10718
10719    fn get_meta(&self, key: &str) -> Option<String> {
10720        meta_get(&self.conn, key)
10721    }
10722
10723    fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
10724        self.conn
10725            .query_row(
10726                "SELECT value FROM _syncular_meta WHERE key = ?1",
10727                rusqlite::params![key],
10728                |row| row.get::<_, String>(0),
10729            )
10730            .optional()
10731            .map_err(|_| {
10732                "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
10733            })
10734    }
10735
10736    // The caller holds the startup/reset transaction. Either both schema
10737    // records persist or the enclosing operation fails and rolls back.
10738    fn persist_schema_version(&self) -> Result<(), String> {
10739        self.conn
10740            .execute(
10741                "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
10742                rusqlite::params![LOCAL_SCHEMA_VERSION_KEY, self.schema.version.to_string()],
10743            )
10744            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10745        set_local_schema_descriptor(&self.conn, &self.schema)
10746            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
10747    }
10748
10749    fn set_meta(&self, key: &str, value: &str) {
10750        meta_set(&self.conn, key, value);
10751    }
10752
10753    fn delete_meta(&self, key: &str) {
10754        meta_delete(&self.conn, key);
10755    }
10756
10757    /// §7.1: the durable outbox in FIFO order, in schema-agnostic local form.
10758    fn load_outbox(&self) -> Result<Vec<OutboxCommit>, String> {
10759        let mut stmt = self
10760            .conn
10761            .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
10762            .map_err(|error| error.to_string())?;
10763        let rows = stmt
10764            .query_map([], |row| {
10765                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
10766            })
10767            .map_err(|error| error.to_string())?;
10768        let mut commits = Vec::new();
10769        for row in rows {
10770            let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
10771            let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
10772                format!("invalid persisted outbox {client_commit_id:?}: {error}")
10773            })?;
10774            let mut ops = Vec::with_capacity(entries.len());
10775            for entry in entries {
10776                let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
10777                ops.push(OutboxOp {
10778                    upsert: op == "upsert",
10779                    table: entry
10780                        .get("table")
10781                        .and_then(Value::as_str)
10782                        .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
10783                        .to_owned(),
10784                    row_id: entry
10785                        .get("rowId")
10786                        .and_then(Value::as_str)
10787                        .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
10788                        .to_owned(),
10789                    base_version: entry.get("baseVersion").and_then(Value::as_i64),
10790                    // A legacy entry's `changedFields` key is ignored:
10791                    // the values map is the presence set (§6.1).
10792                    values: entry.get("values").and_then(Value::as_object).cloned(),
10793                });
10794            }
10795            commits.push(OutboxCommit {
10796                client_commit_id,
10797                ops,
10798            });
10799        }
10800        Ok(commits)
10801    }
10802
10803    fn restore_persisted_state(&mut self) -> Result<(), String> {
10804        self.subs = {
10805            let mut stmt = self
10806                .conn
10807                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
10808                .map_err(|error| error.to_string())?;
10809            let rows = stmt
10810                .query_map([], |row| {
10811                    Ok((
10812                        row.get::<_, String>(0)?,
10813                        row.get::<_, String>(1)?,
10814                        row.get::<_, String>(2)?,
10815                    ))
10816                })
10817                .map_err(|error| error.to_string())?;
10818            let mut subscriptions = Vec::new();
10819            for row in rows {
10820                let (id, table, raw) = row.map_err(|error| error.to_string())?;
10821                let state: Value = serde_json::from_str(&raw)
10822                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
10823                let requested = json_to_scope_map(
10824                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
10825                )?;
10826                let effective = state
10827                    .get("effectiveScopes")
10828                    .filter(|value| !value.is_null())
10829                    .map(json_to_scope_map)
10830                    .transpose()?;
10831                subscriptions.push(Subscription {
10832                    id,
10833                    table,
10834                    requested,
10835                    params: state
10836                        .get("params")
10837                        .and_then(Value::as_str)
10838                        .map(str::to_owned),
10839                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
10840                    bootstrap_state: state
10841                        .get("bootstrapState")
10842                        .and_then(Value::as_str)
10843                        .map(str::to_owned),
10844                    state: SubState::parse(
10845                        state
10846                            .get("status")
10847                            .and_then(Value::as_str)
10848                            .unwrap_or("active"),
10849                    ),
10850                    reason_code: state
10851                        .get("reasonCode")
10852                        .and_then(Value::as_str)
10853                        .map(str::to_owned),
10854                    effective,
10855                    synced_once: state
10856                        .get("syncedOnce")
10857                        .and_then(Value::as_bool)
10858                        .unwrap_or(false),
10859                });
10860            }
10861            subscriptions
10862        };
10863
10864        self.outbox = self.load_outbox()?;
10865
10866        self.prune_commit_outcomes()?;
10867        let active = self.commit_outcomes(CommitOutcomeQuery {
10868            active_only: true,
10869            ..CommitOutcomeQuery::default()
10870        })?;
10871        self.conflicts = active
10872            .iter()
10873            .flat_map(|outcome| outcome.results.iter())
10874            .filter_map(|result| match result {
10875                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
10876                _ => None,
10877            })
10878            .collect();
10879        self.rejections = active
10880            .iter()
10881            .flat_map(|outcome| outcome.results.iter())
10882            .filter_map(|result| match result {
10883                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
10884                _ => None,
10885            })
10886            .collect();
10887
10888        self.lease_state = self
10889            .get_meta(LEASE_STATE_KEY)
10890            .map(|raw| serde_json::from_str(&raw))
10891            .transpose()
10892            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
10893        self.schema_floor = self
10894            .get_meta(SCHEMA_FLOOR_KEY)
10895            .map(|raw| serde_json::from_str(&raw))
10896            .transpose()
10897            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
10898        self.stopped = self.schema_floor.is_some();
10899        // A replica left in unactivated preflight reopens gated: the quarantine
10900        // decision persists with the data across handle teardown and restart.
10901        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
10902            self.security_preflight = true;
10903        }
10904        Ok(())
10905    }
10906
10907    fn save_subscription_scope_schema(&self) {
10908        let declarations: BTreeMap<_, BTreeMap<_, _>> = self
10909            .schema
10910            .tables
10911            .iter()
10912            .map(|table| {
10913                (
10914                    table.name.clone(),
10915                    table
10916                        .scope_variables
10917                        .iter()
10918                        .map(|scope| {
10919                            (
10920                                scope.variable.clone(),
10921                                [scope.prefix.clone(), scope.column.clone()],
10922                            )
10923                        })
10924                        .collect(),
10925                )
10926            })
10927            .collect();
10928        self.set_meta(
10929            "subscriptionScopeSchema",
10930            &serde_json::to_string(&declarations).expect("scope schema serialization"),
10931        );
10932    }
10933
10934    /// Remove registrations with incompatible table/key/prefix/column meaning.
10935    fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
10936        let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
10937            self.get_meta("subscriptionScopeSchema")
10938                .map(|raw| serde_json::from_str(&raw))
10939                .transpose()
10940                .map_err(|error| format!("invalid persisted scope schema: {error}"))?
10941        } else {
10942            None
10943        };
10944        let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
10945            let Some(table) = self.schema.table(table_name) else {
10946                return false;
10947            };
10948            if requested
10949                .iter()
10950                .any(|(variable, _)| table.scope_column(variable).is_none())
10951            {
10952                return false;
10953            }
10954            if !bump {
10955                return true;
10956            }
10957            let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
10958                return false;
10959            };
10960            let variables: BTreeSet<_> = if requested.is_empty() {
10961                old.keys()
10962                    .cloned()
10963                    .chain(
10964                        table
10965                            .scope_variables
10966                            .iter()
10967                            .map(|scope| scope.variable.clone()),
10968                    )
10969                    .collect()
10970            } else {
10971                requested
10972                    .iter()
10973                    .map(|(variable, _)| variable.clone())
10974                    .collect()
10975            };
10976            variables.iter().all(|variable| {
10977                let current = table
10978                    .scope_variables
10979                    .iter()
10980                    .find(|scope| &scope.variable == variable);
10981                match (old.get(variable), current) {
10982                    (Some(old), Some(current)) => {
10983                        old == &[current.prefix.clone(), current.column.clone()]
10984                    }
10985                    _ => false,
10986                }
10987            })
10988        };
10989        let mut stale_ids: BTreeSet<_> = self
10990            .subs
10991            .iter()
10992            .filter(|sub| !compatible(&sub.table, &sub.requested))
10993            .map(|sub| sub.id.clone())
10994            .collect();
10995        for (id, table, effective) in self.load_pending_evictions()? {
10996            if !compatible(&table, &effective) {
10997                stale_ids.insert(id);
10998            }
10999        }
11000        for id in &stale_ids {
11001            for sql in [
11002                "DELETE FROM _syncular_windows WHERE sub_id = ?1",
11003                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
11004                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
11005            ] {
11006                self.conn
11007                    .execute(sql, rusqlite::params![id])
11008                    .map_err(|error| error.to_string())?;
11009            }
11010        }
11011        self.subs.retain(|sub| !stale_ids.contains(&sub.id));
11012        Ok(())
11013    }
11014
11015    #[must_use]
11016    pub fn local_revision(&self) -> u64 {
11017        self.get_meta(LOCAL_REVISION_KEY)
11018            .and_then(|value| value.parse().ok())
11019            .unwrap_or(0)
11020    }
11021
11022    /// §7.5: resolve a snapshot read on the mutable owner connection through
11023    /// the same primitive the read-only sidecar uses, so the command surface
11024    /// and the file sidecar return one revision, rows, coverage, catch-up, and
11025    /// delivery shape. The structured [`QueryReadFailure`] is preserved; an
11026    /// `owner` records its failure (and its next success) in diagnostics.
11027    pub fn snapshot_read(
11028        &mut self,
11029        request: &SnapshotReadRequest<'_>,
11030        owner: Option<QueryOwner<'_>>,
11031    ) -> Result<SnapshotRead, QueryReadFailure> {
11032        let result = snapshot_read_connection(&self.conn, request);
11033        if let Some(owner) = owner {
11034            self.record_query_read(owner, result.as_ref().err());
11035        }
11036        result
11037    }
11038
11039    #[must_use]
11040    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
11041        SyncStatusSnapshot {
11042            current_schema_version: self.schema.version,
11043            outbox: self.outbox.len(),
11044            upgrading: self.upgrading,
11045            lease_state: self.lease_state.clone(),
11046            schema_floor: self.schema_floor.clone(),
11047            sync_needed: self.sync_needed,
11048            previous_version_context: self.previous_version_status(),
11049        }
11050    }
11051
11052    /// RFC 0005 A2: container presence for a host that must act on it (Diego's
11053    /// rollback precondition), read without creating the file.
11054    fn previous_version_status(&self) -> PreviousVersionStatus {
11055        let Some(replica_path) = self.previous_version_replica_path() else {
11056            return PreviousVersionStatus {
11057                present: false,
11058                created_at_ms: None,
11059            };
11060        };
11061        match crate::previous_version::read_previous_version_record(&replica_path) {
11062            Ok(Some(record)) => PreviousVersionStatus {
11063                present: true,
11064                created_at_ms: Some(record.created_at_ms),
11065            },
11066            _ => PreviousVersionStatus {
11067                present: std::path::Path::new(
11068                    &crate::previous_version::previous_version_container_path(&replica_path),
11069                )
11070                .exists(),
11071                created_at_ms: None,
11072            },
11073        }
11074    }
11075
11076    /// RFC 0005 D7: read captured rows from the previous local schema. `state`
11077    /// is always `previousVersion`; the container never makes
11078    /// `query_snapshot()` coverage complete. Lease stop/expiry, scope
11079    /// revocation, coverage completion and the TTL DISCARD the container and
11080    /// close the read with their own reason.
11081    pub fn previous_version_snapshot(
11082        &self,
11083        spec: &PreviousVersionReadSpec,
11084    ) -> Result<PreviousVersionSnapshot, String> {
11085        let now_ms = self.clock_now_ms();
11086        crate::previous_version::previous_version_snapshot(
11087            &self.conn,
11088            self.previous_version_replica_path().as_deref(),
11089            self.previous_version.as_ref(),
11090            PreviousVersionLifecycle {
11091                lease_inactive: self.previous_version_lease_inactive(now_ms),
11092                scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
11093                coverage_complete: self.previous_version_coverage_complete(),
11094            },
11095            now_ms,
11096            self.schema.version,
11097            spec,
11098        )
11099    }
11100
11101    /// RFC 0005 D6: the advisory pre-reset compatibility audit.
11102    #[must_use]
11103    pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
11104        crate::previous_version::stored_previous_version_audit(&self.conn)
11105    }
11106
11107    /// RFC 0005 A2/D9: the executable downgrade step. Drop the container file
11108    /// and both metadata records, and report whether anything was present.
11109    /// Idempotent: absence is a successful no-op.
11110    pub fn previous_version_discard(
11111        &self,
11112    ) -> crate::previous_version::PreviousVersionDiscardOutcome {
11113        self.drop_previous_version()
11114    }
11115
11116    /// The module's discard has no failure path (it removes the file even when
11117    /// the container cannot be opened), so the only error a caller could ever
11118    /// see is a bug; report absence rather than claiming a discard happened.
11119    fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
11120        let absent = crate::previous_version::PreviousVersionDiscardOutcome {
11121            present: false,
11122            discarded: false,
11123        };
11124        let Some(replica_path) = self.previous_version_replica_path() else {
11125            return absent;
11126        };
11127        crate::previous_version::discard_previous_version(&self.conn, &replica_path)
11128            .unwrap_or(absent)
11129    }
11130
11131    /// RFC 0005 D7: `leaseState.errorCode` or an expired lease.
11132    fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
11133        let Some(lease) = &self.lease_state else {
11134            return false;
11135        };
11136        if lease.error_code.is_some() {
11137            return true;
11138        }
11139        lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
11140    }
11141
11142    /// RFC 0005 D7: §7.4.5 replacement coverage — every ACTIVE subscription
11143    /// has `cursor >= 0` and no resume token. An empty active set is not
11144    /// completion (mirrors the TS predicate exactly).
11145    fn previous_version_coverage_complete(&self) -> bool {
11146        let active = self
11147            .subs
11148            .iter()
11149            .filter(|sub| sub.state == SubState::Active)
11150            .collect::<Vec<_>>();
11151        if active.is_empty() {
11152            return false;
11153        }
11154        active
11155            .iter()
11156            .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
11157    }
11158
11159    /// RFC 0005 D7: lifetime triggers evaluated once per sync round, after the
11160    /// apply. A container that no longer has a reason to exist is discarded so
11161    /// the rows do not outlive the window they were kept for.
11162    fn previous_version_lifetime_check(&self) {
11163        let Some(config) = self.previous_version.filter(|config| config.enabled) else {
11164            return;
11165        };
11166        let now_ms = self.clock_now_ms();
11167        if self.previous_version_lease_inactive(now_ms) {
11168            let _ = self.drop_previous_version();
11169            return;
11170        }
11171        let Some(replica_path) = self.previous_version_replica_path() else {
11172            return;
11173        };
11174        let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
11175        else {
11176            return;
11177        };
11178        if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
11179            let _ = self.drop_previous_version();
11180            return;
11181        }
11182        if now_ms - record.created_at_ms > config.max_age_ms {
11183            let _ = self.drop_previous_version();
11184            return;
11185        }
11186        if self.previous_version_coverage_complete() {
11187            let _ = self.drop_previous_version();
11188        }
11189    }
11190
11191    pub fn diagnostics_snapshot(
11192        &self,
11193        request: &ClientDiagnosticsRequest,
11194    ) -> Result<ClientDiagnosticsSnapshot, String> {
11195        if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
11196            return Err(format!(
11197                "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
11198            ));
11199        }
11200        let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
11201        for sub in &self.subs {
11202            let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
11203            let complete =
11204                sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
11205            let state = match sub.state {
11206                SubState::Revoked => "revoked",
11207                SubState::Failed => "failed",
11208                SubState::Active if reset => "reset",
11209                SubState::Active if complete => "complete",
11210                SubState::Active => "bootstrapping",
11211            };
11212            subscriptions.insert(
11213                sub.id.clone(),
11214                DiagnosticSubscription {
11215                    id: sub.id.clone(),
11216                    table: sub.table.clone(),
11217                    state: state.to_owned(),
11218                    complete,
11219                    cursor: Some(sub.cursor),
11220                    reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
11221                },
11222            );
11223        }
11224        for expected in &request.expected_subscriptions {
11225            if expected.id.is_empty() || expected.table.is_empty() {
11226                return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
11227            }
11228            if subscriptions
11229                .get(&expected.id)
11230                .is_some_and(|registered| registered.table != expected.table)
11231            {
11232                subscriptions.insert(
11233                    expected.id.clone(),
11234                    DiagnosticSubscription {
11235                        id: expected.id.clone(),
11236                        table: expected.table.clone(),
11237                        state: "failed".to_owned(),
11238                        complete: false,
11239                        cursor: None,
11240                        reason_code: Some("client.subscription_intent_mismatch".to_owned()),
11241                    },
11242                );
11243            } else {
11244                subscriptions.entry(expected.id.clone()).or_insert_with(|| {
11245                    DiagnosticSubscription {
11246                        id: expected.id.clone(),
11247                        table: expected.table.clone(),
11248                        state: "unregistered".to_owned(),
11249                        complete: false,
11250                        cursor: None,
11251                        reason_code: None,
11252                    }
11253                });
11254            }
11255        }
11256        let mut ordered_subscriptions = Vec::new();
11257        let mut included = BTreeSet::new();
11258        for expected in &request.expected_subscriptions {
11259            if included.insert(expected.id.clone()) {
11260                if let Some(subscription) = subscriptions.get(&expected.id) {
11261                    ordered_subscriptions.push(subscription.clone());
11262                }
11263            }
11264        }
11265        for (id, subscription) in &subscriptions {
11266            if included.insert(id.clone()) {
11267                ordered_subscriptions.push(subscription.clone());
11268            }
11269        }
11270        let subscriptions_truncated =
11271            ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
11272        ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
11273        let captured_at_ms = self.clock_now_ms();
11274        let lease = if let Some(error_code) = self
11275            .lease_state
11276            .as_ref()
11277            .and_then(|state| state.error_code.clone())
11278        {
11279            ClientDiagnosticsLease {
11280                state: "stopped".to_owned(),
11281                expires_at_ms: self
11282                    .lease_state
11283                    .as_ref()
11284                    .and_then(|state| state.expires_at_ms),
11285                error_code: Some(Self::diagnostic_code(&error_code)),
11286            }
11287        } else if let Some(expires_at_ms) = self
11288            .lease_state
11289            .as_ref()
11290            .and_then(|state| state.expires_at_ms)
11291        {
11292            ClientDiagnosticsLease {
11293                state: if expires_at_ms <= captured_at_ms {
11294                    "expired".to_owned()
11295                } else {
11296                    "active".to_owned()
11297                },
11298                expires_at_ms: Some(expires_at_ms),
11299                error_code: None,
11300            }
11301        } else {
11302            ClientDiagnosticsLease {
11303                state: "none".to_owned(),
11304                expires_at_ms: None,
11305                error_code: None,
11306            }
11307        };
11308        let connectivity = match self.last_round.as_ref() {
11309            _ if !self.transport_enabled && self.active_round.is_none() => "offline",
11310            Some(round) if round.status == "succeeded" => "online",
11311            Some(round)
11312                if round.status == "failed"
11313                    && round
11314                        .error_code
11315                        .as_deref()
11316                        .is_some_and(Self::retryable_transport_code) =>
11317            {
11318                "offline"
11319            }
11320            _ => "unknown",
11321        };
11322        Ok(ClientDiagnosticsSnapshot {
11323            version: CLIENT_DIAGNOSTICS_VERSION,
11324            captured_at_ms,
11325            host: ClientDiagnosticsHost {
11326                kind: "direct".to_owned(),
11327                role: "single".to_owned(),
11328                connectivity: connectivity.to_owned(),
11329                realtime: self.realtime_state,
11330                realtime_policy: self.realtime_policy,
11331                realtime_reason_code: self.realtime_reason_code.clone(),
11332                realtime_retry_delay_ms: self.realtime_retry_delay_ms,
11333            },
11334            security_lifecycle: self.security_lifecycle().to_owned(),
11335            schema: ClientDiagnosticsSchema {
11336                current_version: self.schema.version,
11337                upgrading: self.upgrading,
11338                required_version: self
11339                    .schema_floor
11340                    .as_ref()
11341                    .and_then(|floor| floor.required_schema_version),
11342                latest_version: self
11343                    .schema_floor
11344                    .as_ref()
11345                    .and_then(|floor| floor.latest_schema_version),
11346            },
11347            replica: ClientDiagnosticsReplica {
11348                local_revision: self.local_revision().to_string(),
11349                sync_needed: self.sync_needed,
11350                pending_outbox: self.outbox.len(),
11351            },
11352            lease,
11353            subscriptions: ordered_subscriptions,
11354            subscriptions_truncated,
11355            last_round: self.last_round.clone(),
11356            last_change: self.last_change.clone(),
11357            storage: self.diagnostics_storage(),
11358            query_failures: self.query_failures.clone(),
11359        })
11360    }
11361
11362    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
11363        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
11364            let page_count: i64 = self
11365                .conn
11366                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
11367            let page_size: i64 = self
11368                .conn
11369                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
11370            // Reuse compiled aggregates; every invocation still reads current storage.
11371            let outbox_bytes: i64 = self
11372                .conn
11373                .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
11374                .query_row([], |row| row.get(0))?;
11375            let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
11376                "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
11377            )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
11378            let blob_bytes = if self.schema_has_blobs() {
11379                self.conn
11380                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
11381                    .query_row([], |row| row.get(0))?
11382            } else {
11383                0
11384            };
11385            let pressure = self
11386                .limits
11387                .blob_cache_max_bytes
11388                .is_some_and(|limit| blob_bytes > limit);
11389            Ok(ClientDiagnosticsStorage {
11390                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
11391                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
11392                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
11393                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
11394                retained_outcome_entries: Some(outcome_entries.max(0)),
11395                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
11396                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
11397            })
11398        };
11399        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
11400            status: "unreadable".to_owned(),
11401            database_bytes_approx: None,
11402            pending_outbox_bytes_approx: None,
11403            retained_outcome_bytes_approx: None,
11404            retained_outcome_entries: None,
11405            blob_cache_bytes_approx: None,
11406            pressure_reason_code: None,
11407        })
11408    }
11409
11410    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
11411        self.change_queue.drain(..).collect()
11412    }
11413
11414    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
11415        let intents = self.sync_intent_queue.drain(..).collect();
11416        if self.transport_enabled {
11417            intents
11418        } else {
11419            vec![SyncIntent::None]
11420        }
11421    }
11422
11423    fn schedule_background_retry(&mut self) -> u64 {
11424        let delay_ms = self.retry_delay_ms;
11425        self.sync_intent_queue
11426            .push_back(SyncIntent::Background { delay_ms });
11427        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
11428        self.round_retry_delay_ms = Some(delay_ms);
11429        delay_ms
11430    }
11431
11432    fn reset_background_retry(&mut self) {
11433        self.retry_delay_ms = 250;
11434    }
11435
11436    fn retryable_transport_code(code: &str) -> bool {
11437        code == "transport.failed"
11438            || code == "transport.unavailable"
11439            || code == "sync.transport_failed"
11440    }
11441
11442    /// A failed round worth a background retry: a transport failure, or a
11443    /// §10.2 code the catalog marks retryable (clients may hardcode the
11444    /// catalog metadata, §10.1). Matches the TypeScript core, which retries
11445    /// on the server's `retryable` flag.
11446    fn retryable_failure_code(code: &str) -> bool {
11447        Self::retryable_transport_code(code)
11448            || matches!(
11449                code,
11450                "sync.auth_required"
11451                    | "sync.auth_lease_required"
11452                    | "sync.auth_lease_revoked"
11453                    | "sync.internal_error"
11454                    | "sync.idempotency_cache_miss"
11455                    | "sync.schema_not_ready"
11456                    | "sync.segment_expired"
11457                    | "sync.rate_limited"
11458                    | "sync.websocket_connection_limit"
11459            )
11460    }
11461
11462    fn diagnostic_code(code: &str) -> String {
11463        let valid = !code.is_empty()
11464            && code.len() <= 96
11465            && code.contains('.')
11466            && code
11467                .bytes()
11468                .next()
11469                .is_some_and(|byte| byte.is_ascii_lowercase())
11470            && code.bytes().all(|byte| {
11471                byte.is_ascii_lowercase()
11472                    || byte.is_ascii_digit()
11473                    || matches!(byte, b'.' | b'_' | b'-')
11474            });
11475        if valid {
11476            code.to_owned()
11477        } else {
11478            "client.unknown_failure".to_owned()
11479        }
11480    }
11481
11482    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
11483        if self.sync_needed != value {
11484            if self.begin_observation("syncular_status").is_ok() {
11485                self.sync_needed = value;
11486                let batch = ChangeAccumulator {
11487                    status: true,
11488                    ..ChangeAccumulator::default()
11489                };
11490                if self.finish_observation("syncular_status", batch).is_err() {
11491                    self.rollback_observation("syncular_status");
11492                }
11493            } else {
11494                self.sync_needed = value;
11495            }
11496        }
11497        if value && interactive {
11498            self.sync_intent_queue.push_back(SyncIntent::Interactive);
11499        }
11500    }
11501
11502    fn sqlite_failure(
11503        storage_failure: &RefCell<Option<QueryReadFailure>>,
11504        error: rusqlite::Error,
11505    ) -> String {
11506        let message = error.to_string();
11507        let failure = QueryReadFailure::from(error);
11508        let code = failure.code;
11509        if code.is_some() && storage_failure.borrow().is_none() {
11510            *storage_failure.borrow_mut() = Some(failure);
11511        }
11512        match code {
11513            Some(code) => format!("{code}: {message}"),
11514            None => message,
11515        }
11516    }
11517
11518    fn begin_observation(&self, name: &str) -> Result<(), String> {
11519        self.conn
11520            .execute_batch(&format!("SAVEPOINT {name}"))
11521            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
11522    }
11523
11524    fn rollback_observation(&self, name: &str) {
11525        if let Err(error) = self
11526            .conn
11527            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
11528        {
11529            if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
11530                if failure.rollback_failure.is_none() {
11531                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
11532                }
11533            }
11534        }
11535    }
11536
11537    fn finish_observation(
11538        &mut self,
11539        name: &str,
11540        mut batch: ChangeAccumulator,
11541    ) -> Result<(), String> {
11542        #[cfg(feature = "bench-internals")]
11543        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
11544        if self.failed_commits.iter().any(|(commit, _)| {
11545            commit
11546                .ops
11547                .iter()
11548                .any(|operation| batch.tables.contains_key(&operation.table))
11549        }) {
11550            batch.outcomes = true;
11551        }
11552        if !batch.touched() {
11553            self.conn
11554                .execute_batch(&format!("RELEASE {name}"))
11555                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11556            return Ok(());
11557        }
11558        let revision = self
11559            .local_revision()
11560            .checked_add(1)
11561            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
11562        self.conn
11563            .execute(
11564                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11565                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
11566            )
11567            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11568        let status = batch.status.then(|| self.status_snapshot());
11569        let event = ClientChangeBatch {
11570            revision: revision.to_string(),
11571            tables: batch
11572                .tables
11573                .into_iter()
11574                .map(|(table, scope_keys)| TableChange {
11575                    table,
11576                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
11577                })
11578                .collect(),
11579            windows: batch
11580                .windows
11581                .into_iter()
11582                .map(|((base_key, table), units)| WindowChange {
11583                    base_key,
11584                    table,
11585                    units: units.into_iter().collect(),
11586                })
11587                .collect(),
11588            status,
11589            conflicts_changed: batch.conflicts,
11590            rejections_changed: batch.rejections,
11591            outcomes_changed: batch.outcomes,
11592        };
11593        self.conn
11594            .execute_batch(&format!("RELEASE {name}"))
11595            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11596        let mut diagnostic_tables = event
11597            .tables
11598            .iter()
11599            .map(|entry| entry.table.clone())
11600            .collect::<BTreeSet<_>>()
11601            .into_iter()
11602            .collect::<Vec<_>>();
11603        let mut diagnostic_windows = event
11604            .windows
11605            .iter()
11606            .map(|entry| entry.table.clone())
11607            .collect::<BTreeSet<_>>()
11608            .into_iter()
11609            .collect::<Vec<_>>();
11610        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
11611            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
11612        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
11613        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
11614        self.last_change = Some(DiagnosticLastChange {
11615            revision: event.revision.clone(),
11616            recorded_at_ms: self.clock_now_ms(),
11617            tables: diagnostic_tables,
11618            windows: diagnostic_windows,
11619            domains_truncated,
11620            status_changed: event.status.is_some(),
11621            conflicts_changed: event.conflicts_changed,
11622            rejections_changed: event.rejections_changed,
11623            outcomes_changed: event.outcomes_changed,
11624        });
11625        self.change_queue.push_back(event);
11626        Ok(())
11627    }
11628
11629    fn record_scope_map(
11630        &self,
11631        batch: &mut ChangeAccumulator,
11632        table_name: &str,
11633        scopes: &[(String, Vec<String>)],
11634    ) {
11635        let Some(table) = self.schema.table(table_name) else {
11636            return;
11637        };
11638        for (variable, values) in scopes {
11639            let Some(scope) = table
11640                .scope_variables
11641                .iter()
11642                .find(|scope| &scope.variable == variable)
11643            else {
11644                continue;
11645            };
11646            for value in values {
11647                batch.scope(table_name, format!("{}:{value}", scope.prefix));
11648            }
11649        }
11650    }
11651
11652    /// Record a row's current scope keys from the base or visible table.
11653    fn record_row_scopes(
11654        &self,
11655        batch: &mut ChangeAccumulator,
11656        table_name: &str,
11657        row_id: &str,
11658        base: bool,
11659    ) -> bool {
11660        let Some(table) = self.schema.table(table_name) else {
11661            return false;
11662        };
11663        if table.scope_variables.is_empty() {
11664            return false;
11665        }
11666        let columns = table
11667            .scope_variables
11668            .iter()
11669            .map(|scope| quote_ident(&scope.column))
11670            .collect::<Vec<_>>()
11671            .join(", ");
11672        let full_table = if base {
11673            base_table(table_name)
11674        } else {
11675            visible_table(table_name)
11676        };
11677        let sql = format!(
11678            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
11679            row_id_predicate(table)
11680        );
11681        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
11682            return false;
11683        };
11684        let values = stmt.query_row(rusqlite::params![row_id], |row| {
11685            let mut values = Vec::with_capacity(table.scope_variables.len());
11686            for index in 0..table.scope_variables.len() {
11687                values.push(row.get::<_, Option<String>>(index)?);
11688            }
11689            Ok(values)
11690        });
11691        let Ok(values) = values else {
11692            return false;
11693        };
11694        let mut recorded = false;
11695        for (scope, value) in table.scope_variables.iter().zip(values) {
11696            if let Some(value) = value {
11697                batch.scope(table_name, format!("{}:{value}", scope.prefix));
11698                recorded = true;
11699            }
11700        }
11701        recorded
11702    }
11703
11704    fn record_commit_changes(
11705        &self,
11706        batch: &mut ChangeAccumulator,
11707        tables: &[String],
11708        changes: &[ssp2::model::Change],
11709    ) {
11710        #[cfg(feature = "bench-internals")]
11711        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
11712        for change in changes {
11713            let Some(table_name) = tables.get(change.table_index as usize) else {
11714                continue;
11715            };
11716            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
11717            if let Some(table) = self.schema.table(table_name) {
11718                for (variable, value) in &change.scopes {
11719                    if let Some(scope) = table
11720                        .scope_variables
11721                        .iter()
11722                        .find(|scope| &scope.variable == variable)
11723                    {
11724                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
11725                        precise = true;
11726                    }
11727                }
11728            }
11729            if !precise {
11730                batch.table(table_name);
11731            }
11732        }
11733    }
11734
11735    fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
11736        if effective.is_empty() {
11737            return false;
11738        }
11739        let Some(table) = self.schema.table(table_name) else {
11740            return false;
11741        };
11742        let mut clauses = Vec::new();
11743        let mut params = Vec::new();
11744        for (variable, values) in effective {
11745            let Some(column) = table.scope_column(variable) else {
11746                return false;
11747            };
11748            if values.is_empty() {
11749                return false;
11750            }
11751            let placeholders = values
11752                .iter()
11753                .map(|value| {
11754                    params.push(SqlValue::Text(value.clone()));
11755                    "?"
11756                })
11757                .collect::<Vec<_>>()
11758                .join(", ");
11759            clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
11760        }
11761        let sql = format!(
11762            "SELECT 1 FROM {} WHERE {} LIMIT 1",
11763            base_table(table_name),
11764            clauses.join(" AND ")
11765        );
11766        self.conn
11767            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
11768            .is_ok()
11769    }
11770
11771    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
11772    pub fn upgrading(&self) -> bool {
11773        self.upgrading
11774    }
11775
11776    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
11777    /// keeping this client's local database (identity, outbox, tables). The
11778    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
11779    /// version increases. Recreation runs the same marker guard as opening
11780    /// a durable replica, holds the guard and every write it authorizes under
11781    /// one write transaction, and preserves the current client (compiled
11782    /// schema, outbox, tables) on refusal.
11783    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
11784        let new_schema = parse_schema_json(schema_json)?;
11785        validate_authority_reads(&new_schema, &self.limits.authority_reads)?;
11786        self.conn
11787            .execute_batch("BEGIN IMMEDIATE")
11788            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11789        let prior_schema = std::mem::replace(&mut self.schema, new_schema);
11790        // §7.4.3/§7.4.4: a reset inside this transaction mutates in-memory
11791        // state (dropped outbox commits, rejections, upgrade and floor flags,
11792        // overlay dirtiness) and appends change/intent batches. A COMMIT that
11793        // fails after the reset succeeded rolls the database back, so every
11794        // one of those fields is captured here and restored with it.
11795        let prior_subs = self.subs.clone();
11796        let prior_outbox = self.outbox.clone();
11797        let prior_rejections = self.rejections.clone();
11798        let prior_upgrading = self.upgrading;
11799        let prior_stopped = self.stopped;
11800        let prior_schema_floor = self.schema_floor.clone();
11801        let prior_overlay_dirty = self.overlay_dirty.snapshot();
11802        let prior_sync_needed = self.sync_needed;
11803        let prior_active_round = self.active_round;
11804        let prior_changes = self.change_queue.len();
11805        let prior_intents = self.sync_intent_queue.len();
11806        let prior_last_change = self.last_change.clone();
11807        let guarded = (|| -> Result<(), String> {
11808            // §7.4.2: the marker is re-read under the write lock the caller
11809            // holds, so a replica another process upgraded meanwhile refuses
11810            // this recreation instead of having its marker and tables
11811            // downgraded.
11812            let marker = read_local_schema_version(&self.conn, self.schema.version)?;
11813            self.create_bookkeeping_tables()?;
11814            match marker {
11815                Some(version) if version < self.schema.version => self.run_schema_reset()?,
11816                None => {
11817                    self.create_synced_tables()?;
11818                    self.persist_schema_version()?;
11819                }
11820                _ => {}
11821            }
11822            // §7.4.2: recreation IS the boot for the in-memory core, so reload the
11823            // durable outbox the same-version path is about to replay. Only the
11824            // outbox is reloaded: the recovery below reads it, and the caller's
11825            // rollback snapshot already covers this field.
11826            self.outbox = self.load_outbox()?;
11827            // §7.1/§7.4.4: recover intrinsically codec-invalid persisted values at
11828            // this guarded boundary once the schema and marker agree. A bump went
11829            // through `run_schema_reset`, which performs the same recovery after
11830            // it creates the new physical tables and before its final replay.
11831            self.drop_unencodable_outbox(false, None)?;
11832            if marker == Some(self.schema.version)
11833                && (!self.outbox.is_empty()
11834                    || !self.failed_commits.is_empty()
11835                    || self.has_acknowledged_rows())
11836            {
11837                self.overlay_dirty.set(true);
11838                self.rebuild_overlay()?;
11839            }
11840            self.prune_unknown_subscriptions(false)?;
11841            self.save_subscription_scope_schema();
11842            // The conformance recreate is the in-memory equivalent of reopening
11843            // a durable client. Apply the same startup catch-up contract even
11844            // when the schema itself did not change.
11845            self.enqueue_startup_sync_if_needed()?;
11846            Ok(())
11847        })();
11848        let outcome = match guarded {
11849            Ok(()) => self
11850                .conn
11851                .execute_batch("COMMIT")
11852                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error)),
11853            Err(error) => Err(error),
11854        };
11855        if let Err(error) = outcome {
11856            if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
11857                // Keep a failed rollback's typed storage classification.
11858                let _ = Self::sqlite_failure(&self.storage_failure, rollback);
11859            }
11860            self.schema = prior_schema;
11861            self.subs = prior_subs;
11862            self.outbox = prior_outbox;
11863            self.rejections = prior_rejections;
11864            self.upgrading = prior_upgrading;
11865            self.stopped = prior_stopped;
11866            self.schema_floor = prior_schema_floor;
11867            self.overlay_dirty.restore(prior_overlay_dirty);
11868            self.sync_needed = prior_sync_needed;
11869            self.active_round = prior_active_round;
11870            self.change_queue.truncate(prior_changes);
11871            self.sync_intent_queue.truncate(prior_intents);
11872            self.last_change = prior_last_change;
11873            // Prepared insert statements were derived from the reverted schema.
11874            self.insert_sql.borrow_mut().clear();
11875            return Err(error);
11876        }
11877        Ok(())
11878    }
11879
11880    fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
11881        let startup_work = !self.stopped
11882            && (!self.outbox.is_empty()
11883                || self.has_acknowledged_rows()
11884                || !self.load_pending_evictions()?.is_empty()
11885                || self.subs.iter().any(|sub| sub.state == SubState::Active));
11886        if startup_work {
11887            self.sync_needed = true;
11888            self.sync_intent_queue.push_back(SyncIntent::Interactive);
11889        }
11890        Ok(())
11891    }
11892
11893    /// A native client persists schema-floor stops across process restarts.
11894    /// Once the running app already satisfies that floor, the persisted stop
11895    /// is only stale evidence from an older server round (for example, the
11896    /// server was restarted after catching up to an app that was ahead).
11897    /// Clear it and let the normal startup pull re-negotiate. If the server is
11898    /// still incompatible it will return the floor again in that first round.
11899    fn clear_satisfied_persisted_schema_floor(&mut self) {
11900        let satisfied = self
11901            .schema_floor
11902            .as_ref()
11903            .and_then(|floor| floor.required_schema_version)
11904            .is_some_and(|required| self.schema.version >= required);
11905        if !satisfied {
11906            return;
11907        }
11908        self.schema_floor = None;
11909        self.stopped = false;
11910        self.delete_meta(SCHEMA_FLOOR_KEY);
11911    }
11912
11913    /// RFC 0005 D1: discard an orphan or stale container at boot. The reset
11914    /// path already swept it; this covers a same-version or fresh-install open,
11915    /// which runs no reset and therefore no sweep — plus the D7 TTL, which only
11916    /// an aware binary can apply.
11917    fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
11918        let Some(replica_path) = self.previous_version_replica_path() else {
11919            return Ok(());
11920        };
11921        let max_age_ms = self
11922            .previous_version
11923            .filter(|config| config.enabled)
11924            .map(|config| config.max_age_ms);
11925        reconcile_previous_version_at_boot(
11926            &self.conn,
11927            &replica_path,
11928            self.schema.version,
11929            max_age_ms,
11930            self.clock_now_ms(),
11931        )
11932    }
11933
11934    /// RFC 0005 D3: the sibling container path, or `None` when the replica has
11935    /// no file — an in-memory replica returns an empty path, and a `file:` URI
11936    /// has no plain sibling path — so the feature resolves as off there.
11937    fn previous_version_replica_path(&self) -> Option<String> {
11938        let path = self.conn.path()?;
11939        if path.is_empty() || path.starts_with("file:") {
11940            return None;
11941        }
11942        Some(path.to_owned())
11943    }
11944
11945    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
11946    /// and leaseState. Drops/recreates every synced table from the new
11947    /// schema, resets subscription sync-state (keeping registrations), clears
11948    /// the schema-floor stop state, rewrites the marker, drops outbox commits
11949    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
11950    fn run_schema_reset(&mut self) -> Result<(), String> {
11951        self.begin_observation("syncular_schema_reset")?;
11952        let prior_subs = self.subs.clone();
11953        let prior_outbox = self.outbox.clone();
11954        let prior_rejections = self.rejections.clone();
11955        let prior_upgrading = self.upgrading;
11956        let prior_stopped = self.stopped;
11957        let prior_schema_floor = self.schema_floor.clone();
11958        let prior_overlay_dirty = self.overlay_dirty.snapshot();
11959        let prior_sync_needed = self.sync_needed;
11960        let prior_active_round = self.active_round;
11961        let mut batch = ChangeAccumulator::default();
11962        let result = self
11963            .run_schema_reset_observed(&mut batch, true)
11964            .and_then(|()| self.finish_observation("syncular_schema_reset", batch));
11965        if let Err(error) = result {
11966            self.rollback_observation("syncular_schema_reset");
11967            self.subs = prior_subs;
11968            self.outbox = prior_outbox;
11969            self.rejections = prior_rejections;
11970            self.upgrading = prior_upgrading;
11971            self.stopped = prior_stopped;
11972            self.schema_floor = prior_schema_floor;
11973            self.overlay_dirty.restore(prior_overlay_dirty);
11974            self.sync_needed = prior_sync_needed;
11975            self.active_round = prior_active_round;
11976            return Err(error);
11977        }
11978        Ok(())
11979    }
11980
11981    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
11982        if self.get_meta(LOG_EPOCH_KEY).is_none() {
11983            self.begin_observation("syncular_epoch_acquisition")?;
11984            let previous_sync_needed = self.sync_needed;
11985            if let Err(error) = self.conn.execute(
11986                "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
11987                rusqlite::params![LOG_EPOCH_KEY, log_epoch],
11988            ) {
11989                self.rollback_observation("syncular_epoch_acquisition");
11990                return Err(Self::sqlite_failure(&self.storage_failure, error));
11991            }
11992            self.sync_needed = true;
11993            let batch = ChangeAccumulator {
11994                status: true,
11995                ..ChangeAccumulator::default()
11996            };
11997            if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
11998                self.rollback_observation("syncular_epoch_acquisition");
11999                self.sync_needed = previous_sync_needed;
12000                return Err(error);
12001            }
12002            self.sync_intent_queue.push_back(SyncIntent::Interactive);
12003            return Ok(Vec::new());
12004        }
12005        let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
12006        self.begin_observation("syncular_log_epoch_reset")?;
12007        let prior_subs = self.subs.clone();
12008        let prior_outbox = self.outbox.clone();
12009        let prior_rejections = self.rejections.clone();
12010        let prior_upgrading = self.upgrading;
12011        let prior_stopped = self.stopped;
12012        let prior_schema_floor = self.schema_floor.clone();
12013        let prior_overlay_dirty = self.overlay_dirty.snapshot();
12014        let prior_sync_needed = self.sync_needed;
12015        let prior_active_round = self.active_round;
12016        let mut batch = ChangeAccumulator::default();
12017        let result = self
12018            .run_schema_reset_observed(&mut batch, false)
12019            .and_then(|()| {
12020                self.conn
12021                    .execute(
12022                        "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
12023                        rusqlite::params![LOG_EPOCH_KEY, log_epoch],
12024                    )
12025                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12026                self.sync_needed = true;
12027                batch.status = true;
12028                self.finish_observation("syncular_log_epoch_reset", batch)
12029            });
12030        if let Err(error) = result {
12031            self.rollback_observation("syncular_log_epoch_reset");
12032            self.subs = prior_subs;
12033            self.outbox = prior_outbox;
12034            self.rejections = prior_rejections;
12035            self.upgrading = prior_upgrading;
12036            self.stopped = prior_stopped;
12037            self.schema_floor = prior_schema_floor;
12038            self.overlay_dirty.restore(prior_overlay_dirty);
12039            self.sync_needed = prior_sync_needed;
12040            self.active_round = prior_active_round;
12041            return Err(error);
12042        }
12043        self.sync_intent_queue.push_back(SyncIntent::Interactive);
12044        Ok(resets)
12045    }
12046
12047    fn run_schema_reset_observed(
12048        &mut self,
12049        batch: &mut ChangeAccumulator,
12050        capture: bool,
12051    ) -> Result<(), String> {
12052        // §7.4.2: the reset is the only path that wipes local tables for a
12053        // version bump, and it rewrites the marker last. Re-read the marker
12054        // under this reset's transaction: a replica another process upgraded
12055        // meanwhile refuses here instead of being reset to this client's
12056        // older schema.
12057        read_local_schema_version(&self.conn, self.schema.version)?;
12058        self.conn
12059            .execute_batch(
12060                "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
12061            )
12062            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12063        self.cancel_sync_round();
12064        self.upgrading = true;
12065        batch.status = true;
12066        // RFC 0005 D6: classify the pending outbox against the NEW compiled
12067        // schema BEFORE the wipe, and record the advisory audit. This pass
12068        // drops nothing, so the audit sees the original pending intent; the
12069        // §7.4.4 send-time drop and the intrinsic value recovery later in this
12070        // reset are the paths that remove a commit. The classification is
12071        // shared with the send-time drop so the two cannot disagree about what
12072        // is incompatible.
12073        if self.previous_version.is_some_and(|config| config.enabled) {
12074            let previous_version = self
12075                .get_meta(LOCAL_SCHEMA_VERSION_KEY)
12076                .and_then(|value| value.parse::<i32>().ok())
12077                .unwrap_or(self.schema.version);
12078            let pending = self
12079                .outbox
12080                .iter()
12081                .map(|commit| PendingCommitAudit {
12082                    commit_id: &commit.client_commit_id,
12083                    operations: commit_audit_operations(commit),
12084                })
12085                .collect::<Vec<_>>();
12086            crate::previous_version::write_previous_version_audit(
12087                &self.conn,
12088                &crate::previous_version::build_previous_version_audit(
12089                    &self.schema,
12090                    &pending,
12091                    previous_version,
12092                    self.schema.version,
12093                    self.clock_now_ms(),
12094                ),
12095            );
12096        }
12097        // RFC 0005 D5: the container lives in its own FILE, so the reset can
12098        // never reach it. Sweep first (unconditional — also on the log-epoch
12099        // reset, which captures nothing), then capture BEFORE the wipe drops
12100        // any row. A refused capture records its reason and leaves no file.
12101        if let Some(replica_path) = self.previous_version_replica_path() {
12102            sweep_previous_version_container(&self.conn, &replica_path)?;
12103            if capture {
12104                if let Some(config) = self.previous_version.filter(|config| config.enabled) {
12105                    let previous_version = self
12106                        .get_meta(LOCAL_SCHEMA_VERSION_KEY)
12107                        .and_then(|value| value.parse::<i32>().ok())
12108                        .unwrap_or(self.schema.version);
12109                    capture_previous_version_from_replica(
12110                        &self.conn,
12111                        &replica_path,
12112                        previous_version,
12113                        self.schema.version,
12114                        &config,
12115                        self.clock_now_ms(),
12116                    )?;
12117                }
12118            }
12119        }
12120        for table in &self.schema.tables {
12121            batch.table(&table.name);
12122        }
12123        for (base_key, unit, table) in self.load_registered_window_units() {
12124            batch.window(&base_key, &table, &unit);
12125        }
12126        if capture {
12127            self.prune_unknown_subscriptions(true)?;
12128        }
12129        // The per-table insert SQL is derived from the OLD column lists.
12130        self.insert_sql.borrow_mut().clear();
12131        self.overlay_dirty.set(true);
12132        // Drop every synced table (base + visible) that currently exists —
12133        // discovered from sqlite_master so a bump that adds/removes tables is
12134        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
12135        // and the blob cache) are preserved; base tables are `_syncular_base_*`
12136        // so they are matched explicitly, not by the bookkeeping filter.
12137        // Drop virtual tables first so SQLite removes every FTS shadow table
12138        // atomically instead of the generic discovery tearing it apart.
12139        let virtual_tables: Vec<String> = {
12140            let mut stmt = self
12141                .conn
12142                .prepare(
12143                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
12144                )
12145                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12146            let rows = stmt
12147                .query_map([], |row| row.get::<_, String>(0))
12148                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12149            rows.filter_map(Result::ok)
12150                .filter(|name| is_synced_table_name(name))
12151                .collect()
12152        };
12153        for name in virtual_tables {
12154            self.conn
12155                .execute_batch(&format!(
12156                    "DROP TABLE IF EXISTS {}",
12157                    quote_ident(&format!("_syncular_fts_{name}"))
12158                ))
12159                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12160            self.conn
12161                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
12162                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12163        }
12164        let existing: Vec<String> = {
12165            let mut stmt = self
12166                .conn
12167                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
12168                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12169            let rows = stmt
12170                .query_map([], |row| row.get::<_, String>(0))
12171                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12172            rows.filter_map(Result::ok)
12173                .filter(|name| is_synced_table_name(name))
12174                .collect()
12175        };
12176        for name in &existing {
12177            if let Some(table) = name.strip_prefix("_syncular_base_") {
12178                batch.table(table);
12179            } else if !name.starts_with("_syncular_") {
12180                batch.table(name);
12181            }
12182        }
12183        for name in existing {
12184            self.conn
12185                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
12186                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12187        }
12188        // Recreate the synced tables from the NEW schema.
12189        self.create_synced_tables()?;
12190        // Reset every subscription's sync-state, keeping the registration.
12191        for sub in &mut self.subs {
12192            sub.cursor = -1;
12193            sub.bootstrap_state = None;
12194            sub.effective = None;
12195            sub.state = SubState::Active;
12196            sub.reason_code = None;
12197            sub.synced_once = false;
12198        }
12199        let subs = self.subs.clone();
12200        for sub in &subs {
12201            self.persist_sub(sub)?;
12202        }
12203        // The stop state is over: this client now ships a servable schema.
12204        self.stopped = false;
12205        self.schema_floor = None;
12206        self.delete_meta(SCHEMA_FLOOR_KEY);
12207        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
12208        self.persist_schema_version()?;
12209        self.save_subscription_scope_schema();
12210        // §7.1/§7.4.4: the previous-version capture and audit above saw the
12211        // original pending intent; the synced tables now carry the new schema, so
12212        // recover intrinsically codec-invalid persisted values here, before the
12213        // final replay. A pending upsert that names a column the new schema
12214        // removed stays pending for the send-time `drop_incompatible_outbox`
12215        // prepass. The reset wrapper snapshots and restores the outbox,
12216        // rejections, and change queues, so a failed commit rolls this recovery
12217        // back with the rest of the reset.
12218        // §7.4.4: the enclosing reset observation owns the publication, so the
12219        // recovery contributes its rejection, outcome, and table domains to it.
12220        self.drop_unencodable_outbox(false, Some(batch))?;
12221        // Re-apply the surviving outbox optimistically over the empty tables.
12222        self.rebuild_overlay()?;
12223        Ok(())
12224    }
12225
12226    /// §7.4.4 send-time prepass: a persisted upsert whose values reference a
12227    /// column the current schema lacks (or a removed table) cannot be encoded
12228    /// (`commit_audit_operations` inspects upserts, so a value-free delete stays
12229    /// encodable and is validated by the server). Drop the commit, undo its
12230    /// optimistic projection, and raise a client-local
12231    /// `sync.outbox_incompatible` rejection.
12232    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
12233        let incompatible = self
12234            .outbox
12235            .iter()
12236            .filter(|commit| {
12237                first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
12238            })
12239            .cloned()
12240            .collect::<Vec<_>>();
12241        if incompatible.is_empty() {
12242            return Ok(false);
12243        }
12244        // §7.4.4: one observation covers the durable outcome writes, the outbox
12245        // removal, the in-memory drop, and the overlay rebuild, so a storage
12246        // fault leaves the replica and the client unchanged and publishes no
12247        // change; a successful drop publishes exactly one batch carrying the
12248        // rejections, outcomes, status, and the tables whose projection changed.
12249        let prior_outbox = self.outbox.clone();
12250        let prior_rejections = self.rejections.clone();
12251        let prior_overlay_dirty = self.overlay_dirty.snapshot();
12252        let owns_transaction = self.conn.is_autocommit();
12253        self.begin_observation("syncular_outbox_drop")?;
12254        let mut batch = ChangeAccumulator::default();
12255        let dropped = (|| -> Result<(), String> {
12256            let mut rejections = Vec::new();
12257            for commit in &incompatible {
12258                let results = commit
12259                    .ops
12260                    .iter()
12261                    .enumerate()
12262                    .map(|(op_index, operation)| {
12263                        let rejection = RejectionRecord {
12264                            client_commit_id: commit.client_commit_id.clone(),
12265                            op_index: op_index as i32,
12266                            code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
12267                            message: "the persisted commit cannot encode under the current schema"
12268                                .to_owned(),
12269                            retryable: false,
12270                            details: None,
12271                            operation: Some(CommitOperation::from(operation)),
12272                        };
12273                        rejections.push(rejection.clone());
12274                        CommitOperationOutcome::Error { rejection }
12275                    })
12276                    .collect::<Vec<_>>();
12277                self.persist_commit_outcome(
12278                    &commit.client_commit_id,
12279                    CommitOutcomeStatus::Rejected,
12280                    &results,
12281                    Some(&commit.ops),
12282                )?;
12283                self.delete_outbox_persisted(&commit.client_commit_id)?;
12284                // A removed table has no local projection to invalidate; every
12285                // surviving table the dropped commit touched does.
12286                for operation in &commit.ops {
12287                    if self.schema.table(&operation.table).is_some() {
12288                        batch.table(&operation.table);
12289                    }
12290                }
12291            }
12292            self.prune_commit_outcomes()?;
12293            let incompatible_ids = incompatible
12294                .iter()
12295                .map(|commit| commit.client_commit_id.as_str())
12296                .collect::<BTreeSet<_>>();
12297            self.outbox
12298                .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
12299            self.rejections.extend(rejections);
12300            // The dropped commits' purely-optimistic rows are undone: the
12301            // visible projection is re-derived from the surviving base + outbox.
12302            self.overlay_dirty.set(true);
12303            self.rebuild_overlay()?;
12304            batch.status = true;
12305            batch.rejections = true;
12306            batch.outcomes = true;
12307            Ok(())
12308        })();
12309        let result = dropped.and_then(|()| self.finish_observation("syncular_outbox_drop", batch));
12310        if let Err(error) = result {
12311            // An outer RELEASE is the COMMIT. Roll back that transaction
12312            // directly: retrying RELEASE after ROLLBACK TO still needs the
12313            // reader's lock. A nested observation only rolls back its savepoint.
12314            if owns_transaction {
12315                if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
12316                    let mut failure = self.storage_failure.borrow_mut();
12317                    let failure =
12318                        failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
12319                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
12320                }
12321            } else {
12322                self.rollback_observation("syncular_outbox_drop");
12323            }
12324            self.outbox = prior_outbox;
12325            self.rejections = prior_rejections;
12326            self.overlay_dirty.restore(prior_overlay_dirty);
12327            return Err(error);
12328        }
12329        Ok(true)
12330    }
12331
12332    /// §7.4.3: (re)create the base + visible table pair for every synced
12333    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
12334    fn create_synced_tables(&self) -> Result<(), String> {
12335        for table in &self.schema.tables {
12336            // The base half + the visible half form the synced-table pair. An
12337            // index name is global in SQLite, so the base half's indexes are
12338            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
12339            for (full, index_prefix) in [
12340                (base_table(&table.name), "_syncular_base_"),
12341                (visible_table(&table.name), ""),
12342            ] {
12343                let mut cols: Vec<String> =
12344                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
12345                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
12346                let sql = format!(
12347                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
12348                    cols.join(", "),
12349                    quote_ident(&table.primary_key)
12350                );
12351                self.conn
12352                    .execute(&sql, [])
12353                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12354                // Local secondary indexes (CREATE INDEX subset). Created on
12355                // both halves so mirror reads hit an index on either. Runs on
12356                // both the initial create and the §7.4.3 reset recreate path.
12357                for index in &table.indexes {
12358                    let unique = if index.unique { "UNIQUE " } else { "" };
12359                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
12360                    let cols_sql = index
12361                        .columns
12362                        .iter()
12363                        .map(|c| quote_ident(c))
12364                        .collect::<Vec<_>>()
12365                        .join(", ");
12366                    let index_sql = format!(
12367                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
12368                    );
12369                    self.conn
12370                        .execute(&index_sql, [])
12371                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12372                }
12373            }
12374        }
12375        // FTS exists only on the visible half. It is a local search
12376        // projection, never a synced/base table or a wire-schema member.
12377        for table in &self.schema.tables {
12378            for index in &table.fts_indexes {
12379                self.create_fts_projection(table, index)?;
12380            }
12381        }
12382        Ok(())
12383    }
12384
12385    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
12386        let count: i64 = self
12387            .conn
12388            .query_row(
12389                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
12390                rusqlite::params![index.name],
12391                |row| row.get(0),
12392            )
12393            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12394        Ok(count > 0)
12395    }
12396
12397    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
12398        for suffix in ["ai", "ad", "au"] {
12399            self.conn
12400                .execute(
12401                    &format!(
12402                        "DROP TRIGGER IF EXISTS {}",
12403                        quote_ident(&format!("{}_{suffix}", index.name))
12404                    ),
12405                    [],
12406                )
12407                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12408        }
12409        Ok(())
12410    }
12411
12412    fn create_fts_triggers(
12413        &self,
12414        table: &TableSchema,
12415        index: &FtsIndexSchema,
12416    ) -> Result<(), String> {
12417        let fts = quote_ident(&index.name);
12418        let source = visible_table(&table.name);
12419        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
12420        let pk = quote_ident(&table.primary_key);
12421        let projection_columns = std::iter::once(source_id.clone())
12422            .chain(index.columns.iter().map(|column| quote_ident(column)))
12423            .collect::<Vec<_>>()
12424            .join(", ");
12425        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
12426            .chain(
12427                index
12428                    .columns
12429                    .iter()
12430                    .map(|column| format!("new.{}", quote_ident(column))),
12431            )
12432            .collect::<Vec<_>>()
12433            .join(", ");
12434        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
12435        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)");
12436        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)");
12437        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})");
12438        let bi = quote_ident(&format!("{}_bi", index.name));
12439        let ai = quote_ident(&format!("{}_ai", index.name));
12440        let ad = quote_ident(&format!("{}_ad", index.name));
12441        let au = quote_ident(&format!("{}_au", index.name));
12442        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
12443        let sql = format!(
12444            "DROP TRIGGER IF EXISTS {bi};
12445             DROP TRIGGER IF EXISTS {ai};
12446             DROP TRIGGER IF EXISTS {ad};
12447             DROP TRIGGER IF EXISTS {au};
12448             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
12449             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
12450             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
12451             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
12452        );
12453        self.conn
12454            .execute_batch(&sql)
12455            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12456    }
12457
12458    fn rebuild_fts_projection(
12459        &self,
12460        table: &TableSchema,
12461        index: &FtsIndexSchema,
12462    ) -> Result<(), String> {
12463        let fts = quote_ident(&index.name);
12464        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
12465        let pk = quote_ident(&table.primary_key);
12466        let indexed_columns = index
12467            .columns
12468            .iter()
12469            .map(|column| quote_ident(column))
12470            .collect::<Vec<_>>();
12471        let projection_columns = std::iter::once(source_id.clone())
12472            .chain(indexed_columns.iter().cloned())
12473            .collect::<Vec<_>>()
12474            .join(", ");
12475        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
12476        let sql = format!(
12477            "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};",
12478            columns = indexed_columns.join(", "),
12479            source = visible_table(&table.name),
12480        );
12481        self.conn
12482            .execute_batch(&sql)
12483            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12484    }
12485
12486    fn create_fts_projection(
12487        &self,
12488        table: &TableSchema,
12489        index: &FtsIndexSchema,
12490    ) -> Result<(), String> {
12491        self.begin_observation("syncular_fts_schema")?;
12492        let result = (|| {
12493            let existed = self.fts_projection_exists(index)?;
12494            let tokenizer = index.tokenize.replace('\'', "''");
12495            let columns = index
12496                .columns
12497                .iter()
12498                .map(|column| quote_ident(column))
12499                .collect::<Vec<_>>()
12500                .join(", ");
12501            let sql = format!(
12502            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
12503            fts = quote_ident(&index.name),
12504            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
12505        );
12506            self.conn.execute(&sql, []).map_err(|error| {
12507                format!(
12508                    "cannot create local FTS5 projection {:?}: {error}",
12509                    index.name
12510                )
12511            })?;
12512            let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
12513            let mapped: bool = self
12514                .conn
12515                .query_row(
12516                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
12517                    [format!("_syncular_fts_{}", index.name)],
12518                    |row| row.get(0),
12519                )
12520                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12521            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))?;
12522            if !mapped && existed {
12523                self.conn
12524                    .execute_batch(&format!(
12525                        "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
12526                        quote_ident(FTS_SOURCE_ID_COLUMN),
12527                        quote_ident(&index.name)
12528                    ))
12529                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12530            }
12531            self.create_fts_triggers(table, index)?;
12532            if !existed {
12533                self.rebuild_fts_projection(table, index)?;
12534            }
12535            Ok(())
12536        })();
12537        if let Err(error) = result {
12538            self.rollback_observation("syncular_fts_schema");
12539            return Err(error);
12540        }
12541        if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
12542            self.rollback_observation("syncular_fts_schema");
12543            return Err(error.to_string());
12544        }
12545        Ok(())
12546    }
12547
12548    // -- persistence write-through --------------------------------------------
12549
12550    fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
12551        let state = serde_json::json!({
12552            "requested": scope_map_to_json(&sub.requested),
12553            "params": sub.params,
12554            "cursor": sub.cursor,
12555            "bootstrapState": sub.bootstrap_state,
12556            "status": sub.state.name(),
12557            "reasonCode": sub.reason_code,
12558            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
12559            "syncedOnce": sub.synced_once,
12560        });
12561        self.conn.execute(
12562            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
12563            rusqlite::params![sub.id, sub.table, state.to_string()],
12564        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12565    }
12566
12567    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
12568        let ops: Vec<Value> = commit
12569            .ops
12570            .iter()
12571            .map(|op| {
12572                serde_json::json!({
12573                    "op": if op.upsert { "upsert" } else { "delete" },
12574                    "table": op.table,
12575                    "rowId": op.row_id,
12576                    "baseVersion": op.base_version,
12577                    "values": op.values.clone().map(Value::Object),
12578                })
12579            })
12580            .collect();
12581        self.conn
12582            .execute(
12583                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
12584                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
12585            )
12586            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12587        for operation in &commit.ops {
12588            let Some(values) = &operation.values else {
12589                continue;
12590            };
12591            let Some(table) = self.schema.table(&operation.table) else {
12592                continue;
12593            };
12594            for column in table
12595                .columns
12596                .iter()
12597                .filter(|column| column.ty == ColumnType::BlobRef)
12598            {
12599                let Some(Value::String(raw)) = values.get(&column.name) else {
12600                    continue;
12601                };
12602                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
12603                    continue;
12604                };
12605                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
12606                    continue;
12607                };
12608                self.conn
12609                    .execute(
12610                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
12611                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
12612                        rusqlite::params![commit.client_commit_id, blob_id],
12613                    )
12614                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12615            }
12616        }
12617        Ok(())
12618    }
12619
12620    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
12621        if let Some(commit) = self
12622            .outbox
12623            .iter()
12624            .find(|commit| commit.client_commit_id == client_commit_id)
12625        {
12626            for operation in &commit.ops {
12627                self.overlay_dirty.table(&operation.table);
12628            }
12629        }
12630
12631        self.conn
12632            .execute(
12633                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
12634                rusqlite::params![client_commit_id],
12635            )
12636            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12637        self.conn
12638            .execute(
12639                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
12640                rusqlite::params![client_commit_id],
12641            )
12642            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12643        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)", [])
12644            .map(|_| ())
12645            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12646    }
12647
12648    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
12649        match status {
12650            CommitOutcomeStatus::Applied => "applied",
12651            CommitOutcomeStatus::Cached => "cached",
12652            CommitOutcomeStatus::Conflict => "conflict",
12653            CommitOutcomeStatus::Rejected => "rejected",
12654        }
12655    }
12656
12657    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
12658        match resolution {
12659            CommitOutcomeResolution::Active => "active",
12660            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
12661            CommitOutcomeResolution::Superseded => "superseded",
12662            CommitOutcomeResolution::Dismissed => "dismissed",
12663        }
12664    }
12665
12666    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
12667        match value {
12668            "applied" => Ok(CommitOutcomeStatus::Applied),
12669            "cached" => Ok(CommitOutcomeStatus::Cached),
12670            "conflict" => Ok(CommitOutcomeStatus::Conflict),
12671            "rejected" => Ok(CommitOutcomeStatus::Rejected),
12672            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
12673        }
12674    }
12675
12676    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
12677        match value {
12678            "active" => Ok(CommitOutcomeResolution::Active),
12679            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
12680            "superseded" => Ok(CommitOutcomeResolution::Superseded),
12681            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
12682            _ => Err(format!(
12683                "invalid persisted commit outcome resolution {value:?}"
12684            )),
12685        }
12686    }
12687
12688    fn persist_commit_outcome(
12689        &self,
12690        client_commit_id: &str,
12691        status: CommitOutcomeStatus,
12692        results: &[CommitOperationOutcome],
12693        operations: Option<&[OutboxOp]>,
12694    ) -> Result<(), String> {
12695        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
12696        let operations_json = operations
12697            .map(|items| {
12698                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
12699            })
12700            .transpose()
12701            .map_err(|error| error.to_string())?;
12702        self.conn
12703            .execute(
12704                "INSERT INTO _syncular_commit_outcomes (
12705                   client_commit_id, status, recorded_at_ms, results_json,
12706                   operations_json, resolution
12707                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
12708                rusqlite::params![
12709                    client_commit_id,
12710                    Self::outcome_status_name(status),
12711                    self.clock_now_ms(),
12712                    results_json,
12713                    operations_json
12714                ],
12715            )
12716            .map(|_| ())
12717            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12718    }
12719
12720    /// §7.2: the journal holds a stored cell only in its canonical shape. A cell
12721    /// the byte-envelope rule refuses cannot be journaled verbatim.
12722    fn stored_cell_is_representable(cell: &Value) -> bool {
12723        cell.is_null()
12724            || cell.is_string()
12725            || cell.is_boolean()
12726            || cell.is_number()
12727            || is_canonical_bytes_envelope(cell)
12728    }
12729
12730    fn stored_row_is_representable(value: &Value) -> bool {
12731        value
12732            .as_object()
12733            .is_some_and(|row| row.values().all(Self::stored_cell_is_representable))
12734    }
12735
12736    fn stored_operation_is_representable(value: &Value) -> bool {
12737        value.get("table").is_some_and(Value::is_string)
12738            && value.get("rowId").is_some_and(Value::is_string)
12739            && value.get("baseVersion").is_none_or(|v| {
12740                v.as_i64()
12741                    .is_some_and(|n| n.unsigned_abs() <= MAX_JS_SAFE_INTEGER)
12742            })
12743            && match value.get("op").and_then(Value::as_str) {
12744                Some("upsert") => value
12745                    .get("values")
12746                    .is_some_and(Self::stored_row_is_representable),
12747                Some("delete") => value.get("values").is_none(),
12748                _ => false,
12749            }
12750    }
12751
12752    /// §7.2/§7.4.4: a dropped commit keeps its operation envelope in the journal
12753    /// only when the journal reader accepts the serialized operation, which is the
12754    /// rule `outcome_from_row` applies to this same row later.
12755    fn outbox_op_is_journalable(operation: &OutboxOp) -> bool {
12756        serde_json::to_value(CommitOperation::from(operation))
12757            .is_ok_and(|value| Self::stored_operation_is_representable(&value))
12758    }
12759
12760    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
12761        let StoredCommitOutcomeRow {
12762            sequence,
12763            client_commit_id,
12764            status,
12765            recorded_at_ms,
12766            results_json,
12767            operations_json,
12768            resolution,
12769            resolved_at_ms,
12770            replacement_client_commit_id,
12771        } = row;
12772        if sequence.unsigned_abs() > MAX_JS_SAFE_INTEGER
12773            || recorded_at_ms.unsigned_abs() > MAX_JS_SAFE_INTEGER
12774            || resolved_at_ms.is_some_and(|value| value.unsigned_abs() > MAX_JS_SAFE_INTEGER)
12775        {
12776            return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned());
12777        }
12778        let invalid = || "sync.local_corrupt: persisted commit outcome is invalid".to_owned();
12779        let stored: Vec<Value> = serde_json::from_str(&results_json).map_err(|_| invalid())?;
12780        for result in &stored {
12781            let nested = match result.get("status").and_then(Value::as_str) {
12782                Some("conflict") => result.get("conflict"),
12783                Some("error") => result.get("rejection"),
12784                _ => None,
12785            };
12786            if nested
12787                .and_then(|record| record.get("operation"))
12788                .is_some_and(|op| !Self::stored_operation_is_representable(op))
12789                || (result.get("status").and_then(Value::as_str) == Some("conflict")
12790                    && nested
12791                        .and_then(|record| record.get("serverRow"))
12792                        .is_some_and(|row| !Self::stored_row_is_representable(row)))
12793            {
12794                return Err(invalid());
12795            }
12796        }
12797        let results: Vec<CommitOperationOutcome> =
12798            serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())?;
12799        let has_conflict = results
12800            .iter()
12801            .any(|result| matches!(result, CommitOperationOutcome::Conflict { .. }));
12802        let has_error = results
12803            .iter()
12804            .any(|result| matches!(result, CommitOperationOutcome::Error { .. }));
12805        let status = Self::parse_outcome_status(&status).map_err(|_| invalid())?;
12806        let redacted = results.is_empty() && operations_json.is_none();
12807        if match status {
12808            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => has_conflict || has_error,
12809            CommitOutcomeStatus::Conflict => !(has_conflict || redacted),
12810            CommitOutcomeStatus::Rejected => has_conflict || !(has_error || redacted),
12811        } {
12812            return Err(invalid());
12813        }
12814        let operations: Option<Vec<CommitOperation>> = operations_json
12815            .map(|raw| {
12816                let stored: Vec<Value> = serde_json::from_str(&raw).map_err(|_| invalid())?;
12817                if !stored.iter().all(Self::stored_operation_is_representable) {
12818                    return Err(invalid());
12819                }
12820                serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())
12821            })
12822            .transpose()?;
12823        for result in &results {
12824            match result {
12825                CommitOperationOutcome::Conflict { conflict }
12826                    if conflict.server_version.unsigned_abs() > MAX_JS_SAFE_INTEGER =>
12827                {
12828                    return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned())
12829                }
12830                CommitOperationOutcome::Error { rejection } => {
12831                    if let Some(details) = &rejection.details {
12832                        let encoded = serde_json::to_string(details).map_err(|_| {
12833                            "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
12834                        })?;
12835                        RejectionDetails::parse(&encoded).map_err(|_| {
12836                            "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
12837                        })?;
12838                    }
12839                }
12840                _ => {}
12841            }
12842        }
12843        Ok(CommitOutcome {
12844            sequence,
12845            client_commit_id,
12846            status,
12847            recorded_at_ms,
12848            results,
12849            operations,
12850            retained_rows: None,
12851            resolution: Self::parse_outcome_resolution(&resolution)?,
12852            resolved_at_ms,
12853            replacement_client_commit_id,
12854        })
12855    }
12856
12857    /// Resolve declared unique keys through SQLite, including affinity and NULL semantics.
12858    fn unique_conflicts(
12859        &self,
12860        table: &TableSchema,
12861        values: &Map<String, Value>,
12862        base: bool,
12863    ) -> Result<Vec<RetainedUniqueConflict>, String> {
12864        let source = if base {
12865            base_table(&table.name)
12866        } else {
12867            visible_table(&table.name)
12868        };
12869        let mut conflicts = Vec::new();
12870        for index in table.indexes.iter().filter(|index| index.unique) {
12871            let mut params = vec![json_param_to_sql(
12872                values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
12873            )?];
12874            for name in &index.columns {
12875                let column = table
12876                    .columns
12877                    .iter()
12878                    .find(|column| column.name == *name)
12879                    .ok_or("sync.local_corrupt")?;
12880                let cell = json_to_column_value(column, values.get(name))?;
12881                params.push(owned_sql_value(RowParam::Cell(&cell))?);
12882            }
12883            let sql = format!(
12884                "SELECT * FROM {source} WHERE {} != ? AND {}",
12885                quote_ident(&table.primary_key),
12886                index
12887                    .columns
12888                    .iter()
12889                    .map(|column| format!("{} = ?", quote_ident(column)))
12890                    .collect::<Vec<_>>()
12891                    .join(" AND ")
12892            );
12893            let mut statement = self
12894                .conn
12895                .prepare_cached(&sql)
12896                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12897            let records = statement
12898                .query_map(rusqlite::params_from_iter(params), |record| {
12899                    let values = table
12900                        .columns
12901                        .iter()
12902                        .enumerate()
12903                        .map(|(index, column)| {
12904                            let value = sql_ref_to_json(column, record.get_ref(index)?);
12905                            json_to_column_value(column, Some(&value)).map_err(|message| {
12906                                rusqlite::Error::ToSqlConversionFailure(message.into())
12907                            })
12908                        })
12909                        .collect::<Result<Row, _>>()?;
12910                    Ok((values, record.get::<_, i64>(table.columns.len())?))
12911                })
12912                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12913            for record in records {
12914                let (row, version) =
12915                    record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12916                let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
12917                conflicts.push(RetainedUniqueConflict {
12918                    index: index.name.clone(),
12919                    columns: index.columns.clone(),
12920                    row_id,
12921                    server_row: table
12922                        .columns
12923                        .iter()
12924                        .zip(&row)
12925                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
12926                        .collect(),
12927                    server_version: version,
12928                });
12929            }
12930        }
12931        Ok(conflicts)
12932    }
12933
12934    fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
12935        if let Some((commit, initial)) = self
12936            .failed_commits
12937            .iter()
12938            .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
12939        {
12940            let mut rows = Vec::new();
12941            for operation in &commit.ops {
12942                let table = self
12943                    .schema
12944                    .table(&operation.table)
12945                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
12946                let key =
12947                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
12948                        .map_err(|_| "sync.local_corrupt".to_owned())?;
12949                let base = self.stored_row(table, &operation.row_id, true)?;
12950                rows.push(RetainedCommitRow {
12951                    table: operation.table.clone(),
12952                    row_id: operation.row_id.clone(),
12953                    unique_conflicts: if operation.upsert {
12954                        self.unique_conflicts(
12955                            table,
12956                            initial
12957                                .get(&key)
12958                                .and_then(Value::as_object)
12959                                .or(operation.values.as_ref())
12960                                .ok_or("sync.local_corrupt")?,
12961                            true,
12962                        )?
12963                    } else {
12964                        Vec::new()
12965                    },
12966                    local_row: if operation.upsert {
12967                        initial
12968                            .get(&key)
12969                            .and_then(Value::as_object)
12970                            .cloned()
12971                            .or_else(|| operation.values.clone())
12972                    } else {
12973                        None
12974                    },
12975                    server_row: base.as_ref().map(|(values, _)| {
12976                        table
12977                            .columns
12978                            .iter()
12979                            .zip(values)
12980                            .map(|(column, value)| {
12981                                (column.name.clone(), column_value_to_json(value))
12982                            })
12983                            .collect()
12984                    }),
12985                    server_version: base.map(|(_, version)| version),
12986                });
12987            }
12988            outcome.retained_rows = Some(rows);
12989        }
12990        Ok(outcome)
12991    }
12992
12993    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
12994        let row = self
12995            .conn
12996            .query_row(
12997                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
12998                        resolution, resolved_at_ms, replacement_client_commit_id
12999                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
13000                rusqlite::params![client_commit_id],
13001                |row| {
13002                    Ok(StoredCommitOutcomeRow {
13003                        sequence: row.get(0)?,
13004                        client_commit_id: row.get(1)?,
13005                        status: row.get(2)?,
13006                        recorded_at_ms: row.get(3)?,
13007                        results_json: row.get(4)?,
13008                        operations_json: row.get(5)?,
13009                        resolution: row.get(6)?,
13010                        resolved_at_ms: row.get(7)?,
13011                        replacement_client_commit_id: row.get(8)?,
13012                    })
13013                },
13014            )
13015            .optional()
13016            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13017        row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
13018            .transpose()
13019    }
13020
13021    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
13022        if query.limit == Some(0) {
13023            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
13024        }
13025        let mut sql = String::from(
13026            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
13027                    resolution, resolved_at_ms, replacement_client_commit_id
13028               FROM _syncular_commit_outcomes",
13029        );
13030        if query.active_only {
13031            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
13032        }
13033        sql.push_str(" ORDER BY seq DESC");
13034        if let Some(limit) = query.limit {
13035            sql.push_str(&format!(" LIMIT {limit}"));
13036        }
13037        let mut stmt = self
13038            .conn
13039            .prepare(&sql)
13040            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13041        let rows = stmt
13042            .query_map([], |row| {
13043                Ok(StoredCommitOutcomeRow {
13044                    sequence: row.get(0)?,
13045                    client_commit_id: row.get(1)?,
13046                    status: row.get(2)?,
13047                    recorded_at_ms: row.get(3)?,
13048                    results_json: row.get(4)?,
13049                    operations_json: row.get(5)?,
13050                    resolution: row.get(6)?,
13051                    resolved_at_ms: row.get(7)?,
13052                    replacement_client_commit_id: row.get(8)?,
13053                })
13054            })
13055            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13056        let mut outcomes = Vec::new();
13057        for row in rows {
13058            outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
13059                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
13060            )?)?);
13061        }
13062        Ok(outcomes)
13063    }
13064
13065    fn prune_commit_outcomes(&self) -> Result<(), String> {
13066        #[cfg(test)]
13067        self.outcome_prune_count
13068            .set(self.outcome_prune_count.get() + 1);
13069        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
13070        let count = self
13071            .conn
13072            .query_row(
13073                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
13074                [],
13075                |row| row.get::<_, i64>(0),
13076            )
13077            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13078            as usize;
13079        let excess = count.saturating_sub(max_entries);
13080        if excess == 0 {
13081            return Ok(());
13082        }
13083        let mut stmt = self
13084            .conn
13085            .prepare(
13086                "SELECT seq FROM _syncular_commit_outcomes
13087                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
13088                  ORDER BY seq ASC LIMIT ?1",
13089            )
13090            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13091        let rows = stmt
13092            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
13093            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13094        let sequences = rows
13095            .collect::<Result<Vec<_>, _>>()
13096            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13097        drop(stmt);
13098        for sequence in sequences {
13099            self.conn
13100                .execute(
13101                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
13102                    rusqlite::params![sequence],
13103                )
13104                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13105        }
13106        Ok(())
13107    }
13108
13109    // -- driver surface ---------------------------------------------------------
13110
13111    pub fn subscribe(
13112        &mut self,
13113        id: String,
13114        table: String,
13115        scopes: Vec<(String, Vec<String>)>,
13116        params: Option<String>,
13117    ) -> Result<(), String> {
13118        if self.schema.table(&table).is_none() {
13119            return Err(format!("unknown table {table:?}"));
13120        }
13121        if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
13122            let same_intent = existing.table == table
13123                && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
13124                && existing.params == params;
13125            if same_intent {
13126                return Ok(());
13127            }
13128            return Err(
13129                "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
13130                    .to_owned(),
13131            );
13132        }
13133        let sub = Subscription {
13134            id: id.clone(),
13135            table,
13136            requested: scopes,
13137            params,
13138            cursor: -1,
13139            bootstrap_state: None,
13140            state: SubState::Active,
13141            reason_code: None,
13142            effective: None,
13143            synced_once: false,
13144        };
13145        self.persist_sub(&sub)?;
13146        self.subs.push(sub);
13147        Ok(())
13148    }
13149
13150    pub fn unsubscribe(&mut self, id: &str) {
13151        if self.subs.iter().any(|s| s.id == id) {
13152            self.cancel_sync_round();
13153        }
13154        self.subs.retain(|s| s.id != id);
13155        let _ = self.conn.execute(
13156            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
13157            rusqlite::params![id],
13158        );
13159    }
13160
13161    // -- windowed subscriptions (§4.8) ------------------------------------------
13162
13163    /// §4.8: set the live window units for a base — a value-sharded family
13164    /// of subscriptions, one per unit. Added units get fresh subscriptions
13165    /// (image-lane bootstrap on the next sync); removed units are
13166    /// unsubscribed with their first eviction chunk in one transaction (E1–E4).
13167    /// Idempotent; re-entry cancels any deferred eviction.
13168    pub fn set_window(
13169        &mut self,
13170        base: &WindowBase,
13171        units: &[String],
13172    ) -> Result<CommandEffects, String> {
13173        let table = self
13174            .schema
13175            .table(&base.table)
13176            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
13177        if table.scope_column(&base.variable).is_none() {
13178            return Err(format!(
13179                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
13180                base.table, base.variable
13181            ));
13182        }
13183        let base_key = window_base_key(base);
13184        let wanted: std::collections::HashSet<&String> = units.iter().collect();
13185        let live = self.load_window_units(&base_key);
13186        self.begin_observation("syncular_window")?;
13187        let mut batch = ChangeAccumulator::default();
13188        let mut changed = false;
13189
13190        let prior_subs = self.subs.clone();
13191        let widened = (|| {
13192            // Widen: units wanted but not live → fresh subscription + registry row.
13193            for unit in units {
13194                if live.iter().any(|(u, _)| u == unit) {
13195                    continue;
13196                }
13197                let sub_id = derive_sub_id(base, unit);
13198                self.delete_pending_evict(&sub_id)?;
13199                self.insert_window_unit(&base_key, unit, &sub_id)?;
13200                self.subscribe(
13201                    sub_id,
13202                    base.table.clone(),
13203                    unit_scopes(base, unit),
13204                    base.params.clone(),
13205                )?;
13206                batch.window(&base_key, &base.table, unit);
13207                changed = true;
13208            }
13209
13210            self.finish_observation("syncular_window", batch)
13211        })();
13212        if let Err(error) = widened {
13213            self.rollback_observation("syncular_window");
13214            self.subs = prior_subs;
13215            return Err(error);
13216        }
13217        for (unit, sub_id) in live {
13218            if wanted.contains(&unit) {
13219                continue;
13220            }
13221            self.rebuild_overlay_if_dirty()?;
13222            let prior_subs = self.subs.clone();
13223            self.begin_observation("syncular_window")?;
13224            let effective = self
13225                .subs
13226                .iter()
13227                .find(|sub| sub.id == sub_id)
13228                .and_then(|sub| sub.effective.clone())
13229                .unwrap_or_else(|| unit_scopes(base, &unit));
13230            let mut batch = ChangeAccumulator::default();
13231            self.record_scope_map(&mut batch, &base.table, &effective);
13232            batch.window(&base_key, &base.table, &unit);
13233            let result = self.evict_unit(&base_key, base, &unit, &sub_id);
13234            let remaining = match result {
13235                Ok(remaining) => remaining,
13236                Err(error) => {
13237                    self.rollback_observation("syncular_window");
13238                    self.subs = prior_subs;
13239                    return Err(error);
13240                }
13241            };
13242            if let Err(error) = self.finish_observation("syncular_window", batch) {
13243                self.rollback_observation("syncular_window");
13244                self.subs = prior_subs;
13245                return Err(error);
13246            }
13247            if remaining {
13248                self.drain_pending_evictions()?;
13249            }
13250            changed = true;
13251        }
13252        Ok(if changed {
13253            CommandEffects::interactive()
13254        } else {
13255            CommandEffects::none()
13256        })
13257    }
13258
13259    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
13260    /// the subset still bootstrap-pending. Registration alone is not
13261    /// completeness — a unit is pending until its subscription completes a
13262    /// bootstrap round (cursor advances past -1 with no resume token held).
13263    pub fn window_state(&self, base: &WindowBase) -> WindowState {
13264        let mut units = Vec::new();
13265        let mut pending = Vec::new();
13266        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
13267            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
13268                Some(sub) => {
13269                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
13270                }
13271                None => true,
13272            };
13273            if is_pending {
13274                pending.push(unit.clone());
13275            }
13276            units.push(unit);
13277        }
13278        WindowState { units, pending }
13279    }
13280
13281    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
13282        let mut stmt = match self
13283            .conn
13284            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
13285        {
13286            Ok(stmt) => stmt,
13287            Err(_) => return Vec::new(),
13288        };
13289        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
13290            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
13291        });
13292        match rows {
13293            Ok(rows) => rows.filter_map(Result::ok).collect(),
13294            Err(_) => Vec::new(),
13295        }
13296    }
13297
13298    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
13299        let mut stmt = match self.conn.prepare(
13300            "SELECT windows.base, windows.unit, subscriptions.tbl
13301               FROM _syncular_windows AS windows
13302               JOIN _syncular_subscriptions AS subscriptions
13303                 ON subscriptions.id = windows.sub_id
13304               ORDER BY windows.base, windows.unit",
13305        ) {
13306            Ok(stmt) => stmt,
13307            Err(_) => return Vec::new(),
13308        };
13309        let rows = stmt.query_map([], |row| {
13310            Ok((
13311                row.get::<_, String>(0)?,
13312                row.get::<_, String>(1)?,
13313                row.get::<_, String>(2)?,
13314            ))
13315        });
13316        match rows {
13317            Ok(rows) => rows.filter_map(Result::ok).collect(),
13318            Err(_) => Vec::new(),
13319        }
13320    }
13321
13322    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
13323        self.conn
13324            .query_row(
13325                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
13326                rusqlite::params![sub_id],
13327                |row| Ok((row.get(0)?, row.get(1)?)),
13328            )
13329            .ok()
13330    }
13331
13332    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
13333        self.conn
13334            .execute(
13335                "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
13336                rusqlite::params![base_key, unit, sub_id],
13337            )
13338            .map(|_| ())
13339            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13340    }
13341
13342    fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
13343        self.conn
13344            .execute(
13345                "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
13346                rusqlite::params![base_key, unit],
13347            )
13348            .map(|_| ())
13349            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13350    }
13351
13352    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
13353    /// Deletes the unit's rows except those pinned by a pending outbox
13354    /// commit (E1); records a deferred eviction if any pin remains; discards
13355    /// the subscription's cursor/resume/effective-echo (E3) and version
13356    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
13357    /// nothing.
13358    fn evict_unit(
13359        &mut self,
13360        base_key: &str,
13361        base: &WindowBase,
13362        unit: &str,
13363        sub_id: &str,
13364    ) -> Result<bool, String> {
13365        let effective = self
13366            .subs
13367            .iter()
13368            .find(|s| s.id == sub_id)
13369            .and_then(|s| s.effective.clone())
13370            .unwrap_or_else(|| unit_scopes(base, unit));
13371        let pinned = self.pinned_row_ids(&base.table);
13372        let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
13373        self.delete_window_unit(base_key, unit)?;
13374        self.conn
13375            .execute(
13376                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
13377                [sub_id],
13378            )
13379            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13380        self.subs.retain(|sub| sub.id != sub_id);
13381        if deferred {
13382            self.save_pending_evict(sub_id, &base.table, &effective)?;
13383        } else {
13384            self.delete_pending_evict(sub_id)?;
13385        }
13386        Ok(remaining)
13387    }
13388
13389    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
13390    /// primary keys. Returns (more unpinned work, any scoped rows remain).
13391    /// A missing scope-column mapping fails without deleting rows.
13392    fn evict_scope_rows(
13393        &self,
13394        table_name: &str,
13395        effective: &[(String, Vec<String>)],
13396        pinned: &std::collections::HashSet<String>,
13397    ) -> Result<(bool, bool), String> {
13398        if effective.is_empty() {
13399            return Ok((false, false));
13400        }
13401        let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
13402        let mut clauses = Vec::new();
13403        let mut params: Vec<SqlValue> = Vec::new();
13404        for (variable, values) in effective {
13405            let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
13406            if values.is_empty() {
13407                return Ok((false, false));
13408            }
13409            let holes = values
13410                .iter()
13411                .map(|v| {
13412                    params.push(SqlValue::Text(v.clone()));
13413                    "?"
13414                })
13415                .collect::<Vec<_>>()
13416                .join(", ");
13417            clauses.push(format!("{} IN ({holes})", quote_ident(column)));
13418        }
13419        let scope = clauses.join(" AND ");
13420        let scope_params = params.len();
13421        let pk = quote_ident(&table.primary_key);
13422        if !pinned.is_empty() {
13423            let holes = pinned
13424                .iter()
13425                .map(|id| {
13426                    params.push(SqlValue::Text(id.clone()));
13427                    "?"
13428                })
13429                .collect::<Vec<_>>()
13430                .join(", ");
13431            clauses.push(format!("{pk} NOT IN ({holes})"));
13432        }
13433        let predicate = clauses.join(" AND ");
13434        let base = base_table(table_name);
13435        let visible = visible_table(table_name);
13436        let mut ids: Vec<SqlValue> = Vec::new();
13437        for source in [&base, &visible] {
13438            let mut stmt = self
13439                .conn
13440                .prepare_cached(&format!(
13441                    "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
13442                ))
13443                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13444            ids = stmt
13445                .query_map(rusqlite::params_from_iter(&params), |row| row.get(0))
13446                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13447                .collect::<Result<_, _>>()
13448                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13449            if !ids.is_empty() {
13450                break;
13451            }
13452        }
13453        if !ids.is_empty() {
13454            for id in &ids {
13455                self.conn
13456                    .execute(
13457                        "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
13458                        rusqlite::params![table_name, id],
13459                    )
13460                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13461            }
13462            let holes = vec!["?"; ids.len()].join(", ");
13463            for source in [&base, &visible] {
13464                self.conn
13465                    .execute(
13466                        &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
13467                        rusqlite::params_from_iter(&ids),
13468                    )
13469                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13470            }
13471        }
13472        let mut remaining = false;
13473        let mut deferred = false;
13474        for source in [&base, &visible] {
13475            remaining |= self
13476                .conn
13477                .query_row(
13478                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
13479                    rusqlite::params_from_iter(&params),
13480                    |row| row.get::<_, bool>(0),
13481                )
13482                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13483            deferred |= self
13484                .conn
13485                .query_row(
13486                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
13487                    rusqlite::params_from_iter(&params[..scope_params]),
13488                    |row| row.get::<_, bool>(0),
13489                )
13490                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13491        }
13492        Ok((remaining, deferred))
13493    }
13494
13495    /// Resume committed eviction chunks, retaining outbox pins and their durable marker.
13496    fn drain_pending_evictions(&mut self) -> Result<(), String> {
13497        let pending = self.load_pending_evictions()?;
13498        if pending.is_empty() {
13499            return Ok(());
13500        }
13501        self.rebuild_overlay_if_dirty()?;
13502        for (sub_id, table_name, effective) in pending {
13503            if self.schema.table(&table_name).is_none() {
13504                self.delete_pending_evict(&sub_id)?;
13505                continue;
13506            }
13507            loop {
13508                std::thread::yield_now();
13509                self.begin_observation("syncular_evict")?;
13510                let pinned = self.pinned_row_ids(&table_name);
13511                let result = self.evict_scope_rows(&table_name, &effective, &pinned);
13512                let (remaining, deferred) = match result {
13513                    Ok(result) => result,
13514                    Err(error) => {
13515                        self.rollback_observation("syncular_evict");
13516                        return Err(error);
13517                    }
13518                };
13519                if !deferred {
13520                    if let Err(error) = self.delete_pending_evict(&sub_id) {
13521                        self.rollback_observation("syncular_evict");
13522                        return Err(error);
13523                    }
13524                }
13525                let mut batch = ChangeAccumulator::default();
13526                self.record_scope_map(&mut batch, &table_name, &effective);
13527                if let Err(error) = self.finish_observation("syncular_evict", batch) {
13528                    self.rollback_observation("syncular_evict");
13529                    return Err(error);
13530                }
13531                if !remaining {
13532                    break;
13533                }
13534            }
13535        }
13536        Ok(())
13537    }
13538
13539    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
13540    /// commit — rows that MUST NOT be evicted until the commit drains.
13541    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
13542        let mut pinned = std::collections::HashSet::new();
13543        for commit in self
13544            .outbox
13545            .iter()
13546            .chain(self.failed_commits.iter().map(|(commit, _)| commit))
13547        {
13548            for op in &commit.ops {
13549                if op.table == table {
13550                    pinned.insert(op.row_id.clone());
13551                }
13552            }
13553        }
13554        pinned
13555    }
13556
13557    fn save_pending_evict(
13558        &self,
13559        sub_id: &str,
13560        table: &str,
13561        effective: &[(String, Vec<String>)],
13562    ) -> Result<(), String> {
13563        self.conn.execute(
13564            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
13565            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
13566        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13567    }
13568
13569    fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
13570        self.conn
13571            .execute(
13572                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
13573                rusqlite::params![sub_id],
13574            )
13575            .map(|_| ())
13576            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13577    }
13578
13579    fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
13580        let mut stmt = self
13581            .conn
13582            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
13583            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13584        let rows = stmt
13585            .query_map([], |row| {
13586                Ok((
13587                    row.get::<_, String>(0)?,
13588                    row.get::<_, String>(1)?,
13589                    row.get::<_, String>(2)?,
13590                ))
13591            })
13592            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13593        rows.map(|row| {
13594            let (sub_id, table, json) =
13595                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13596            let value: Value = serde_json::from_str(&json)
13597                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
13598            let effective = json_to_scope_map(&value)
13599                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
13600            Ok((sub_id, table, effective))
13601        })
13602        .collect()
13603    }
13604
13605    /// Select failed-intent retention; reload durable retained aggregates.
13606    pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
13607        self.retain_failed_commits = enabled;
13608        for (commit, _) in &self.failed_commits {
13609            for operation in &commit.ops {
13610                self.overlay_dirty.table(&operation.table);
13611            }
13612        }
13613        self.failed_commits = self.load_failed_commits()?;
13614        for (commit, _) in &self.failed_commits {
13615            for operation in &commit.ops {
13616                self.overlay_dirty.table(&operation.table);
13617            }
13618        }
13619        Ok(())
13620    }
13621
13622    fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
13623        let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
13624            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13625        let rows = statement
13626            .query_map([], |row| {
13627                Ok((
13628                    row.get::<_, String>(0)?,
13629                    row.get::<_, String>(1)?,
13630                    row.get::<_, String>(2)?,
13631                ))
13632            })
13633            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13634        rows.map(|row| {
13635            let (id, operations, initial) =
13636                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13637            let operations: Vec<CommitOperation> =
13638                serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
13639            let initial =
13640                serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
13641            Ok((
13642                OutboxCommit {
13643                    client_commit_id: id,
13644                    ops: operations
13645                        .into_iter()
13646                        .map(|op| OutboxOp {
13647                            upsert: op.op == "upsert",
13648                            table: op.table,
13649                            row_id: op.row_id,
13650                            base_version: op.base_version,
13651                            values: op.values,
13652                        })
13653                        .collect(),
13654                },
13655                initial,
13656            ))
13657        })
13658        .collect()
13659    }
13660
13661    fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
13662        let mut initial = Map::new();
13663        for operation in operations {
13664            let table = self
13665                .schema
13666                .table(&operation.table)
13667                .ok_or_else(|| "sync.unknown_table".to_owned())?;
13668            let initial_row = match self.stored_row(table, &operation.row_id, true)? {
13669                Some(row) => Some(row),
13670                None => self.visible_row(table, &operation.row_id)?,
13671            };
13672            if let Some((values, _)) = initial_row {
13673                let mut intended: Map<String, Value> = table
13674                    .columns
13675                    .iter()
13676                    .zip(values.iter())
13677                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
13678                    .collect();
13679                if let Some(patch) = &operation.values {
13680                    intended.extend(patch.clone());
13681                }
13682                initial.insert(
13683                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
13684                        .map_err(|_| "sync.local_corrupt".to_owned())?,
13685                    Value::Object(intended),
13686                );
13687            }
13688        }
13689        self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
13690            serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
13691            serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
13692            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13693        self.failed_commits.push((
13694            OutboxCommit {
13695                client_commit_id: id.to_owned(),
13696                ops: operations.to_vec(),
13697            },
13698            initial,
13699        ));
13700        Ok(())
13701    }
13702
13703    fn drop_failed_commits(
13704        &mut self,
13705        matches: impl Fn(&str, &Map<String, Value>) -> bool,
13706    ) -> Result<Vec<OutboxCommit>, String> {
13707        let mut doomed = self.drop_acknowledged_rows(&matches)?;
13708        for (commit, initial) in &self.failed_commits {
13709            for operation in &commit.ops {
13710                let key =
13711                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
13712                        .map_err(|_| "sync.local_corrupt".to_owned())?;
13713                if initial
13714                    .get(&key)
13715                    .and_then(Value::as_object)
13716                    .is_some_and(|values| matches(&operation.table, values))
13717                {
13718                    doomed.push(commit.clone());
13719                    break;
13720                }
13721            }
13722        }
13723        for commit in &doomed {
13724            self.conn
13725                .execute(
13726                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
13727                    rusqlite::params![commit.client_commit_id],
13728                )
13729                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13730        }
13731        for commit in &doomed {
13732            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))?;
13733        }
13734        self.conflicts.retain(|record| {
13735            !doomed
13736                .iter()
13737                .any(|commit| commit.client_commit_id == record.client_commit_id)
13738        });
13739        self.rejections.retain(|record| {
13740            !doomed
13741                .iter()
13742                .any(|commit| commit.client_commit_id == record.client_commit_id)
13743        });
13744        self.failed_commits.retain(|(commit, _)| {
13745            !doomed
13746                .iter()
13747                .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
13748        });
13749        for commit in &doomed {
13750            for operation in &commit.ops {
13751                self.overlay_dirty.table(&operation.table);
13752            }
13753        }
13754        Ok(doomed)
13755    }
13756
13757    /// Record one commit of local mutations. The returned commit ID is the
13758    /// §2.3 idempotency key. Failures are structured so callers read
13759    /// `code`/`message`/`details` instead of parsing a string prefix.
13760    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, ClientError> {
13761        let prior = self.storage_failure.borrow_mut().take();
13762        let outcome = (|| -> Result<String, String> {
13763            if mutations.is_empty() {
13764                return Err(
13765                    "sync.invalid_request: a commit must contain at least one operation (§6.1)"
13766                        .to_owned(),
13767                );
13768            }
13769            let mut ops = Vec::with_capacity(mutations.len());
13770            for mutation in mutations {
13771                match mutation {
13772                    Mutation::Patch {
13773                        table,
13774                        values,
13775                        base_version,
13776                    } => {
13777                        let schema_table = self
13778                            .schema
13779                            .table(&table)
13780                            .ok_or_else(|| "sync.unknown_table".to_owned())?;
13781                        let values = normalize_values_casing(schema_table, values)?;
13782                        let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
13783                        ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
13784                    }
13785                    Mutation::Upsert {
13786                        table,
13787                        values,
13788                        base_version,
13789                    } => {
13790                        let schema_table = self.schema.table(&table).ok_or_else(|| {
13791                            format!("sync.unknown_table: unknown table {table:?}")
13792                        })?;
13793                        // §5: value keys are accepted in snake_case AND the
13794                        // generated row types' camelCase; normalize to SQL truth
13795                        // before the pk lookup / codec see them.
13796                        let values = normalize_values_casing(schema_table, values)?;
13797                        let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
13798                        // §6.7 `mutate` marks every column present: fill missing
13799                        // nullable columns with NULL before the payload is built.
13800                        let values = full_row_values(schema_table, values)?;
13801                        // §5.11: validate the payload encodes with the current
13802                        // codec. A key-selection or unknown-key failure is a
13803                        // durable rejection at the push seam (§10.3), never an
13804                        // author-time error. Full-row `mutate` presents every
13805                        // column, so no stored fallback is needed.
13806                        self.validate_author_encode(schema_table, &row_id, &values)?;
13807                        ops.push(OutboxOp {
13808                            upsert: true,
13809                            table,
13810                            row_id,
13811                            base_version,
13812                            values: Some(values),
13813                        });
13814                    }
13815                    Mutation::Delete {
13816                        table,
13817                        row_id,
13818                        base_version,
13819                    } => {
13820                        if self.schema.table(&table).is_none() {
13821                            return Err(format!("sync.unknown_table: unknown table {table:?}"));
13822                        }
13823                        ops.push(OutboxOp {
13824                            upsert: false,
13825                            table,
13826                            row_id,
13827                            base_version,
13828                            values: None,
13829                        });
13830                    }
13831                }
13832            }
13833            self.record_outbox_commit(ops)
13834        })();
13835        self.finish_authoring(prior, outcome)
13836    }
13837
13838    /// Scope the retained `storage_failure` diagnostic to this call so an
13839    /// earlier operation's failure cannot classify a new one, and surface the
13840    /// call's own classified failure with its details. A fresh classified
13841    /// failure replaces the retained diagnostic; otherwise the earlier one is
13842    /// kept for the next sync.
13843    fn finish_authoring(
13844        &self,
13845        prior: Option<QueryReadFailure>,
13846        outcome: Result<String, String>,
13847    ) -> Result<String, ClientError> {
13848        let fresh = self.storage_failure.borrow_mut().take();
13849        *self.storage_failure.borrow_mut() = fresh.clone().or(prior);
13850        match outcome {
13851            Ok(id) => Ok(id),
13852            Err(message) => Err(fresh
13853                .map(ClientError::from)
13854                .unwrap_or_else(|| ClientError::from(message))),
13855        }
13856    }
13857
13858    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
13859        let commit = OutboxCommit {
13860            client_commit_id: uuid::Uuid::new_v4().to_string(),
13861            ops,
13862        };
13863        self.begin_observation("syncular_mutation")?;
13864        let mut batch = ChangeAccumulator::default();
13865        for op in &commit.ops {
13866            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
13867            if let Some(values) = &op.values {
13868                if let Some(table) = self.schema.table(&op.table) {
13869                    for scope in &table.scope_variables {
13870                        if let Some(Value::String(value)) = values.get(&scope.column) {
13871                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
13872                            precise = true;
13873                        }
13874                    }
13875                }
13876            }
13877            if !precise {
13878                batch.table(&op.table);
13879            }
13880        }
13881        if let Err(error) = self.persist_outbox_insert(&commit) {
13882            self.rollback_observation("syncular_mutation");
13883            return Err(error);
13884        }
13885        let was_dirty = self.overlay_dirty.snapshot();
13886        // A clean overlay contains the FIFO fold of every previous commit, so
13887        // the new commit applies over it. §7.1: an operation that does not
13888        // apply (a secondary unique collision) fails the whole commit; the
13889        // savepoint rollback removes the outbox entry and every visible write.
13890        if let Err(error) = self.rebuild_overlay_if_dirty() {
13891            self.rollback_observation("syncular_mutation");
13892            self.overlay_dirty.restore(was_dirty.clone());
13893            return Err(error);
13894        }
13895        if let Some(error) = commit
13896            .ops
13897            .iter()
13898            .find_map(|op| self.apply_outbox_op(op).err())
13899        {
13900            self.rollback_observation("syncular_mutation");
13901            self.overlay_dirty.restore(was_dirty.clone());
13902            return Err(error);
13903        }
13904        let id = commit.client_commit_id.clone();
13905        self.outbox.push(commit);
13906        batch.status = true;
13907        if let Err(error) = self.finish_observation("syncular_mutation", batch) {
13908            self.rollback_observation("syncular_mutation");
13909            self.outbox.pop();
13910            self.overlay_dirty.restore(was_dirty.clone());
13911            return Err(error);
13912        }
13913        Ok(id)
13914    }
13915
13916    /// Record one §6.1 sparse upsert: the primary key plus the supplied
13917    /// non-scope columns are present; every other column is absent and stays
13918    /// untouched on the server and in the local overlay. An absent local base
13919    /// refuses the whole batch with sync.row_missing before enqueueing (§7.1).
13920    /// Failures are structured so callers read `code`/`message`/`details`.
13921    pub fn patch(
13922        &mut self,
13923        table: &str,
13924        row_id: &str,
13925        partial: Map<String, Value>,
13926        base_version: Option<i64>,
13927    ) -> Result<String, ClientError> {
13928        let prior = self.storage_failure.borrow_mut().take();
13929        let outcome = self.perform_patch(table, row_id, partial, base_version);
13930        self.finish_authoring(prior, outcome)
13931    }
13932
13933    /// The unscoped patch body. Public authoring entry points scope
13934    /// `storage_failure` around the whole operation, including any pre-read.
13935    fn perform_patch(
13936        &mut self,
13937        table: &str,
13938        row_id: &str,
13939        partial: Map<String, Value>,
13940        base_version: Option<i64>,
13941    ) -> Result<String, String> {
13942        let operation = self.prepare_patch(table, row_id, partial, base_version)?;
13943        self.record_outbox_commit(vec![operation])
13944    }
13945
13946    fn prepare_patch(
13947        &self,
13948        table: &str,
13949        row_id: &str,
13950        partial: Map<String, Value>,
13951        base_version: Option<i64>,
13952    ) -> Result<OutboxOp, String> {
13953        let schema_table = self
13954            .schema
13955            .table(table)
13956            .ok_or_else(|| format!("sync.unknown_table: unknown table {table:?}"))?;
13957        let partial = normalize_values_casing(schema_table, partial)?;
13958        if let Some(pk) = partial.get(&schema_table.primary_key) {
13959            if render_row_id_json(Some(pk)).ok().as_deref() != Some(row_id) {
13960                return Err(format!(
13961                    "sync.invalid_request: table {table:?}: patch cannot change the primary key"
13962                ));
13963            }
13964        }
13965        // Existence reads only the key. Locked encrypted values stay opaque;
13966        // decoding the complete row here would reject a valid plain patch.
13967        let exists: bool = self
13968            .conn
13969            .query_row(
13970                &format!(
13971                    "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
13972                    visible_table(table),
13973                    row_id_predicate(schema_table)
13974                ),
13975                rusqlite::params![row_id],
13976                |row| row.get(0),
13977            )
13978            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13979        if !exists {
13980            return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
13981        }
13982        // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
13983        // server accepts a present scope column whose value equals the
13984        // stored row and applies it as a no-op, so a decoded envelope
13985        // round-trips. The local row is the only value the client can prove
13986        // equality against; when the row is absent locally the client cannot
13987        // prove equality against the server's stored row and fails closed.
13988        // A primary key that is also a scope column needs no stored-row
13989        // read: the primary key in a sparse payload is by construction the
13990        // row id being patched (§6.1), so its value is proven equal already.
13991        for scope in &schema_table.scope_variables {
13992            if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
13993                continue;
13994            }
13995            let Some(column) = schema_table
13996                .columns
13997                .iter()
13998                .find(|column| column.name == scope.column)
13999            else {
14000                continue;
14001            };
14002            let stored = self
14003                .conn
14004                .query_row(
14005                    &format!(
14006                        "SELECT {} FROM {} WHERE {}",
14007                        quote_ident(&scope.column),
14008                        visible_table(&schema_table.name),
14009                        row_id_predicate(schema_table)
14010                    ),
14011                    rusqlite::params![row_id],
14012                    |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
14013                )
14014                .optional()
14015                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14016            // Decode the supplied value through the same column-type path the
14017            // rest of the patch encoding uses, so a value that only equals
14018            // the stored one in another number representation still compares
14019            // equal.
14020            let incoming = json_to_column_value(column, partial.get(&scope.column))
14021                .ok()
14022                .map(|value| column_value_to_json(&value));
14023            // A stored blob never equals a patch value, matching the server's
14024            // scope-column comparison (§3.4): bytes are unstorable as scope
14025            // values.
14026            let equal = stored.as_ref().is_some_and(|stored| {
14027                stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
14028            });
14029            if !equal {
14030                return Err(format!(
14031                    "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
14032                    scope.column
14033                ));
14034            }
14035        }
14036        let mut values = partial;
14037        // Presence-set semantics: a proven-equal scope column is dropped, so
14038        // the patch leaves the stored value untouched (§6.2).
14039        for scope in &schema_table.scope_variables {
14040            values.remove(&scope.column);
14041        }
14042        let pk_value = match schema_table.columns[schema_table.pk_index].ty {
14043            // §2.4: the rowId of an integer key is its canonical decimal form.
14044            ColumnType::Integer => match row_id.parse::<i64>() {
14045                Ok(v)
14046                    if v.to_string() == row_id
14047                        && (-ssp2::primitives::I64_SAFE_MAX..=ssp2::primitives::I64_SAFE_MAX)
14048                            .contains(&v) =>
14049                {
14050                    Value::from(v)
14051                }
14052                _ => {
14053                    return Err(format!(
14054                    "sync.invalid_request: table {table:?}: invalid integer primary key {row_id:?}"
14055                ))
14056                }
14057            },
14058            ColumnType::Boolean => match row_id {
14059                "true" => Value::Bool(true),
14060                "false" => Value::Bool(false),
14061                _ => {
14062                    return Err(format!(
14063                    "sync.invalid_request: table {table:?}: invalid boolean primary key {row_id:?}"
14064                ))
14065                }
14066            },
14067            _ => Value::from(row_id.to_owned()),
14068        };
14069        values.insert(schema_table.primary_key.clone(), pk_value);
14070        // §5.11: structural validation only; an absent key-id selector is
14071        // resolved at the push seam from the stored local row, and a
14072        // key-selection or unknown-key failure becomes a durable
14073        // `client.encrypt_failed` rejection there (§10.3), never an
14074        // author-time error.
14075        self.validate_author_encode(schema_table, row_id, &values)?;
14076        Ok(OutboxOp {
14077            upsert: true,
14078            table: table.to_owned(),
14079            row_id: row_id.to_owned(),
14080            base_version,
14081            values: Some(values),
14082        })
14083    }
14084
14085    /// §5.11 key-id fallback: the stored local row's selector value as a
14086    /// positional row (only the selector slot filled). Returns None when no
14087    /// selector is configured, the row is locally absent, or the stored
14088    /// value is not a usable key id.
14089    fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
14090        let selector = self.encryption.key_id_columns.get(&table.name)?;
14091        let index = table
14092            .columns
14093            .iter()
14094            .position(|column| &column.name == selector)?;
14095        let sql = format!(
14096            "SELECT {} FROM {} WHERE {}",
14097            quote_ident(selector),
14098            visible_table(&table.name),
14099            row_id_predicate(table)
14100        );
14101        let key: Option<String> = self
14102            .conn
14103            .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
14104            .ok()?;
14105        let key = key.filter(|key| !key.is_empty())?;
14106        let mut fallback: Row = vec![None; table.columns.len()];
14107        fallback[index] = Some(ColumnValue::String(key));
14108        Some(fallback)
14109    }
14110
14111    /// §5.11/§10.3 author-time encode check: a structural failure (a
14112    /// non-nullable NULL, a value the codec rejects) is an author-time
14113    /// error; a key-selection or unknown-key failure is deferred to the push
14114    /// seam, where it becomes a durable `client.encrypt_failed` rejection.
14115    fn validate_author_encode(
14116        &self,
14117        table: &TableSchema,
14118        row_id: &str,
14119        values: &Map<String, Value>,
14120    ) -> Result<(), String> {
14121        match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
14122            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
14123            result => result.map(|_| ()),
14124        }
14125    }
14126
14127    /// §5.11/§10.3: drop every pending commit whose push encode fails with
14128    /// `client.encrypt_failed` (an unresolvable key id or an unknown key) and
14129    /// raise one durable rejection per commit. `sync()` never aborts for an
14130    /// encode failure. Mirrors §7.4.4's `drop_incompatible_outbox`, including
14131    /// undoing the dropped commit's purely-optimistic rows.
14132    /// §7.1/§7.4.4: drop pending commits whose values the current codec refuses
14133    /// (an envelope or a type an earlier version accepted). `with_encryption`
14134    /// additionally drops commits whose §5.11 encryption cannot resolve a key;
14135    /// the startup boundary leaves those to the send seam, where hosts configure
14136    /// keys after open.
14137    /// Recovery runs as one observation. An enclosing observation (the guarded
14138    /// schema reset) passes its `batch`, so the rejection, outcome, and table
14139    /// domains join that change event instead of publishing a second one.
14140    fn drop_unencodable_outbox(
14141        &mut self,
14142        with_encryption: bool,
14143        batch: Option<&mut ChangeAccumulator>,
14144    ) -> Result<bool, String> {
14145        let failures = self
14146            .outbox
14147            .iter()
14148            .filter_map(|commit| {
14149                commit
14150                    .ops
14151                    .iter()
14152                    .find_map(|op| {
14153                        let values = op.values.as_ref().filter(|_| op.upsert)?;
14154                        let table = self.schema.table(&op.table)?;
14155                        let fallback = self.stored_key_fallback(table, &op.row_id);
14156                        match encode_sparse_row_json(
14157                            table,
14158                            &op.row_id,
14159                            values,
14160                            &self.encryption,
14161                            fallback.as_ref(),
14162                        ) {
14163                            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => {
14164                                with_encryption.then_some((error, ENCRYPT_FAILED_CODE))
14165                            }
14166                            // §7.1/§7.4.4: a value the current codec refuses was
14167                            // persisted by a version that accepted it; it leaves
14168                            // through the incompatible-outbox path, so the FIFO
14169                            // queue never wedges.
14170                            Err(error) if error.starts_with(INVALID_REQUEST_CODE) => Some((
14171                                LEGACY_VALUE_REFUSAL_MESSAGE.to_owned(),
14172                                OUTBOX_INCOMPATIBLE_CODE,
14173                            )),
14174                            _ => None,
14175                        }
14176                    })
14177                    .map(|(error, code)| (commit.clone(), error, code))
14178            })
14179            .collect::<Vec<_>>();
14180        if failures.is_empty() {
14181            return Ok(false);
14182        }
14183        // §7.4.4: one observation covers the durable outcome writes, the outbox
14184        // removal, the in-memory drop, and the overlay rebuild, so a storage fault
14185        // leaves the replica and the client unchanged, a successful recovery
14186        // publishes one batch with the rejection, outcome, and table domains, and
14187        // an ordinary open with nothing to recover publishes nothing.
14188        let prior_outbox = self.outbox.clone();
14189        let prior_rejections = self.rejections.clone();
14190        // §5.11 retention and a self-published observation mutate two more
14191        // in-memory fields before this transaction ends, so the rollback below
14192        // restores them with the rest.
14193        let prior_failed_commits = self.failed_commits.clone();
14194        let prior_changes = self.change_queue.len();
14195        let prior_intents = self.sync_intent_queue.len();
14196        let prior_last_change = self.last_change.clone();
14197        let prior_overlay_dirty = self.overlay_dirty.snapshot();
14198        let owns_observation = batch.is_none();
14199        let owns_transaction = self.conn.is_autocommit();
14200        let mut domains = ChangeAccumulator::default();
14201        if owns_observation {
14202            self.begin_observation("syncular_value_recovery")?;
14203        }
14204        let dropped = (|| -> Result<(), String> {
14205            for (commit, message, code) in failures {
14206                if self.retain_failed_commits && code == ENCRYPT_FAILED_CODE {
14207                    self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
14208                }
14209                let representable = commit.ops.iter().all(Self::outbox_op_is_journalable);
14210                let rejection = RejectionRecord {
14211                    client_commit_id: commit.client_commit_id.clone(),
14212                    op_index: 0,
14213                    code: code.to_owned(),
14214                    message,
14215                    retryable: false,
14216                    details: (code == OUTBOX_INCOMPATIBLE_CODE).then(|| RejectionDetails {
14217                        field_paths: None,
14218                        reason: Some(INVALID_STORED_VALUES_REASON.to_owned()),
14219                        required_action: None,
14220                        references: None,
14221                    }),
14222                    operation: representable
14223                        .then(|| commit.ops.first())
14224                        .flatten()
14225                        .map(CommitOperation::from),
14226                };
14227                self.persist_commit_outcome(
14228                    &commit.client_commit_id,
14229                    CommitOutcomeStatus::Rejected,
14230                    &[CommitOperationOutcome::Error {
14231                        rejection: rejection.clone(),
14232                    }],
14233                    representable.then_some(commit.ops.as_slice()),
14234                )?;
14235                self.delete_outbox_persisted(&commit.client_commit_id)?;
14236                self.outbox
14237                    .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
14238                self.rejections.push(rejection);
14239                for operation in &commit.ops {
14240                    domains.table(operation.table.as_str());
14241                }
14242            }
14243            domains.status = true;
14244            domains.rejections = true;
14245            domains.outcomes = true;
14246            self.prune_commit_outcomes()?;
14247            // The visible projection is re-derived from the surviving base + outbox.
14248            self.overlay_dirty.set(true);
14249            self.rebuild_overlay()?;
14250            Ok(())
14251        })();
14252        let result = match batch {
14253            Some(batch) => {
14254                if dropped.is_ok() {
14255                    for table in domains.tables.keys() {
14256                        batch.table(table.as_str());
14257                    }
14258                    batch.status |= domains.status;
14259                    batch.rejections |= domains.rejections;
14260                    batch.outcomes |= domains.outcomes;
14261                }
14262                dropped
14263            }
14264            None => {
14265                dropped.and_then(|()| self.finish_observation("syncular_value_recovery", domains))
14266            }
14267        };
14268        if let Err(error) = result {
14269            if owns_observation {
14270                if owns_transaction {
14271                    if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
14272                        let mut failure = self.storage_failure.borrow_mut();
14273                        let failure =
14274                            failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
14275                        failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
14276                    }
14277                } else {
14278                    self.rollback_observation("syncular_value_recovery");
14279                }
14280            }
14281            self.outbox = prior_outbox;
14282            self.rejections = prior_rejections;
14283            self.failed_commits = prior_failed_commits;
14284            self.change_queue.truncate(prior_changes);
14285            self.sync_intent_queue.truncate(prior_intents);
14286            self.last_change = prior_last_change;
14287            self.overlay_dirty.restore(prior_overlay_dirty);
14288            return Err(error);
14289        }
14290        Ok(true)
14291    }
14292
14293    pub fn pending_commit_ids(&self) -> Vec<String> {
14294        self.outbox
14295            .iter()
14296            .map(|c| c.client_commit_id.clone())
14297            .collect()
14298    }
14299
14300    /// §6.1: the sparse push payload bytes of every pending upsert operation,
14301    /// FIFO — exactly what the next push round would put on the wire. Used by
14302    /// conformance B.21(h) to pin cross-core byte identity.
14303    pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
14304        let mut payloads = Vec::new();
14305        for commit in &self.outbox {
14306            for op in &commit.ops {
14307                let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
14308                    continue;
14309                };
14310                let Some(table) = self.schema.table(&op.table) else {
14311                    continue;
14312                };
14313                let fallback = self.stored_key_fallback(table, &op.row_id);
14314                if let Ok(payload) = encode_sparse_row_json(
14315                    table,
14316                    &op.row_id,
14317                    values,
14318                    &self.encryption,
14319                    fallback.as_ref(),
14320                ) {
14321                    payloads.push(payload);
14322                }
14323            }
14324        }
14325        payloads
14326    }
14327
14328    pub fn conflicts(&self) -> &[ConflictRecord] {
14329        &self.conflicts
14330    }
14331
14332    pub fn rejections(&self) -> &[RejectionRecord] {
14333        &self.rejections
14334    }
14335
14336    pub fn resolve_commit_outcome(
14337        &mut self,
14338        input: ResolveCommitOutcomeInput,
14339    ) -> Result<CommitOutcome, String> {
14340        let current = self
14341            .commit_outcome(&input.client_commit_id)?
14342            .ok_or_else(|| {
14343                format!(
14344                    "sync.outcome_not_found: no durable outcome exists for {:?}",
14345                    input.client_commit_id
14346                )
14347            })?;
14348        if current.resolution != CommitOutcomeResolution::Active {
14349            return Ok(current);
14350        }
14351        if input.resolution == CommitOutcomeResolution::Active {
14352            return Err("sync.invalid_request: resolution must leave active state".to_owned());
14353        }
14354        match input.resolution {
14355            CommitOutcomeResolution::Superseded => {
14356                let replacement = input
14357                    .replacement_client_commit_id
14358                    .as_deref()
14359                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
14360                    .ok_or_else(|| {
14361                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
14362                            .to_owned()
14363                    })?;
14364                let _ = replacement;
14365            }
14366            _ if input.replacement_client_commit_id.is_some() => {
14367                return Err(
14368                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
14369                        .to_owned(),
14370                );
14371            }
14372            _ => {}
14373        }
14374        let allowed = match current.status {
14375            CommitOutcomeStatus::Conflict => matches!(
14376                input.resolution,
14377                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
14378            ),
14379            CommitOutcomeStatus::Rejected => {
14380                matches!(
14381                    input.resolution,
14382                    CommitOutcomeResolution::Superseded
14383                        | CommitOutcomeResolution::ResolvedKeepServer
14384                )
14385            }
14386            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
14387                input.resolution == CommitOutcomeResolution::Dismissed
14388            }
14389        };
14390        if !allowed {
14391            return Err(format!(
14392                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
14393                input.resolution, current.status
14394            ));
14395        }
14396
14397        self.begin_observation("syncular_outcome_resolution")?;
14398        let previous_failed = self.failed_commits.clone();
14399        let previous_conflicts = self.conflicts.clone();
14400        let previous_rejections = self.rejections.clone();
14401        let result = (|| {
14402            self.conn
14403                .execute(
14404                    "UPDATE _syncular_commit_outcomes
14405                        SET resolution = ?1, resolved_at_ms = ?2,
14406                            replacement_client_commit_id = ?3
14407                      WHERE client_commit_id = ?4 AND resolution = 'active'",
14408                    rusqlite::params![
14409                        Self::outcome_resolution_name(input.resolution),
14410                        self.clock_now_ms(),
14411                        input.replacement_client_commit_id,
14412                        input.client_commit_id
14413                    ],
14414                )
14415                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14416            let resolved = self
14417                .commit_outcome(&current.client_commit_id)?
14418                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
14419            self.conn
14420                .execute(
14421                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
14422                    rusqlite::params![input.client_commit_id],
14423                )
14424                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14425            for (commit, _) in &self.failed_commits {
14426                if commit.client_commit_id == input.client_commit_id {
14427                    for operation in &commit.ops {
14428                        self.overlay_dirty.table(&operation.table);
14429                    }
14430                }
14431            }
14432            self.failed_commits
14433                .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
14434            self.prune_commit_outcomes()?;
14435            Ok(resolved)
14436        })();
14437        let resolved = match result {
14438            Ok(outcome) => outcome,
14439            Err(error) => {
14440                self.rollback_observation("syncular_outcome_resolution");
14441                self.failed_commits = previous_failed;
14442                self.conflicts = previous_conflicts;
14443                self.rejections = previous_rejections;
14444                return Err(error);
14445            }
14446        };
14447        self.conflicts
14448            .retain(|record| record.client_commit_id != current.client_commit_id);
14449        self.rejections
14450            .retain(|record| record.client_commit_id != current.client_commit_id);
14451        let mut batch = ChangeAccumulator {
14452            conflicts: current.status == CommitOutcomeStatus::Conflict,
14453            rejections: current.status == CommitOutcomeStatus::Rejected,
14454            outcomes: true,
14455            ..ChangeAccumulator::default()
14456        };
14457        if let Some(operations) = &current.operations {
14458            for operation in operations {
14459                batch.table(&operation.table);
14460            }
14461        }
14462        if let Err(error) = self
14463            .rebuild_overlay_if_dirty()
14464            .and_then(|()| self.finish_observation("syncular_outcome_resolution", batch))
14465        {
14466            self.rollback_observation("syncular_outcome_resolution");
14467            self.failed_commits = previous_failed;
14468            self.conflicts = previous_conflicts;
14469            self.rejections = previous_rejections;
14470            return Err(error);
14471        }
14472        Ok(resolved)
14473    }
14474
14475    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
14476        self.schema_floor.as_ref()
14477    }
14478
14479    /// §7.3.5: the client's opaque auth-lease state, if any.
14480    pub fn lease_state(&self) -> Option<&LeaseState> {
14481        self.lease_state.as_ref()
14482    }
14483
14484    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
14485    /// the two lease codes set it; other errors leave leaseState untouched.
14486    fn record_lease_error(&mut self, code: &str) {
14487        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
14488            return;
14489        }
14490        let mut next = self.lease_state.clone().unwrap_or_default();
14491        next.error_code = Some(code.to_owned());
14492        self.set_lease_state(Some(next));
14493    }
14494
14495    fn set_lease_state(&mut self, next: Option<LeaseState>) {
14496        if self.lease_state == next {
14497            return;
14498        }
14499        if self.begin_observation("syncular_lease").is_err() {
14500            return;
14501        }
14502        self.lease_state = next;
14503        if let Some(lease) = &self.lease_state {
14504            if let Ok(json) = serde_json::to_string(lease) {
14505                self.set_meta(LEASE_STATE_KEY, &json);
14506            }
14507        } else {
14508            self.delete_meta(LEASE_STATE_KEY);
14509        }
14510        let batch = ChangeAccumulator {
14511            status: true,
14512            ..ChangeAccumulator::default()
14513        };
14514        if self.finish_observation("syncular_lease", batch).is_err() {
14515            self.rollback_observation("syncular_lease");
14516        }
14517    }
14518
14519    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
14520        if self.schema_floor == next {
14521            return;
14522        }
14523        if self.begin_observation("syncular_schema_floor").is_err() {
14524            return;
14525        }
14526        self.schema_floor = next;
14527        self.stopped = self.schema_floor.is_some();
14528        if let Some(floor) = &self.schema_floor {
14529            if let Ok(json) = serde_json::to_string(floor) {
14530                self.set_meta(SCHEMA_FLOOR_KEY, &json);
14531            }
14532        } else {
14533            self.delete_meta(SCHEMA_FLOOR_KEY);
14534        }
14535        let batch = ChangeAccumulator {
14536            status: true,
14537            ..ChangeAccumulator::default()
14538        };
14539        if self
14540            .finish_observation("syncular_schema_floor", batch)
14541            .is_err()
14542        {
14543            self.rollback_observation("syncular_schema_floor");
14544        }
14545    }
14546
14547    fn set_upgrading(&mut self, value: bool) {
14548        if self.upgrading == value {
14549            return;
14550        }
14551        if self.begin_observation("syncular_upgrading").is_err() {
14552            return;
14553        }
14554        self.upgrading = value;
14555        let batch = ChangeAccumulator {
14556            status: true,
14557            ..ChangeAccumulator::default()
14558        };
14559        if self
14560            .finish_observation("syncular_upgrading", batch)
14561            .is_err()
14562        {
14563            self.rollback_observation("syncular_upgrading");
14564        }
14565    }
14566
14567    pub fn sync_needed(&self) -> bool {
14568        self.sync_needed
14569    }
14570
14571    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
14572        let sub = self.subs.iter().find(|s| s.id == id)?;
14573        Some(SubscriptionStateView {
14574            id: sub.id.clone(),
14575            table: sub.table.clone(),
14576            status: sub.state.name().to_owned(),
14577            cursor: sub.cursor,
14578            has_resume_token: sub.bootstrap_state.is_some(),
14579            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
14580            reason_code: sub.reason_code.clone(),
14581        })
14582    }
14583
14584    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
14585        let schema_table = self
14586            .schema
14587            .table(table)
14588            .ok_or_else(|| format!("unknown table {table:?}"))?;
14589        let sql = format!(
14590            "SELECT * FROM {} ORDER BY {} ASC",
14591            visible_table(table),
14592            quote_ident(&schema_table.primary_key)
14593        );
14594        let mut stmt = self
14595            .conn
14596            .prepare(&sql)
14597            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14598        let mut rows = stmt
14599            .query([])
14600            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14601        let mut out = Vec::new();
14602        while let Some(row) = rows
14603            .next()
14604            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14605        {
14606            let mut values = Map::new();
14607            for (i, column) in schema_table.columns.iter().enumerate() {
14608                let value = row
14609                    .get_ref(i)
14610                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14611                values.insert(column.name.clone(), sql_ref_to_json(column, value));
14612            }
14613            let version: i64 = row
14614                .get(schema_table.columns.len())
14615                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14616            let row_id = match values.get(&schema_table.primary_key) {
14617                Some(Value::String(s)) => s.clone(),
14618                Some(Value::Number(n)) => n.to_string(),
14619                Some(Value::Bool(b)) => b.to_string(),
14620                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
14621            };
14622            out.push(RowState {
14623                row_id,
14624                version,
14625                values,
14626            });
14627        }
14628        Ok(out)
14629    }
14630
14631    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
14632    //
14633    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
14634    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
14635    // the op with `yrs`, re-encodes the whole doc state, and records a
14636    // baseVersion-less sparse upsert carrying only the primary key and the
14637    // crdt column (§5.10.3, §6.6). No local merge — merging is server-side;
14638    // the overlay re-materializes the edit immediately (optimistic apply,
14639    // §7.1) and the server-merged bytes arrive on the next pull, idempotently.
14640    // Byte-compatible with `@syncular/crdt-yjs`.
14641
14642    /// The current stored value of a `crdt` column for one row — the visible
14643    /// (optimistic) bytes, or `None` when the row is absent or the column is
14644    /// NULL (the empty document, §5.10.1). Errors if the column is not a
14645    /// `crdt` column (guards the app against a typo'd column name).
14646    #[cfg(feature = "crdt-yjs")]
14647    fn crdt_column_bytes(
14648        &self,
14649        table: &str,
14650        row_id: &str,
14651        column: &str,
14652    ) -> Result<Option<Vec<u8>>, String> {
14653        let schema_table = self
14654            .schema
14655            .table(table)
14656            .ok_or_else(|| format!("unknown table {table:?}"))?;
14657        let col = schema_table
14658            .columns
14659            .iter()
14660            .find(|c| c.name == column)
14661            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
14662        if col.ty != ColumnType::Crdt {
14663            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
14664        }
14665        let sql = format!(
14666            "SELECT {} FROM {} WHERE {}",
14667            quote_ident(column),
14668            visible_table(table),
14669            row_id_predicate(schema_table)
14670        );
14671        let bytes: Option<Vec<u8>> = self
14672            .conn
14673            .prepare_cached(&sql)
14674            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14675            .query_row(rusqlite::params![row_id], |row| {
14676                row.get::<_, Option<Vec<u8>>>(0)
14677            })
14678            .map_err(|e| match e {
14679                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
14680                other => Self::sqlite_failure(&self.storage_failure, other),
14681            })?;
14682        Ok(bytes)
14683    }
14684
14685    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
14686    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
14687    /// An absent row / NULL column is the empty document (empty string).
14688    #[cfg(feature = "crdt-yjs")]
14689    pub fn crdt_text(
14690        &self,
14691        table: &str,
14692        row_id: &str,
14693        column: &str,
14694        name: &str,
14695    ) -> Result<String, String> {
14696        let bytes = self
14697            .crdt_column_bytes(table, row_id, column)?
14698            .unwrap_or_default();
14699        crate::crdt::text(&bytes, name)
14700    }
14701
14702    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and
14703    /// record the resulting crdt-only sparse operation. Returns the enqueued
14704    /// `clientCommitId`.
14705    #[cfg(feature = "crdt-yjs")]
14706    pub fn crdt_insert_text(
14707        &mut self,
14708        table: &str,
14709        row_id: &str,
14710        column: &str,
14711        name: &str,
14712        index: u32,
14713        value: &str,
14714    ) -> Result<String, ClientError> {
14715        let prior = self.storage_failure.borrow_mut().take();
14716        let outcome = (|| -> Result<String, String> {
14717            let current = self
14718                .crdt_column_bytes(table, row_id, column)?
14719                .unwrap_or_default();
14720            let update = crate::crdt::insert_text(&current, name, index, value)?;
14721            self.perform_crdt_push_update(table, row_id, column, &update)
14722        })();
14723        self.finish_authoring(prior, outcome)
14724    }
14725
14726    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and
14727    /// record the resulting crdt-only sparse operation. Returns the enqueued
14728    /// `clientCommitId`.
14729    #[cfg(feature = "crdt-yjs")]
14730    pub fn crdt_delete_text(
14731        &mut self,
14732        table: &str,
14733        row_id: &str,
14734        column: &str,
14735        name: &str,
14736        index: u32,
14737        len: u32,
14738    ) -> Result<String, ClientError> {
14739        let prior = self.storage_failure.borrow_mut().take();
14740        let outcome = (|| -> Result<String, String> {
14741            let current = self
14742                .crdt_column_bytes(table, row_id, column)?
14743                .unwrap_or_default();
14744            let update = crate::crdt::delete_text(&current, name, index, len)?;
14745            self.perform_crdt_push_update(table, row_id, column, &update)
14746        })();
14747        self.finish_authoring(prior, outcome)
14748    }
14749
14750    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
14751    /// `crdt` column's current state and record the resulting crdt-only sparse
14752    /// operation. The app authored the update with its own `yrs` model.
14753    /// Returns the enqueued `clientCommitId`.
14754    #[cfg(feature = "crdt-yjs")]
14755    pub fn crdt_apply_update(
14756        &mut self,
14757        table: &str,
14758        row_id: &str,
14759        column: &str,
14760        update: &[u8],
14761    ) -> Result<String, ClientError> {
14762        let prior = self.storage_failure.borrow_mut().take();
14763        let outcome = (|| -> Result<String, String> {
14764            let current = self
14765                .crdt_column_bytes(table, row_id, column)?
14766                .unwrap_or_default();
14767            let next = crate::crdt::apply_update(&current, update)?;
14768            self.perform_crdt_push_update(table, row_id, column, &next)
14769        })();
14770        self.finish_authoring(prior, outcome)
14771    }
14772
14773    /// Shared tail of the crdt edit methods: record a §6.1 sparse crdt-only
14774    /// upsert — the primary key plus the crdt column, no `baseVersion` — and
14775    /// enqueue it. The server merges the crdt column and leaves every other
14776    /// column untouched (§5.10.3), so a baseVersion-less crdt edit cannot
14777    /// clobber a concurrent edit to another column. A locally absent row
14778    /// records the same partial operation; the server answers per §6.2.
14779    #[cfg(feature = "crdt-yjs")]
14780    fn perform_crdt_push_update(
14781        &mut self,
14782        table: &str,
14783        row_id: &str,
14784        column: &str,
14785        crdt_bytes: &[u8],
14786    ) -> Result<String, String> {
14787        let mut bytes_obj = Map::new();
14788        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
14789        let mut partial = Map::new();
14790        partial.insert(column.to_owned(), Value::Object(bytes_obj));
14791        self.perform_patch(table, row_id, partial, None)
14792    }
14793
14794    /// Run an arbitrary read-only SQL query against the local database and
14795    /// return each row as a `column-name → JSON value` map. This is the seam
14796    /// the React `useSyncQuery` live-query API needs (it takes app-authored
14797    /// SQL over the visible tables/views, not a fixed table read like
14798    /// [`read_rows`]).
14799    ///
14800    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
14801    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
14802    /// same envelope the command surface uses everywhere else. Output BLOB
14803    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
14804    ///
14805    /// The result column typing is dynamic (SQLite's stored affinity), because
14806    /// arbitrary SQL can alias, join, and compute — there is no schema column
14807    /// to consult per output cell, unlike [`read_rows`].
14808    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
14809        query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
14810    }
14811
14812    /// Repository benchmark and conformance access, present only with `bench-internals`.
14813    #[cfg(feature = "bench-internals")]
14814    #[doc(hidden)]
14815    pub fn benchmark_connection(&mut self) -> &mut Connection {
14816        &mut self.conn
14817    }
14818
14819    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
14820    #[cfg(feature = "bench-internals")]
14821    #[doc(hidden)]
14822    pub fn benchmark_phases(
14823        &self,
14824        enabled: Option<bool>,
14825        reset: bool,
14826    ) -> Result<Option<Value>, String> {
14827        self.benchmark_phases.configure(enabled, reset)?;
14828        Ok(self.benchmark_phases.snapshot())
14829    }
14830
14831    /// One serialized, read-only authority observation, including durable coverage.
14832    pub fn authority_snapshot(&self) -> Result<Value, String> {
14833        validate_authority_reads(&self.schema, &self.limits.authority_reads)?;
14834        if self.limits.authority_reads.is_empty() {
14835            return Err("client.authority_read_forbidden: no authority reads were declared at client creation".into());
14836        }
14837        self.conn
14838            .execute_batch("SAVEPOINT authority_snapshot")
14839            .map_err(|e| e.to_string())?;
14840        let result = (|| {
14841            let revision = self
14842                .get_meta(LOCAL_REVISION_KEY)
14843                .ok_or("sync.local_corrupt: authority revision is missing")?;
14844            let parsed_revision = revision
14845                .parse::<u64>()
14846                .map_err(|_| "sync.local_corrupt: authority revision is invalid")?;
14847            if parsed_revision.to_string() != revision {
14848                return Err("sync.local_corrupt: authority revision is invalid".into());
14849            }
14850            let mut tables = Vec::new();
14851            for read in &self.limits.authority_reads {
14852                let table = self
14853                    .schema
14854                    .table(&read.table)
14855                    .ok_or("client.authority_read_forbidden: authority table is not declared")?;
14856                let scalar = |column: &str, value: &Value| -> Result<String, String> {
14857                    let ty = table
14858                        .columns
14859                        .iter()
14860                        .find(|c| c.name == column)
14861                        .expect("validated authority column")
14862                        .ty;
14863                    if ty == ColumnType::Boolean {
14864                        if let Some(value) = value.as_bool() {
14865                            return Ok(value.to_string());
14866                        }
14867                        return match value.as_i64() {
14868                            Some(0) => Ok("false".into()),
14869                            Some(1) => Ok("true".into()),
14870                            _ => Err("sync.local_corrupt: authority boolean is invalid".into()),
14871                        };
14872                    }
14873                    match value {
14874                        Value::String(s) => Ok(s.clone()),
14875                        Value::Number(n) => Ok(n.to_string()),
14876                        Value::Object(o) if ty == ColumnType::Integer && o.len() == 1 => o
14877                            .get("$bigint")
14878                            .and_then(Value::as_str)
14879                            .map(str::to_owned)
14880                            .ok_or("sync.local_corrupt: authority integer is invalid".into()),
14881                        _ => {
14882                            Err("sync.local_corrupt: authority scalar is missing or invalid".into())
14883                        }
14884                    }
14885                };
14886                let matches = |row: &QueryRow| -> Result<bool, String> {
14887                    for (variable, values) in &read.scopes {
14888                        let column = table
14889                            .scope_column(variable)
14890                            .expect("validated authority scope");
14891                        let value = row
14892                            .get(column)
14893                            .ok_or("sync.local_corrupt: authority scope column is missing")?;
14894                        if value.is_null() || !values.contains(&scalar(column, value)?) {
14895                            return Ok(false);
14896                        }
14897                    }
14898                    Ok(true)
14899                };
14900                let selected = read
14901                    .columns
14902                    .iter()
14903                    .map(|c| quote_ident(c))
14904                    .collect::<Vec<_>>()
14905                    .join(",");
14906                let mut intent = BTreeSet::new();
14907                let mut relevant = BTreeSet::new();
14908                for row in query_connection(&self.conn,
14909                    "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=?",
14910                    &[Value::String(read.table.clone())]).map_err(|e| e.to_string())? {
14911                    let id = row.get("id").and_then(Value::as_str).ok_or("sync.local_corrupt: authority intent id is invalid")?.to_owned();
14912                    intent.insert(id.clone());
14913                    if let Some(raw) = row.get("values_json").and_then(Value::as_str) {
14914                        let values: QueryRow = serde_json::from_str(raw).map_err(|_| "sync.local_corrupt: authority intent values are invalid")?;
14915                        if read.scopes.keys().all(|v| values.contains_key(table.scope_column(v).expect("validated scope"))) && matches(&values)? {relevant.insert(id);}
14916                    }
14917                }
14918                let mut rows = BTreeMap::new();
14919                for full in [base_table(&read.table), visible_table(&read.table)] {
14920                    for mut values in query_connection(
14921                        &self.conn,
14922                        &format!(
14923                            "SELECT {selected},_syncular_version AS authority_version FROM {full}"
14924                        ),
14925                        &[],
14926                    )
14927                    .map_err(|e| e.to_string())?
14928                    {
14929                        if !matches(&values)? {
14930                            continue;
14931                        }
14932                        let id = scalar(
14933                            &table.primary_key,
14934                            values
14935                                .get(&table.primary_key)
14936                                .expect("selected primary key"),
14937                        )?;
14938                        relevant.insert(id.clone());
14939                        if full == base_table(&read.table) {
14940                            let version = values
14941                                .remove("authority_version")
14942                                .expect("selected version");
14943                            let version = version
14944                                .as_i64()
14945                                .ok_or("sync.local_corrupt: authority server version is invalid")?;
14946                            if version >= 0 {
14947                                rows.insert(id.clone(), json!({"values": values, "version": version, "hasLocalIntent": intent.contains(&id)}));
14948                            }
14949                        }
14950                    }
14951                }
14952                intent.retain(|id| relevant.contains(id));
14953                let rows = rows.into_values().collect::<Vec<_>>();
14954                let mut persisted = Vec::new();
14955                for row in query_connection(
14956                    &self.conn,
14957                    "SELECT state_json FROM _syncular_subscriptions WHERE tbl=? ORDER BY rowid",
14958                    &[Value::String(read.table.clone())],
14959                )
14960                .map_err(|e| e.message)?
14961                {
14962                    let state: Value = serde_json::from_str(
14963                        row.get("state_json")
14964                            .and_then(Value::as_str)
14965                            .ok_or("sync.local_corrupt: authority subscription state is missing")?,
14966                    )
14967                    .map_err(|_| "sync.local_corrupt: authority subscription state is invalid")?;
14968                    let effective = state.get("effectiveScopes").and_then(Value::as_object);
14969                    let status = state
14970                        .get("status")
14971                        .and_then(Value::as_str)
14972                        .ok_or("sync.local_corrupt: authority subscription status is missing")?;
14973                    let cursor = state
14974                        .get("cursor")
14975                        .and_then(Value::as_i64)
14976                        .ok_or("sync.local_corrupt: authority subscription cursor is missing")?;
14977                    let complete = effective.is_some_and(|s| s.len() == read.scopes.len())
14978                        && status == "active"
14979                        && cursor >= 0
14980                        && state.get("bootstrapState").is_none_or(Value::is_null)
14981                        && state.get("params").is_none_or(Value::is_null);
14982                    let exposed = effective.map(|s| {
14983                        read.scopes
14984                            .keys()
14985                            .map(|v| (v.clone(), s.get(v).cloned().unwrap_or(json!([]))))
14986                            .collect::<Map<_, _>>()
14987                    });
14988                    let requested = read
14989                        .scopes
14990                        .keys()
14991                        .map(|v| {
14992                            (
14993                                v.clone(),
14994                                state
14995                                    .get("requested")
14996                                    .and_then(|s| s.get(v))
14997                                    .cloned()
14998                                    .unwrap_or(json!([])),
14999                            )
15000                        })
15001                        .collect::<Map<_, _>>();
15002                    persisted.push(json!({"requestedScopes": requested, "status": status, "cursor": cursor, "effectiveScopes": exposed, "complete": complete}));
15003                }
15004                fn covered(selectors: &[(&String, &Vec<String>)], candidates: &[&Value]) -> bool {
15005                    match selectors.split_first() {
15006                        None => !candidates.is_empty(),
15007                        Some(((variable, values), rest)) => values.iter().all(|value| {
15008                            covered(
15009                                rest,
15010                                &candidates
15011                                    .iter()
15012                                    .copied()
15013                                    .filter(|p| {
15014                                        p["effectiveScopes"][variable.as_str()]
15015                                            .as_array()
15016                                            .is_some_and(|held| {
15017                                                held.contains(&Value::String(value.clone()))
15018                                                    || held.contains(&Value::String("*".into()))
15019                                            })
15020                                    })
15021                                    .collect::<Vec<_>>(),
15022                            )
15023                        }),
15024                    }
15025                }
15026                let coverage = if covered(
15027                    &read.scopes.iter().collect::<Vec<_>>(),
15028                    &persisted
15029                        .iter()
15030                        .filter(|p| p["complete"] == true)
15031                        .collect::<Vec<_>>(),
15032                ) {
15033                    "complete"
15034                } else if persisted.iter().any(|p| {
15035                    p["status"] == "active"
15036                        && read.scopes.iter().all(|(v, values)| {
15037                            values.iter().any(|value| {
15038                                p["requestedScopes"][v].as_array().is_some_and(|held| {
15039                                    held.contains(&Value::String(value.clone()))
15040                                        || held.contains(&Value::String("*".into()))
15041                                })
15042                            })
15043                        })
15044                }) {
15045                    "pending"
15046                } else {
15047                    "missing"
15048                };
15049                tables.push(json!({"table": read.table, "rows": rows, "localIntentRowIds": intent, "scopes": read.scopes, "coverage": coverage, "persisted": persisted}));
15050            }
15051            Ok(
15052                json!({"revision": revision, "complete": tables.iter().all(|t| t["coverage"] == "complete"), "tables": tables}),
15053            )
15054        })();
15055        match result {
15056            Ok(snapshot) => {
15057                self.conn
15058                    .execute_batch("RELEASE authority_snapshot")
15059                    .map_err(|e| e.to_string())?;
15060                Ok(snapshot)
15061            }
15062            Err(error) => {
15063                self.conn
15064                    .execute_batch("ROLLBACK TO authority_snapshot; RELEASE authority_snapshot")
15065                    .map_err(|e| format!("{error}; rollback failed: {e}"))?;
15066                Err(error)
15067            }
15068        }
15069    }
15070
15071    /// Rows, coverage, and local revision from one SQLite read snapshot.
15072    /// §7.5 atomic snapshot read. An `owner` records a failure in
15073    /// `diagnostics_snapshot().query_failures` until its next successful read.
15074    pub fn query_snapshot(
15075        &mut self,
15076        sql: &str,
15077        params: &[QueryValue],
15078        coverage: &[WindowCoverage],
15079        owner: Option<QueryOwner<'_>>,
15080    ) -> Result<QuerySnapshot, String> {
15081        let result = snapshot_connection(&self.conn, sql, params, coverage);
15082        if let Some(owner) = owner {
15083            self.record_query_read(owner, result.as_ref().err());
15084        }
15085        result.map_err(|failure| failure.to_string())
15086    }
15087
15088    /// §7.6: record the outcome of one owned snapshot read, including reads a
15089    /// host ran on a separate connection. A success removes the owner's entry;
15090    /// an identical failure leaves it unchanged; a changed failure moves last.
15091    pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
15092        let position = self
15093            .query_failures
15094            .iter()
15095            .position(|entry| entry.id == owner.id);
15096        let Some(failure) = failure else {
15097            if let Some(index) = position {
15098                self.query_failures.remove(index);
15099            }
15100            return;
15101        };
15102        let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
15103            .into_iter()
15104            .map(str::to_owned)
15105            .collect();
15106        let code = failure.code.unwrap_or("client.query_failed").to_owned();
15107        if position.is_some_and(|index| {
15108            let entry = &self.query_failures[index];
15109            entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
15110        }) {
15111            return;
15112        }
15113        if let Some(index) = position {
15114            self.query_failures.remove(index);
15115        }
15116        self.query_failures.push(DiagnosticQueryFailure {
15117            id: owner.id.to_owned(),
15118            tables,
15119            code,
15120            sqlite_code: failure.sqlite_code,
15121            at_ms: self.clock_now_ms(),
15122        });
15123        if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
15124            self.query_failures.remove(0);
15125        }
15126    }
15127
15128    // -- request building ---------------------------------------------------------
15129
15130    fn build_request(&self, url_capable: bool) -> Result<(Message, RequestMeta), Box<SyncOutcome>> {
15131        #[cfg(feature = "bench-internals")]
15132        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
15133        let log_epoch = self.get_meta(LOG_EPOCH_KEY);
15134        let max_commits = self.limits.max_push_commits_per_request;
15135        let max_ops = self
15136            .limits
15137            .max_push_operations_per_request
15138            .unwrap_or(PUSH_OPS_PER_REQUEST);
15139        let byte_cap = self.limits.max_push_request_bytes;
15140        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
15141        // bare URL — capability negotiation, decided per transport.
15142        let accept = self.limits.accept.unwrap_or(if url_capable {
15143            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
15144        } else {
15145            DEFAULT_ACCEPT
15146        });
15147        let header = Frame::ReqHeader {
15148            client_id: self.client_id.clone(),
15149            schema_version: self.schema.version,
15150            log_epoch: log_epoch.clone(),
15151        };
15152        let pull = Frame::PullHeader {
15153            limit_commits: self.limits.limit_commits.unwrap_or(0),
15154            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
15155            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
15156            accept,
15157        };
15158        let mut fresh = Vec::new();
15159        let mut sub_frames = Vec::new();
15160        for sub in &self.subs {
15161            if sub.state != SubState::Active {
15162                continue;
15163            }
15164            let mut scopes = sub.requested.clone();
15165            sort_scope_map(&mut scopes);
15166            sub_frames.push(Frame::Subscription {
15167                id: sub.id.clone(),
15168                table: sub.table.clone(),
15169                scopes,
15170                params: sub.params.clone().map(RawJson),
15171                cursor: sub.cursor,
15172                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
15173            });
15174            fresh.push((
15175                sub.id.clone(),
15176                sub.cursor < 0 && sub.bootstrap_state.is_none(),
15177            ));
15178        }
15179        // The fixed frames (order-independent size) and the header+pull probe
15180        // both encode canonically, so the byte budget is the exact wire size.
15181        let fixed_bytes = match byte_cap {
15182            Some(_) => {
15183                let mut frames = vec![header.clone(), pull.clone()];
15184                frames.extend(sub_frames.iter().cloned());
15185                encode_message(&Message {
15186                    wire_version: WIRE_VERSION,
15187                    msg_kind: MsgKind::Request,
15188                    frames,
15189                })
15190                .len()
15191            }
15192            None => 0,
15193        };
15194        if let Some(cap) = byte_cap {
15195            if fixed_bytes > cap {
15196                return Err(Self::push_capacity_outcome("bytes", cap, fixed_bytes, None));
15197            }
15198        }
15199        let base_bytes = match byte_cap {
15200            Some(_) => encode_message(&Message {
15201                wire_version: WIRE_VERSION,
15202                msg_kind: MsgKind::Request,
15203                frames: vec![header.clone(), pull.clone()],
15204            })
15205            .len(),
15206            None => 0,
15207        };
15208        let total_outbox = if log_epoch.is_some() {
15209            self.outbox.len()
15210        } else {
15211            0
15212        };
15213        let mut push_frames: Vec<Frame> = Vec::new();
15214        let mut pushed_ids = Vec::new();
15215        let mut ops_in_request = 0usize;
15216        let mut bytes_in_request = 0usize;
15217        let mut deferred_commits = 0usize;
15218        #[cfg(feature = "bench-internals")]
15219        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
15220        for (index, commit) in self.outbox.iter().take(total_outbox).enumerate() {
15221            // §6.1/§7.1: the commit-count and operation caps are hard. A
15222            // first commit over the operation cap is a typed capacity error
15223            // and stays queued; a later one defers with the complete suffix.
15224            if max_commits.is_some_and(|cap| push_frames.len() >= cap) {
15225                deferred_commits = total_outbox - index;
15226                break;
15227            }
15228            if ops_in_request + commit.ops.len() > max_ops {
15229                if push_frames.is_empty() {
15230                    return Err(Self::push_capacity_outcome(
15231                        "operations",
15232                        max_ops,
15233                        commit.ops.len(),
15234                        Some(commit.client_commit_id.as_str()),
15235                    ));
15236                }
15237                deferred_commits = total_outbox - index;
15238                break;
15239            }
15240            let operations = commit
15241                .ops
15242                .iter()
15243                .map(|op| {
15244                    let payload = op.values.as_ref().and_then(|values| {
15245                        let table = self.schema.table(&op.table)?;
15246                        // §0: outbox entries encode at send time with the
15247                        // current codec (validated at mutate()). §6.1: the
15248                        // values map's keys are the sparse presence set.
15249                        // §5.11: encrypted columns are encrypted here, with
15250                        // an absent key-id selector read from the stored row.
15251                        // `sync_inner` runs `drop_unencodable_outbox` first,
15252                        // so an encrypt failure cannot reach this encode; the
15253                        // author seam validated every other failure.
15254                        let fallback = self.stored_key_fallback(table, &op.row_id);
15255                        encode_sparse_row_json(
15256                            table,
15257                            &op.row_id,
15258                            values,
15259                            &self.encryption,
15260                            fallback.as_ref(),
15261                        )
15262                        .ok()
15263                    });
15264                    ssp2::model::Operation {
15265                        table: op.table.clone(),
15266                        row_id: op.row_id.clone(),
15267                        op: if op.upsert { Op::Upsert } else { Op::Delete },
15268                        base_version: op.base_version,
15269                        payload,
15270                    }
15271                })
15272                .collect();
15273            let frame = Frame::PushCommit {
15274                client_commit_id: commit.client_commit_id.clone(),
15275                operations,
15276            };
15277            let frame_bytes = match byte_cap {
15278                Some(_) => {
15279                    encode_message(&Message {
15280                        wire_version: WIRE_VERSION,
15281                        msg_kind: MsgKind::Request,
15282                        frames: vec![header.clone(), frame.clone(), pull.clone()],
15283                    })
15284                    .len()
15285                        - base_bytes
15286                }
15287                None => 0,
15288            };
15289            // The head is always encoded so the caller can report the full
15290            // projected request size; a later commit defers once it no longer
15291            // fits.
15292            if let Some(cap) = byte_cap {
15293                if !push_frames.is_empty() && fixed_bytes + bytes_in_request + frame_bytes > cap {
15294                    deferred_commits = total_outbox - index;
15295                    break;
15296                }
15297            }
15298            push_frames.push(frame);
15299            pushed_ids.push(commit.client_commit_id.clone());
15300            ops_in_request += commit.ops.len();
15301            bytes_in_request += frame_bytes;
15302        }
15303        #[cfg(feature = "bench-internals")]
15304        drop(outbox_phase);
15305        if let Some(cap) = byte_cap {
15306            if fixed_bytes + bytes_in_request > cap {
15307                // The head alone does not fit this budget; the commit stays
15308                // queued and no transport round is attempted.
15309                let blocked = if push_frames.is_empty() {
15310                    None
15311                } else {
15312                    self.outbox.first().map(|c| c.client_commit_id.as_str())
15313                };
15314                return Err(Self::push_capacity_outcome(
15315                    "bytes",
15316                    cap,
15317                    fixed_bytes + bytes_in_request,
15318                    blocked,
15319                ));
15320            }
15321        }
15322        let mut frames = vec![header];
15323        frames.extend(push_frames);
15324        frames.push(pull);
15325        frames.extend(sub_frames);
15326        let message = Message {
15327            wire_version: WIRE_VERSION,
15328            msg_kind: MsgKind::Request,
15329            frames,
15330        };
15331        Ok((
15332            message,
15333            RequestMeta {
15334                pushed_ids,
15335                fresh,
15336                accept,
15337                deferred_commits,
15338            },
15339        ))
15340    }
15341
15342    /// §7.1: a configured push capacity limit cannot carry this request.
15343    fn push_capacity_outcome(
15344        kind: &str,
15345        limit: usize,
15346        size: usize,
15347        client_commit_id: Option<&str>,
15348    ) -> Box<SyncOutcome> {
15349        let mut details = Map::new();
15350        details.insert("kind".to_owned(), Value::from(kind));
15351        details.insert("limit".to_owned(), Value::from(limit as u64));
15352        details.insert("size".to_owned(), Value::from(size as u64));
15353        if let Some(id) = client_commit_id {
15354            details.insert("clientCommitId".to_owned(), Value::from(id));
15355        }
15356        Box::new(SyncOutcome::Failed {
15357            error_code: "client.push_request_too_large".into(),
15358            message: "the push request exceeds a configured capacity limit".into(),
15359            details: Some(Value::Object(details)),
15360        })
15361    }
15362
15363    // -- sync -------------------------------------------------------------------
15364
15365    /// Cloneable observation handle; no client lock is needed to read it.
15366    pub fn progress(&self) -> ProgressObserver {
15367        self.progress.clone()
15368    }
15369
15370    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
15371        let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
15372            Ok(prepared) => prepared,
15373            Err(outcome) => return *outcome,
15374        };
15375        // The round's network deadline rides `PreparedSyncRound` and is scoped
15376        // to each exchange, so uploads, continuations, the main request, and
15377        // every segment fetch share one monotonic anchor without leaving
15378        // deadline state on a reused transport.
15379        let mut next = prepared;
15380        loop {
15381            match self.apply_sync_round(next.exchange(transport)) {
15382                crate::AppliedSyncRound::Continue(prepared) => next = prepared,
15383                crate::AppliedSyncRound::Complete {
15384                    outcome, controls, ..
15385                } => {
15386                    if self.transport_enabled {
15387                        for text in controls {
15388                            let _ = transport.realtime_send(&text);
15389                        }
15390                    } else {
15391                        self.disconnect_realtime(transport);
15392                    }
15393                    return outcome;
15394                }
15395            }
15396        }
15397    }
15398
15399    fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
15400        if let SyncOutcome::Failed {
15401            error_code,
15402            message,
15403            details,
15404        } = &mut outcome
15405        {
15406            if let Some(failure) = self.storage_failure.borrow().as_ref() {
15407                let code = failure
15408                    .code
15409                    .expect("retained storage failures are classified");
15410                *error_code = code.to_owned();
15411                *message = match code {
15412                    "client.storage_busy" => "local SQLite storage is busy",
15413                    "client.storage_full" => "local SQLite storage is full",
15414                    "client.storage_io" => "local SQLite storage I/O failed",
15415                    "client.storage_corrupt" => "local SQLite storage is corrupt",
15416                    _ => unreachable!("unrecognized classified storage failure"),
15417                }
15418                .to_owned();
15419                *details = failure.details();
15420            }
15421        }
15422        let retry_delay_ms = self.round_retry_delay_ms;
15423        self.progress.update(|p| match &outcome {
15424            SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report)
15425                if report.failed.is_empty() =>
15426            {
15427                p.state = ProgressState::Complete
15428            }
15429            SyncOutcome::Ok(_) | SyncOutcome::BudgetExhausted(_) => {
15430                p.state = ProgressState::Failed;
15431                p.error_code = Some("sync.scope_revoked".into());
15432            }
15433            SyncOutcome::Failed { error_code, .. } => {
15434                p.state = ProgressState::Failed;
15435                p.error_code = Some(Self::diagnostic_code(error_code));
15436                p.retry_delay_ms = retry_delay_ms;
15437            }
15438            SyncOutcome::RealtimeUnavailable { .. } => {
15439                p.state = ProgressState::Failed;
15440                p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
15441                p.retry_delay_ms = retry_delay_ms;
15442            }
15443        });
15444        let completed_at_ms = self.clock_now_ms();
15445        self.last_round = Some(match &outcome {
15446            SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report) => DiagnosticLastRound {
15447                status: "succeeded".to_owned(),
15448                started_at_ms,
15449                completed_at_ms,
15450                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
15451                counters: Some(DiagnosticRoundCounters {
15452                    pushed: report.pushed,
15453                    applied: report.applied.len(),
15454                    rejected: report.rejected.len(),
15455                    retryable: report.retryable.len(),
15456                    conflicts: report.conflicts,
15457                    commits_applied: report.commits_applied,
15458                    segment_rows_applied: report.segment_rows_applied,
15459                    bootstrapping: report.bootstrapping.len(),
15460                    resets: report.resets.len(),
15461                    revoked: report.revoked.len(),
15462                    failed: report.failed.len(),
15463                    deferred_commits: report.deferred_commits,
15464                }),
15465                error_code: None,
15466            },
15467            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
15468                status: "failed".to_owned(),
15469                started_at_ms,
15470                completed_at_ms,
15471                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
15472                counters: None,
15473                error_code: Some(Self::diagnostic_code(error_code)),
15474            },
15475            SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
15476                status: "failed".to_owned(),
15477                started_at_ms,
15478                completed_at_ms,
15479                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
15480                counters: None,
15481                error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
15482            },
15483        });
15484        outcome
15485    }
15486
15487    /// Capture one request on the client owner. Mutations made after this
15488    /// call remain in the outbox for a later round; no network I/O occurs here.
15489    pub fn prepare_sync_round(
15490        &mut self,
15491        url_capable: bool,
15492    ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
15493        if !self.transport_enabled {
15494            return Err(Box::new(SyncOutcome::Failed {
15495                error_code: "sync.offline".into(),
15496                message: "transport gate is closed".into(),
15497                details: None,
15498            }));
15499        }
15500        if self.active_round.is_some() {
15501            return Err(Box::new(SyncOutcome::Failed {
15502                error_code: "client.round_in_progress".into(),
15503                message: "a sync round is already in flight".into(),
15504                details: None,
15505            }));
15506        }
15507        self.storage_failure.borrow_mut().take();
15508        self.progress.start();
15509        self.round_retry_delay_ms = None;
15510        let started_at_ms = self.clock_now_ms();
15511        let prepared = (|| {
15512            if self.stopped {
15513                return Err(Box::new(SyncOutcome::Ok(SyncReport {
15514                    schema_floor: self.schema_floor.clone(),
15515                    ..SyncReport::default()
15516                })));
15517            }
15518            if self.security_preflight {
15519                return Err(Box::new(SyncOutcome::Failed {
15520                    error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
15521                    message: "security preflight must complete before syncing".into(),
15522                    details: None,
15523                }));
15524            }
15525            self.set_sync_needed(false, false);
15526            self.drain_pending_evictions()
15527                .and_then(|_| self.drop_incompatible_outbox())
15528                .and_then(|_| self.drop_unencodable_outbox(true, None))
15529                .map_err(|message| {
15530                    Box::new(SyncOutcome::Failed {
15531                        error_code: "storage.failed".into(),
15532                        message,
15533                        details: None,
15534                    })
15535                })?;
15536            let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
15537                self.pending_blob_uploads().map_err(|e| {
15538                    Box::new(SyncOutcome::Failed {
15539                        error_code: e.code,
15540                        message: e.message,
15541                        details: e.details,
15542                    })
15543                })?
15544            } else {
15545                Vec::new()
15546            };
15547            if self.realtime_state != RealtimeState::Connected
15548                && self.realtime_policy == RealtimePolicy::Required
15549            {
15550                let retry_delay_ms = self.schedule_background_retry();
15551                self.realtime_retry_delay_ms = Some(retry_delay_ms);
15552                return Err(Box::new(SyncOutcome::RealtimeUnavailable {
15553                    state: self.realtime_state,
15554                    reason_code: self.realtime_reason_code.clone(),
15555                    retry_delay_ms,
15556                }));
15557            }
15558            let (message, meta) = self.build_request(url_capable)?;
15559            let id = uuid::Uuid::new_v4();
15560            self.active_round = Some(id);
15561            Ok(crate::PreparedSyncRound {
15562                id,
15563                started_at_ms,
15564                message,
15565                meta,
15566                uploads: uploads.into(),
15567                realtime: self.realtime_state == RealtimeState::Connected,
15568                scopes: self
15569                    .subs
15570                    .iter()
15571                    .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
15572                    .collect(),
15573                #[cfg(feature = "bench-internals")]
15574                benchmark_phases: self.benchmark_phases.clone(),
15575                progress: self.progress.clone(),
15576                fixed_now: self.now_ms,
15577                round_deadline_at: None,
15578            })
15579        })();
15580        prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
15581    }
15582
15583    /// Discard the reply of an old authorization/subscription context. Its
15584    /// submitted commits stay durable and can be retried with the same ids.
15585    pub fn cancel_sync_round(&mut self) {
15586        self.active_round = None;
15587    }
15588
15589    /// Apply captured network results on the owner, preserving all normal
15590    /// version, schema, content-address and cursor checks. No network I/O.
15591    pub fn apply_sync_round(
15592        &mut self,
15593        completed: crate::CompletedSyncRound,
15594    ) -> crate::AppliedSyncRound {
15595        let prepared = completed.prepared;
15596        if self.active_round != Some(prepared.id) || self.security_preflight {
15597            return crate::AppliedSyncRound::Complete {
15598                outcome: SyncOutcome::Failed {
15599                    error_code: "client.round_cancelled".into(),
15600                    message: "sync round belongs to a released client context".into(),
15601                    details: None,
15602                },
15603                controls: Vec::new(),
15604                more: false,
15605                bootstrap_advanced: false,
15606            };
15607        }
15608        self.storage_failure.borrow_mut().take();
15609        let (response, mut downloads, transport_failed) = match completed.exchange {
15610            crate::round::ExchangeResult::Upload { id, result } => {
15611                let result = result.and_then(|()| {
15612                    self.conn
15613                        .execute(
15614                            "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
15615                            rusqlite::params![id],
15616                        )
15617                        .map(|_| ())
15618                        .map_err(|error| {
15619                            TransportError::new(
15620                                "client.failed",
15621                                Self::sqlite_failure(&self.storage_failure, error),
15622                            )
15623                        })
15624                });
15625                match result {
15626                    Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
15627                    Err(TransportError {
15628                        code,
15629                        message,
15630                        details,
15631                    }) => {
15632                        self.active_round = None;
15633                        if Self::retryable_failure_code(&code) {
15634                            self.schedule_background_retry();
15635                        }
15636                        let outcome = self.finish_sync_round(
15637                            prepared.started_at_ms,
15638                            SyncOutcome::Failed {
15639                                error_code: code,
15640                                message,
15641                                details,
15642                            },
15643                        );
15644                        return crate::AppliedSyncRound::Complete {
15645                            outcome,
15646                            controls: Vec::new(),
15647                            more: false,
15648                            bootstrap_advanced: false,
15649                        };
15650                    }
15651                }
15652            }
15653            crate::round::ExchangeResult::Reply {
15654                response,
15655                downloads,
15656                transport_failed,
15657            } => (response, downloads, transport_failed),
15658        };
15659        self.active_round = None;
15660        let before = self
15661            .subs
15662            .iter()
15663            .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
15664            .collect::<Vec<_>>();
15665        let outcome = (|| {
15666            let response = match response {
15667                Ok(response) => response,
15668                Err(TransportError {
15669                    code,
15670                    message,
15671                    details,
15672                }) => {
15673                    if transport_failed {
15674                        if prepared.realtime {
15675                            self.set_realtime_state(RealtimeState::Lost, Some(&code));
15676                        }
15677                        self.record_lease_error(&code);
15678                    }
15679                    if Self::retryable_failure_code(&code) {
15680                        let delay = self.schedule_background_retry();
15681                        if prepared.realtime && transport_failed {
15682                            self.realtime_retry_delay_ms = Some(delay);
15683                        }
15684                    }
15685                    return SyncOutcome::Failed {
15686                        error_code: code,
15687                        message,
15688                        details,
15689                    };
15690                }
15691            };
15692            let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
15693            if let SyncOutcome::Ok(report) = &mut outcome {
15694                report.deferred_commits = prepared.meta.deferred_commits;
15695            }
15696            match &outcome {
15697                SyncOutcome::Ok(_) => self.reset_background_retry(),
15698                SyncOutcome::Failed { error_code, .. }
15699                    if Self::retryable_failure_code(error_code) =>
15700                {
15701                    self.schedule_background_retry();
15702                }
15703                _ => {}
15704            }
15705            if prepared.meta.deferred_commits > 0 {
15706                self.set_sync_needed(true, true);
15707            }
15708            self.previous_version_lifetime_check();
15709            outcome
15710        })();
15711        let more = self.transport_enabled
15712            && matches!(&outcome, SyncOutcome::Ok(report)
15713                if report.schema_floor.is_none()
15714                    && (!report.bootstrapping.is_empty()
15715                        || report.commits_applied > 0
15716                        || report.segment_rows_applied > 0
15717                        || !report.resets.is_empty()
15718                        || report.deferred_commits > 0
15719                        || self.sync_needed));
15720        let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
15721            && before
15722                != self
15723                    .subs
15724                    .iter()
15725                    .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
15726                    .collect::<Vec<_>>();
15727        crate::AppliedSyncRound::Complete {
15728            outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
15729            controls: if self.transport_enabled {
15730                downloads.controls
15731            } else {
15732                Vec::new()
15733            },
15734            more,
15735            bootstrap_advanced,
15736        }
15737    }
15738
15739    pub fn sync_until_idle(
15740        &mut self,
15741        transport: &mut dyn Transport,
15742        max_rounds: Option<u32>,
15743    ) -> SyncOutcome {
15744        // §7.7: an explicit budget is a strict positive round count. Zero is a
15745        // caller error, not an exhaustion.
15746        if max_rounds == Some(0) {
15747            return SyncOutcome::Failed {
15748                details: None,
15749                error_code: "sync.invalid_request".into(),
15750                message: "maxRounds must be a positive integer".into(),
15751            };
15752        }
15753        let rounds = max_rounds.unwrap_or(20);
15754        let mut aggregate = SyncReport::default();
15755        let mut spent = 0;
15756        while spent < rounds {
15757            spent += 1;
15758            let before = self
15759                .subs
15760                .iter()
15761                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
15762                .collect::<Vec<_>>();
15763            match self.sync(transport) {
15764                SyncOutcome::Failed {
15765                    error_code,
15766                    message,
15767                    details,
15768                } => {
15769                    return SyncOutcome::Failed {
15770                        details,
15771                        error_code,
15772                        message,
15773                    };
15774                }
15775                outcome @ SyncOutcome::RealtimeUnavailable { .. } => return outcome,
15776                SyncOutcome::BudgetExhausted(_) => {
15777                    unreachable!("sync() never exhausts a round budget")
15778                }
15779                SyncOutcome::Ok(report) => {
15780                    if max_rounds.is_none()
15781                        && report.segment_rows_applied > 0
15782                        && !report.bootstrapping.is_empty()
15783                        && before
15784                            != self
15785                                .subs
15786                                .iter()
15787                                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
15788                                .collect::<Vec<_>>()
15789                    {
15790                        spent = 0;
15791                    }
15792                    // TS parity: a disabled transport or a schema floor ends
15793                    // the loop. The latest round still merges first.
15794                    let stop = !self.transport_enabled() || report.schema_floor.is_some();
15795                    aggregate.merge(&report);
15796                    if stop {
15797                        return SyncOutcome::Ok(aggregate);
15798                    }
15799                    // §4.5: pull again whenever the response contained
15800                    // commits or segments; resets re-bootstrap; a pending
15801                    // resume token continues paging (§4.7); deferred outbox
15802                    // commits and a raised sync-needed signal push on the next
15803                    // round.
15804                    let more = !report.bootstrapping.is_empty()
15805                        || report.commits_applied > 0
15806                        || report.segment_rows_applied > 0
15807                        || !report.resets.is_empty()
15808                        || report.deferred_commits > 0
15809                        || self.sync_needed;
15810                    if !more {
15811                        return SyncOutcome::Ok(aggregate);
15812                    }
15813                }
15814            }
15815        }
15816        // §7.7: partial success. The aggregate report is retained and the run
15817        // is explicitly not idle.
15818        SyncOutcome::BudgetExhausted(aggregate)
15819    }
15820
15821    fn process_response(
15822        &mut self,
15823        transport: &mut dyn Transport,
15824        response: Message,
15825        meta: &RequestMeta,
15826    ) -> SyncOutcome {
15827        #[cfg(feature = "bench-internals")]
15828        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
15829        let mut report = SyncReport {
15830            pushed: meta.pushed_ids.len() as u32,
15831            ..SyncReport::default()
15832        };
15833        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
15834            HashMap::new();
15835        let pushed_ids = meta
15836            .pushed_ids
15837            .iter()
15838            .map(String::as_str)
15839            .collect::<HashSet<_>>();
15840        let mut last_final_push_result_id: Option<String> = None;
15841        for frame in &response.frames {
15842            match frame {
15843                Frame::PushResultDetails {
15844                    client_commit_id,
15845                    entries,
15846                } => {
15847                    let details = rejection_details_by_commit
15848                        .entry(client_commit_id.clone())
15849                        .or_default();
15850                    for entry in entries {
15851                        let parsed = match RejectionDetails::parse(&entry.details.0) {
15852                            Ok(value) => value,
15853                            Err(message) => {
15854                                return SyncOutcome::Failed {
15855                                    details: None,
15856                                    error_code: "sync.invalid_request".to_owned(),
15857                                    message,
15858                                };
15859                            }
15860                        };
15861                        details.insert(entry.op_index, parsed);
15862                    }
15863                }
15864                Frame::PushResult {
15865                    client_commit_id,
15866                    status,
15867                    results,
15868                    ..
15869                } if pushed_ids.contains(client_commit_id.as_str())
15870                    && Self::push_result_is_final(*status, results) =>
15871                {
15872                    last_final_push_result_id = Some(client_commit_id.clone());
15873                }
15874                _ => {}
15875            }
15876        }
15877        let mut frames = response.frames.into_iter().peekable();
15878        if response.wire_version < 2 {
15879            return SyncOutcome::Failed {
15880                details: None,
15881                error_code: "client.invalid_host_response".to_owned(),
15882                message: "server response does not carry wire version 2 log-epoch state".to_owned(),
15883            };
15884        }
15885        match frames.next() {
15886            Some(Frame::RespHeader {
15887                required_schema_version,
15888                latest_schema_version,
15889                log_epoch,
15890                reset_required,
15891            }) => {
15892                let Some(log_epoch) = log_epoch else {
15893                    return SyncOutcome::Failed {
15894                        details: None,
15895                        error_code: "client.invalid_host_response".to_owned(),
15896                        message: "response header omits logEpoch".to_owned(),
15897                    };
15898                };
15899                let Some(reset_required) = reset_required else {
15900                    return SyncOutcome::Failed {
15901                        details: None,
15902                        error_code: "client.invalid_host_response".to_owned(),
15903                        message: "response header omits resetRequired".to_owned(),
15904                    };
15905                };
15906                if let Some(required) = required_schema_version {
15907                    // §1.6 schema-floor response: nothing else is processed;
15908                    // stop syncing and surface the upgrade requirement.
15909                    let floor = SchemaFloor {
15910                        required_schema_version: Some(required),
15911                        latest_schema_version,
15912                    };
15913                    self.set_schema_floor(Some(floor.clone()));
15914                    report.schema_floor = Some(floor);
15915                    return SyncOutcome::Ok(report);
15916                }
15917                if reset_required {
15918                    if frames.next().is_some() {
15919                        return SyncOutcome::Failed {
15920                            details: None,
15921                            error_code: "client.invalid_host_response".to_owned(),
15922                            message: "log-epoch reset response contains body frames".to_owned(),
15923                        };
15924                    }
15925                    match self.run_log_epoch_reset(&log_epoch) {
15926                        Ok(resets) => {
15927                            report.resets = resets;
15928                            return SyncOutcome::Ok(report);
15929                        }
15930                        Err(message) => {
15931                            return SyncOutcome::Failed {
15932                                details: None,
15933                                error_code: "sync.local_corrupt".to_owned(),
15934                                message,
15935                            };
15936                        }
15937                    }
15938                }
15939                if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
15940                    return SyncOutcome::Failed {
15941                        details: None,
15942                        error_code: "client.invalid_host_response".to_owned(),
15943                        message: "server changed logEpoch without requiring a reset".to_owned(),
15944                    };
15945                }
15946            }
15947            _ => {
15948                return SyncOutcome::Failed {
15949                    details: None,
15950                    error_code: "sync.invalid_request".to_owned(),
15951                    message: "response does not start with RESP_HEADER".to_owned(),
15952                };
15953            }
15954        }
15955
15956        let mut failure: Option<(String, String)> = None;
15957        while let Some(frame) = frames.next() {
15958            match frame {
15959                frame @ Frame::PushResult { .. } => {
15960                    let successful = matches!(
15961                        &frame,
15962                        Frame::PushResult {
15963                            status: PushStatus::Applied | PushStatus::Cached,
15964                            ..
15965                        }
15966                    );
15967                    let mut results = vec![frame];
15968                    if successful {
15969                        while matches!(
15970                            frames.peek(),
15971                            Some(Frame::PushResult {
15972                                status: PushStatus::Applied | PushStatus::Cached,
15973                                ..
15974                            })
15975                        ) {
15976                            if let Some(next) = frames.next() {
15977                                results.push(next);
15978                            }
15979                        }
15980                    }
15981                    if self
15982                        .handle_push_results(
15983                            &results,
15984                            &pushed_ids,
15985                            &rejection_details_by_commit,
15986                            &mut report,
15987                            last_final_push_result_id.as_deref(),
15988                        )
15989                        .is_err()
15990                    {
15991                        failure = Some((
15992                            "client.outcome_persistence_failed".to_owned(),
15993                            "local commit outcome could not be persisted".to_owned(),
15994                        ));
15995                        break;
15996                    }
15997                }
15998                Frame::PushResultDetails { .. } => {}
15999                Frame::SubStart {
16000                    id,
16001                    status,
16002                    reason_code,
16003                    effective_scopes,
16004                    bootstrap: _,
16005                } => {
16006                    let mut body = Vec::new();
16007                    let mut sub_end: Option<(i64, Option<String>)> = None;
16008                    for inner in frames.by_ref() {
16009                        match inner {
16010                            Frame::SubEnd {
16011                                next_cursor,
16012                                bootstrap_state,
16013                            } => {
16014                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
16015                                break;
16016                            }
16017                            Frame::Unknown { .. } => {}
16018                            other => body.push(other),
16019                        }
16020                    }
16021                    if let Err(SectionError::Abort(code, message)) = self.process_section(
16022                        transport,
16023                        &id,
16024                        status,
16025                        &reason_code,
16026                        effective_scopes,
16027                        body,
16028                        sub_end,
16029                        meta,
16030                        &mut report,
16031                    ) {
16032                        failure = Some((code, message));
16033                        break;
16034                    }
16035                }
16036                Frame::Lease {
16037                    lease_id,
16038                    expires_at_ms,
16039                } => {
16040                    // §7.3.5: persist the opaque lease; a fresh lease clears
16041                    // any prior lease error (the outage/revocation is over).
16042                    self.set_lease_state(Some(LeaseState {
16043                        lease_id: Some(lease_id),
16044                        expires_at_ms: Some(expires_at_ms),
16045                        error_code: None,
16046                    }));
16047                }
16048                Frame::Error { code, message, .. } => {
16049                    // §1.6: the whole request failed; already-completed
16050                    // subscriptions keep their applied data and cursors.
16051                    failure = Some((code, message));
16052                    break;
16053                }
16054                Frame::Unknown { .. } => {}
16055                _ => {
16056                    failure = Some((
16057                        "sync.invalid_request".to_owned(),
16058                        "unexpected frame in response".to_owned(),
16059                    ));
16060                    break;
16061                }
16062            }
16063        }
16064
16065        if let Some((error_code, message)) = failure {
16066            return SyncOutcome::Failed {
16067                details: None,
16068                error_code,
16069                message,
16070            };
16071        }
16072        // Local apply boundaries already reconciled their affected overlay.
16073        // Refuse round success if any remaining dirty work fails to reconcile.
16074        if let Err(message) = self.rebuild_overlay_if_dirty() {
16075            return SyncOutcome::Failed {
16076                details: None,
16077                error_code: "storage.failed".into(),
16078                message,
16079            };
16080        }
16081        // §4.8 E1: the push half may have drained commits that pinned rows of
16082        // a shrunk window unit — retry any deferred evictions now.
16083        if let Err(message) = self.drain_pending_evictions() {
16084            return SyncOutcome::Failed {
16085                details: None,
16086                error_code: "storage.failed".into(),
16087                message,
16088            };
16089        }
16090        self.ack_after_pull(transport);
16091        // §7.4.5: the reset is over once the first post-reset pull round
16092        // leaves no subscription mid-bootstrap — the tables are rebuilt.
16093        if self.upgrading && report.bootstrapping.is_empty() {
16094            self.set_upgrading(false);
16095        }
16096        SyncOutcome::Ok(report)
16097    }
16098
16099    // -- push results (§6.3, §7.2) ------------------------------------------------
16100
16101    fn handle_push_results(
16102        &mut self,
16103        frames: &[Frame],
16104        pushed_ids: &HashSet<&str>,
16105        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
16106        report: &mut SyncReport,
16107        last_final_push_result_id: Option<&str>,
16108    ) -> Result<(), String> {
16109        self.begin_observation("syncular_push_results")?;
16110        let previous_failed = self.failed_commits.clone();
16111        let previous_sync_needed = self.sync_needed;
16112        let conflict_count = self.conflicts.len();
16113        let rejection_count = self.rejections.len();
16114        let applied_count = report.applied.len();
16115        let rejected_count = report.rejected.len();
16116        let retryable_count = report.retryable.len();
16117        let was_dirty = self.overlay_dirty.snapshot();
16118        let mut removed = Vec::new();
16119        let mut batch = ChangeAccumulator::default();
16120        let persisted = (|| {
16121            for frame in frames {
16122                let Frame::PushResult {
16123                    client_commit_id,
16124                    status,
16125                    results,
16126                    commit_seq,
16127                } = frame
16128                else {
16129                    return Err("unexpected acknowledgement frame".to_owned());
16130                };
16131                if !pushed_ids.contains(client_commit_id.as_str()) {
16132                    continue;
16133                }
16134                let Some(index) = self
16135                    .outbox
16136                    .iter()
16137                    .position(|commit| commit.client_commit_id == *client_commit_id)
16138                else {
16139                    continue;
16140                };
16141                let status = *status;
16142                let rejection_details = rejection_details_by_commit.get(client_commit_id);
16143                let operations = self.outbox[index].ops.clone();
16144                match status {
16145                    PushStatus::Applied | PushStatus::Cached => {
16146                        // §7.2: a lost ack replays as `cached` — proceed as if the
16147                        // ack had arrived.
16148                        let sequence =
16149                            commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
16150                        let affected: HashSet<_> = operations
16151                            .iter()
16152                            .map(|op| (op.table.as_str(), op.row_id.as_str()))
16153                            .collect();
16154                        let prior = self.acknowledged_operations(Some(&affected), None)?;
16155                        for (op_index, operation) in operations.iter().enumerate() {
16156                            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))
16157                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16158                            if delivered {
16159                                continue;
16160                            }
16161                            let table = self
16162                                .schema
16163                                .table(&operation.table)
16164                                .ok_or_else(|| "sync.unknown_table".to_owned())?;
16165                            let mut intended: Map<String, Value> = self
16166                                .stored_row(table, &operation.row_id, true)?
16167                                .map(|(row, _)| {
16168                                    table
16169                                        .columns
16170                                        .iter()
16171                                        .zip(&row)
16172                                        .map(|(column, value)| {
16173                                            (column.name.clone(), column_value_to_json(value))
16174                                        })
16175                                        .collect()
16176                                })
16177                                .unwrap_or_default();
16178                            let mut scope_image = intended.clone();
16179                            for op in prior
16180                                .iter()
16181                                .chain(
16182                                    self.failed_commits
16183                                        .iter()
16184                                        .flat_map(|(commit, _)| &commit.ops),
16185                                )
16186                                .chain(
16187                                    self.outbox
16188                                        .iter()
16189                                        .take(index)
16190                                        .flat_map(|commit| &commit.ops),
16191                                )
16192                                .chain(operations.iter().take(op_index + 1))
16193                            {
16194                                if op.table != operation.table || op.row_id != operation.row_id {
16195                                    continue;
16196                                }
16197                                if let Some(values) = &op.values {
16198                                    intended.extend(values.clone());
16199                                } else {
16200                                    scope_image = intended.clone();
16201                                    intended.clear();
16202                                }
16203                            }
16204                            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)",
16205                                rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
16206                                    serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
16207                                    serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
16208                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16209                        }
16210                        let journal_results = results
16211                            .iter()
16212                            .map(|result| {
16213                                let op_index = match result {
16214                                    OpResult::Applied { op_index }
16215                                    | OpResult::Conflict { op_index, .. }
16216                                    | OpResult::Error { op_index, .. } => *op_index,
16217                                };
16218                                CommitOperationOutcome::Applied { op_index }
16219                            })
16220                            .collect::<Vec<_>>();
16221                        let outcome_status = if status == PushStatus::Applied {
16222                            CommitOutcomeStatus::Applied
16223                        } else {
16224                            CommitOutcomeStatus::Cached
16225                        };
16226                        let persisted = self
16227                            .persist_commit_outcome(
16228                                client_commit_id,
16229                                outcome_status,
16230                                &journal_results,
16231                                None,
16232                            )
16233                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
16234                        persisted?;
16235                        for operation in &operations {
16236                            self.overlay_dirty.table(&operation.table);
16237                        }
16238                        self.sync_needed = true;
16239                        batch.status = true;
16240                        batch.outcomes = true;
16241                    }
16242                    PushStatus::Rejected => {
16243                        if results.iter().any(|result| {
16244                            matches!(
16245                                result,
16246                                OpResult::Error {
16247                                    code,
16248                                    retryable: true,
16249                                    ..
16250                                } if code == "sync.idempotency_cache_miss"
16251                            )
16252                        }) {
16253                            // §6.3/§7.2: a serving failure, not an outcome — keep the
16254                            // exact commit queued for an identical retry.
16255                            report.retryable.push(client_commit_id.to_owned());
16256                            continue;
16257                        }
16258
16259                        let mut journal_results = Vec::with_capacity(results.len());
16260                        let mut conflicts = Vec::new();
16261                        let mut rejections = Vec::new();
16262                        for result in results {
16263                            match result {
16264                                OpResult::Applied { op_index } => {
16265                                    journal_results.push(CommitOperationOutcome::Applied {
16266                                        op_index: *op_index,
16267                                    });
16268                                }
16269                                OpResult::Conflict {
16270                                    op_index,
16271                                    code,
16272                                    message,
16273                                    server_version,
16274                                    server_row,
16275                                    conflict_columns: conflict_columns_bitmap,
16276                                } => {
16277                                    let operation = operations
16278                                        .get(*op_index as usize)
16279                                        .map(CommitOperation::from);
16280                                    let (table, row_id) = operation
16281                                        .as_ref()
16282                                        .map(|op| (op.table.clone(), op.row_id.clone()))
16283                                        .unwrap_or_default();
16284                                    let server_row_json = self
16285                                        .schema
16286                                        .table(&table)
16287                                        .and_then(|t| {
16288                                            decode_row_bytes(t, server_row, &self.encryption)
16289                                                .ok()
16290                                                .map(|row| (t, row))
16291                                        })
16292                                        .map(|(t, row)| {
16293                                            let mut map = Map::new();
16294                                            for (i, column) in t.columns.iter().enumerate() {
16295                                                map.insert(
16296                                                    column.name.clone(),
16297                                                    column_value_to_json(
16298                                                        row.get(i).unwrap_or(&None),
16299                                                    ),
16300                                                );
16301                                            }
16302                                            map
16303                                        })
16304                                        .unwrap_or_default();
16305                                    // §6.3: decode the presence-layout bitmap
16306                                    // into the marked column names.
16307                                    let conflict_columns = self
16308                                        .schema
16309                                        .table(&table)
16310                                        .map(|t| {
16311                                            t.columns
16312                                                .iter()
16313                                                .enumerate()
16314                                                .filter(|(index, _)| {
16315                                                    conflict_columns_bitmap
16316                                                        .get(index / 8)
16317                                                        .is_some_and(|byte| {
16318                                                            byte & (1 << (index % 8)) != 0
16319                                                        })
16320                                                })
16321                                                .map(|(_, column)| column.name.clone())
16322                                                .collect::<Vec<_>>()
16323                                        })
16324                                        .unwrap_or_default();
16325                                    let conflict = ConflictRecord {
16326                                        client_commit_id: client_commit_id.to_owned(),
16327                                        op_index: *op_index,
16328                                        table,
16329                                        row_id,
16330                                        code: code.clone(),
16331                                        message: message.clone(),
16332                                        server_version: *server_version,
16333                                        server_row: server_row_json,
16334                                        conflict_columns,
16335                                        operation,
16336                                    };
16337                                    journal_results.push(CommitOperationOutcome::Conflict {
16338                                        conflict: conflict.clone(),
16339                                    });
16340                                    conflicts.push(conflict);
16341                                }
16342                                OpResult::Error {
16343                                    op_index,
16344                                    code,
16345                                    message,
16346                                    retryable,
16347                                } => {
16348                                    let rejection = RejectionRecord {
16349                                        client_commit_id: client_commit_id.to_owned(),
16350                                        op_index: *op_index,
16351                                        code: code.clone(),
16352                                        message: message.clone(),
16353                                        retryable: *retryable,
16354                                        details: rejection_details
16355                                            .and_then(|details| details.get(op_index))
16356                                            .cloned(),
16357                                        operation: operations
16358                                            .get(*op_index as usize)
16359                                            .map(CommitOperation::from),
16360                                    };
16361                                    journal_results.push(CommitOperationOutcome::Error {
16362                                        rejection: rejection.clone(),
16363                                    });
16364                                    rejections.push(rejection);
16365                                }
16366                            }
16367                        }
16368                        if self.retain_failed_commits {
16369                            self.retain_failed_commit(client_commit_id, &operations)?;
16370                        }
16371                        let outcome_status = if conflicts.is_empty() {
16372                            CommitOutcomeStatus::Rejected
16373                        } else {
16374                            CommitOutcomeStatus::Conflict
16375                        };
16376                        let persisted = self
16377                            .persist_commit_outcome(
16378                                client_commit_id,
16379                                outcome_status,
16380                                &journal_results,
16381                                Some(&operations),
16382                            )
16383                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
16384                        persisted?;
16385                        batch.conflicts = !conflicts.is_empty();
16386                        batch.rejections = !rejections.is_empty();
16387                        batch.status = true;
16388                        batch.outcomes = true;
16389                        self.conflicts.extend(conflicts);
16390                        self.rejections.extend(rejections);
16391                        for operation in &operations {
16392                            self.overlay_dirty.table(&operation.table);
16393                        }
16394                    }
16395                }
16396                removed.push((index, self.outbox.remove(index)));
16397                if batch.status {
16398                    for operation in &operations {
16399                        if !self.record_row_scopes(
16400                            &mut batch,
16401                            &operation.table,
16402                            &operation.row_id,
16403                            false,
16404                        ) {
16405                            batch.table(&operation.table);
16406                        }
16407                    }
16408                }
16409                match status {
16410                    PushStatus::Applied | PushStatus::Cached => {
16411                        report.applied.push(client_commit_id.to_owned())
16412                    }
16413                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
16414                }
16415            }
16416            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)) {
16417                self.prune_commit_outcomes()?;
16418            }
16419            self.rebuild_overlay_if_dirty()?;
16420            self.finish_observation("syncular_push_results", batch)
16421        })();
16422        if let Err(error) = persisted {
16423            self.rollback_observation("syncular_push_results");
16424            self.failed_commits = previous_failed;
16425            self.sync_needed = previous_sync_needed;
16426            for (index, commit) in removed.into_iter().rev() {
16427                self.outbox.insert(index, commit);
16428            }
16429            self.conflicts.truncate(conflict_count);
16430            self.rejections.truncate(rejection_count);
16431            self.overlay_dirty.restore(was_dirty.clone());
16432            report.applied.truncate(applied_count);
16433            report.rejected.truncate(rejected_count);
16434            report.retryable.truncate(retryable_count);
16435            return Err(error);
16436        }
16437        if !previous_sync_needed && self.sync_needed {
16438            self.sync_intent_queue.push_back(SyncIntent::Interactive);
16439        }
16440        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
16441        Ok(())
16442    }
16443
16444    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
16445        status != PushStatus::Rejected
16446            || !results.iter().any(|result| {
16447                matches!(
16448                    result,
16449                    OpResult::Error {
16450                        code,
16451                        retryable: true,
16452                        ..
16453                    } if code == "sync.idempotency_cache_miss"
16454                )
16455            })
16456    }
16457
16458    // -- subscription sections ------------------------------------------------------
16459
16460    #[allow(clippy::too_many_arguments)]
16461    fn process_section(
16462        &mut self,
16463        transport: &mut dyn Transport,
16464        id: &str,
16465        status: SubStatus,
16466        reason_code: &str,
16467        effective_scopes: Vec<(String, Vec<String>)>,
16468        body: Vec<Frame>,
16469        sub_end: Option<(i64, Option<String>)>,
16470        meta: &RequestMeta,
16471        report: &mut SyncReport,
16472    ) -> Result<(), SectionError> {
16473        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
16474            return Ok(()); // unknown echo: ignore
16475        };
16476        if status != SubStatus::Active && sub_end.is_none() {
16477            return Err(SectionError::Abort(
16478                "sync.invalid_request".into(),
16479                "subscription section without SUB_END".into(),
16480            ));
16481        }
16482        match status {
16483            SubStatus::Revoked => {
16484                self.begin_observation("syncular_revocation")
16485                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
16486                let previous = self.subs[sub_index].clone();
16487                let was_dirty = self.overlay_dirty.snapshot();
16488                let previous_outbox = self.outbox.clone();
16489                let previous_failed = self.failed_commits.clone();
16490                let previous_rejections = self.rejections.clone();
16491                let previous_conflicts = self.conflicts.clone();
16492                let revoked_count = report.revoked.len();
16493                let failed_count = report.failed.len();
16494                let result = (|| {
16495                    let mut batch = ChangeAccumulator::default();
16496                    let registered = self.window_unit_by_sub_id(id);
16497                    // §3.3: stop pulling, purge exactly the last effective grant.
16498                    let (table, effective) = {
16499                        let sub = &self.subs[sub_index];
16500                        (sub.table.clone(), sub.effective.clone().unwrap_or_default())
16501                    };
16502                    let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
16503                        SectionError::Abort(
16504                            "sync.unknown_table".to_owned(),
16505                            "revoked scope references an unknown table".to_owned(),
16506                        )
16507                    })?;
16508                    let dropped = self
16509                        .drop_failed_commits(|name, values| {
16510                            name == table
16511                                && !effective.is_empty()
16512                                && effective.iter().all(|(variable, allowed)| {
16513                                    scoped_table
16514                                        .scope_column(variable)
16515                                        .and_then(|column| values.get(column))
16516                                        .and_then(Value::as_str)
16517                                        .is_some_and(|value| {
16518                                            allowed.iter().any(|allowed| allowed == value)
16519                                        })
16520                                })
16521                        })
16522                        .map_err(|message| {
16523                            SectionError::Abort("storage.failed".to_owned(), message)
16524                        })?;
16525                    for commit in dropped {
16526                        batch.conflicts = true;
16527                        batch.rejections = true;
16528                        batch.outcomes = true;
16529                        for operation in commit.ops {
16530                            batch.table(&operation.table);
16531                        }
16532                    }
16533                    let purged = self.purge_scope_rows(&table, &effective);
16534                    match purged {
16535                        Ok(()) => {
16536                            self.record_scope_map(&mut batch, &table, &effective);
16537                            let sub = &mut self.subs[sub_index];
16538                            sub.state = SubState::Revoked;
16539                            sub.reason_code = Some(if reason_code.is_empty() {
16540                                "sync.scope_revoked".to_owned()
16541                            } else {
16542                                reason_code.to_owned()
16543                            });
16544                            report.revoked.push(id.to_owned());
16545                            let doomed_effective = effective;
16546                            let sub_table = table;
16547                            self.persist_sub(&self.subs[sub_index].clone())
16548                                .map_err(|message| {
16549                                    SectionError::Abort("storage.failed".into(), message)
16550                                })?;
16551                            let dropped = self
16552                                .drop_doomed_outbox(&sub_table, &doomed_effective)
16553                                .map_err(|message| {
16554                                    SectionError::Abort("storage.failed".to_owned(), message)
16555                                })?;
16556                            if dropped {
16557                                batch.status = true;
16558                                batch.rejections = true;
16559                                batch.outcomes = true;
16560                            }
16561                            // §5.9.7 B2: revocation deletes now-unauthorized blob
16562                            // bodies (evicted ≠ revoked).
16563                            self.delete_unreferenced_cached_blobs().map_err(|message| {
16564                                SectionError::Abort("storage.failed".into(), message)
16565                            })?;
16566                        }
16567                        Err(()) => {
16568                            // §3.3 fail closed: no local mapping — never clear by
16569                            // approximation; fatal configuration error.
16570                            let sub = &mut self.subs[sub_index];
16571                            sub.state = SubState::Failed;
16572                            sub.reason_code = Some("sync.scope_revoked".to_owned());
16573                            report.failed.push(id.to_owned());
16574                            self.persist_sub(&self.subs[sub_index].clone())
16575                                .map_err(|message| {
16576                                    SectionError::Abort("storage.failed".into(), message)
16577                                })?;
16578                        }
16579                    }
16580                    if let Some((base_key, unit)) = registered {
16581                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
16582                    }
16583                    self.rebuild_overlay_if_dirty()
16584                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
16585                    self.finish_observation("syncular_revocation", batch)
16586                        .map_err(|message| {
16587                            SectionError::Abort("storage.failed".to_owned(), message)
16588                        })?;
16589                    Ok(())
16590                })();
16591                if result.is_err() {
16592                    self.rollback_observation("syncular_revocation");
16593                    self.subs[sub_index] = previous;
16594                    self.overlay_dirty.restore(was_dirty.clone());
16595                    self.outbox = previous_outbox;
16596                    self.failed_commits = previous_failed;
16597                    self.rejections = previous_rejections;
16598                    self.conflicts = previous_conflicts;
16599                    report.revoked.truncate(revoked_count);
16600                    report.failed.truncate(failed_count);
16601                }
16602                result
16603            }
16604            SubStatus::Reset => {
16605                self.begin_observation("syncular_reset")
16606                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
16607                let previous = self.subs[sub_index].clone();
16608                let was_dirty = self.overlay_dirty.snapshot();
16609                let reset_count = report.resets.len();
16610                let result = (|| {
16611                    let mut batch = ChangeAccumulator::default();
16612                    let registered = self.window_unit_by_sub_id(id);
16613                    // §4.6: discard cursor + bootstrap state, keep local rows —
16614                    // reset is a staleness signal, not a purge signal.
16615                    let sub = &mut self.subs[sub_index];
16616                    sub.cursor = -1;
16617                    sub.bootstrap_state = None;
16618                    sub.reason_code = Some(if reason_code.is_empty() {
16619                        "sync.cursor_expired".to_owned()
16620                    } else {
16621                        reason_code.to_owned()
16622                    });
16623                    report.resets.push(id.to_owned());
16624                    self.persist_sub(&self.subs[sub_index].clone())
16625                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
16626                    if let Some((base_key, unit)) = registered {
16627                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
16628                    }
16629                    self.finish_observation("syncular_reset", batch)
16630                        .map_err(|message| {
16631                            SectionError::Abort("storage.failed".to_owned(), message)
16632                        })?;
16633                    Ok(())
16634                })();
16635                if result.is_err() {
16636                    self.rollback_observation("syncular_reset");
16637                    self.subs[sub_index] = previous;
16638                    self.overlay_dirty.restore(was_dirty.clone());
16639                    report.resets.truncate(reset_count);
16640                }
16641                result
16642            }
16643            SubStatus::Active => {
16644                let fresh = meta
16645                    .fresh
16646                    .iter()
16647                    .find(|(fid, _)| fid == id)
16648                    .map(|(_, fresh)| *fresh)
16649                    .unwrap_or(false);
16650                let previous = self.subs[sub_index].clone();
16651                self.subs[sub_index].effective = Some(effective_scopes);
16652                let outcome = self
16653                    .apply_section_body(transport, sub_index, body, fresh, meta, report)
16654                    .and_then(|()| {
16655                        let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
16656                            SectionError::Abort(
16657                                "sync.invalid_request".into(),
16658                                "subscription section without SUB_END".into(),
16659                            )
16660                        })?;
16661                        self.apply_sub_end(sub_index, cursor, bootstrap_state)
16662                            .map_err(|message| {
16663                                SectionError::Abort("storage.failed".into(), message)
16664                            })
16665                    });
16666                match outcome {
16667                    Ok(()) => {
16668                        if self.subs[sub_index].bootstrap_state.is_some() {
16669                            report.bootstrapping.push(id.to_owned());
16670                        }
16671                        Ok(())
16672                    }
16673                    Err(SectionError::FailClosed) => {
16674                        self.subs[sub_index] = previous.clone();
16675                        self.begin_observation("syncular_section_failure")
16676                            .map_err(|message| {
16677                                SectionError::Abort("storage.failed".into(), message)
16678                            })?;
16679                        let persisted = (|| {
16680                            let mut batch = ChangeAccumulator::default();
16681                            if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
16682                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
16683                            }
16684                            self.subs[sub_index].state = SubState::Failed;
16685                            self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
16686                            self.persist_sub(&self.subs[sub_index])?;
16687                            self.finish_observation("syncular_section_failure", batch)
16688                        })();
16689                        if let Err(message) = persisted {
16690                            self.rollback_observation("syncular_section_failure");
16691                            self.subs[sub_index] = previous;
16692                            return Err(SectionError::Abort("storage.failed".into(), message));
16693                        }
16694                        report.failed.push(id.to_owned());
16695                        Ok(())
16696                    }
16697                    Err(error) => {
16698                        // Frame/block transactions already completed. Preserve their
16699                        // rows and revisions, but leave this subscription cursor unchanged.
16700                        self.subs[sub_index] = previous;
16701                        Err(error)
16702                    }
16703                }
16704            }
16705        }
16706    }
16707
16708    fn apply_sub_end(
16709        &mut self,
16710        sub_index: usize,
16711        cursor: i64,
16712        bootstrap_state: Option<String>,
16713    ) -> Result<(), String> {
16714        #[cfg(feature = "bench-internals")]
16715        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
16716        let previous = self.subs[sub_index].clone();
16717        self.begin_observation("syncular_sub_end")?;
16718        let persisted = (|| {
16719            let mut batch = ChangeAccumulator::default();
16720            let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
16721                && cursor >= 0
16722                && bootstrap_state.is_none();
16723            if completed {
16724                let table = self
16725                    .schema
16726                    .table(&previous.table)
16727                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
16728                let effective = previous.effective.as_deref().unwrap_or(&[]);
16729                let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
16730                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16731                let rows = statement
16732                    .query_map(rusqlite::params![previous.table, cursor], |row| {
16733                        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
16734                    })
16735                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16736                let mut covered = HashSet::new();
16737                for row in rows {
16738                    let (id, intent) =
16739                        row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16740                    let values: Map<String, Value> = serde_json::from_str(&intent)
16741                        .map_err(|_| "sync.local_corrupt".to_owned())?;
16742                    if !effective.is_empty()
16743                        && effective.iter().all(|(variable, allowed)| {
16744                            table
16745                                .scope_column(variable)
16746                                .and_then(|column| values.get(column))
16747                                .is_some_and(|value| {
16748                                    allowed.contains(
16749                                        &value
16750                                            .as_str()
16751                                            .map_or_else(|| value.to_string(), str::to_owned),
16752                                    )
16753                                })
16754                        })
16755                    {
16756                        covered.insert(id);
16757                    }
16758                }
16759                drop(statement);
16760                for id in covered {
16761                    self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
16762                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16763                    self.overlay_dirty.table(&previous.table);
16764                    batch.table(&previous.table);
16765                }
16766                self.rebuild_overlay_if_dirty()?;
16767                if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
16768                    batch.window(&base_key, &previous.table, &unit);
16769                }
16770            }
16771            let sub = &mut self.subs[sub_index];
16772            sub.cursor = cursor;
16773            sub.bootstrap_state = bootstrap_state;
16774            // §4.6: a completed (or continuing) active end clears the reset /
16775            // revocation reason, matching the TS core's active-state persist.
16776            sub.reason_code = None;
16777            sub.synced_once = true;
16778            self.persist_sub(&self.subs[sub_index])?;
16779            self.finish_observation("syncular_sub_end", batch)
16780        })();
16781        if persisted.is_err() {
16782            self.rollback_observation("syncular_sub_end");
16783            self.subs[sub_index] = previous;
16784        }
16785        persisted
16786    }
16787
16788    fn apply_section_body(
16789        &mut self,
16790        transport: &mut dyn Transport,
16791        sub_index: usize,
16792        body: Vec<Frame>,
16793        fresh: bool,
16794        meta: &RequestMeta,
16795        report: &mut SyncReport,
16796    ) -> Result<(), SectionError> {
16797        let mut saw_segment = false;
16798        for frame in body {
16799            match frame {
16800                Frame::Commit {
16801                    tables,
16802                    changes,
16803                    commit_seq,
16804                    ..
16805                } => {
16806                    self.apply_commit_frame(&tables, &changes, Some(commit_seq))
16807                        .map_err(|(c, m)| SectionError::Abort(c, m))?;
16808                    report.commits_applied += 1;
16809                }
16810                Frame::SegmentInline { payload } => {
16811                    let segment = decode_rows_segment(&payload)
16812                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
16813                    self.progress.update(|p| {
16814                        p.phase = ProgressPhase::Import;
16815                        p.subscription_id = Some(self.subs[sub_index].id.clone());
16816                        p.table = Some(self.subs[sub_index].table.clone());
16817                        p.segment_id = None;
16818                        p.bytes_received = payload.len() as u64;
16819                        p.bytes_total = Some(payload.len() as u64);
16820                        p.rows_processed = 0;
16821                        p.rows_total =
16822                            Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
16823                    });
16824                    let first = !saw_segment;
16825                    saw_segment = true;
16826                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
16827                    report.segment_rows_applied += applied;
16828                }
16829                Frame::SegmentRef {
16830                    segment_id,
16831                    byte_length,
16832                    media_type,
16833                    table,
16834                    row_count,
16835                    as_of_commit_seq,
16836                    scope_digest,
16837                    row_cursor,
16838                    next_row_cursor,
16839                    url,
16840                    url_expires_at_ms,
16841                    ..
16842                } => {
16843                    // §4.2: reject a descriptor whose mediaType was not
16844                    // advertised — never skip or guess.
16845                    let advertised = match media_type {
16846                        MediaType::Rows => {
16847                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
16848                                || meta.accept & ACCEPT_INLINE_ROWS != 0
16849                        }
16850                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
16851                    };
16852                    if !advertised {
16853                        return Err(SectionError::Abort(
16854                            "sync.invalid_request".to_owned(),
16855                            format!(
16856                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
16857                                media_type.name()
16858                            ),
16859                        ));
16860                    }
16861                    self.progress.update(|p| {
16862                        p.phase = ProgressPhase::Download;
16863                        p.subscription_id = Some(self.subs[sub_index].id.clone());
16864                        p.table = Some(table.clone());
16865                        p.segment_id = Some(segment_id.clone());
16866                        p.bytes_received = 0;
16867                        p.bytes_total = u64::try_from(byte_length).ok();
16868                        p.rows_processed = 0;
16869                        p.rows_total = u64::try_from(row_count).ok();
16870                    });
16871                    let observer = self.progress.clone();
16872                    let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
16873                    let bytes = if let Some(url) = url {
16874                        // §5.4: a url-carrying descriptor MUST be fetched
16875                        // from that URL; failure invalidates the whole
16876                        // descriptor (no fall-through to §5.5 — re-pull
16877                        // recovers, §1.4 rule 5).
16878                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
16879                            return Err(SectionError::Abort(
16880                                "sync.invalid_request".to_owned(),
16881                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
16882                                    .to_owned(),
16883                            ));
16884                        }
16885                        // §5.4: MUST NOT start a fetch at/past expiry.
16886                        if url_expires_at_ms.is_some_and(|exp| {
16887                            exp <= transport
16888                                .segment_fetch_started_at(&url)
16889                                .unwrap_or_else(|| self.clock_now_ms())
16890                        }) {
16891                            return Err(SectionError::Abort(
16892                                "sync.segment_expired".to_owned(),
16893                                format!(
16894                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
16895                                ),
16896                            ));
16897                        }
16898                        transport
16899                            .fetch_url(&url, &mut on_progress)
16900                            .map_err(|e| SectionError::Abort(e.code, e.message))?
16901                    } else {
16902                        let requested_scopes_json =
16903                            canonical_scope_json(&self.subs[sub_index].requested);
16904                        transport
16905                            .download_segment(
16906                                &SegmentRequest {
16907                                    segment_id: segment_id.clone(),
16908                                    table,
16909                                    requested_scopes_json,
16910                                },
16911                                &mut on_progress,
16912                            )
16913                            .map_err(|e| SectionError::Abort(e.code, e.message))?
16914                    };
16915                    // §5.1: verify the content address before applying.
16916                    on_progress(bytes.len() as u64);
16917                    let digest = Sha256::digest(&bytes);
16918                    let expected = segment_id
16919                        .strip_prefix("sha256:")
16920                        .unwrap_or(segment_id.as_str());
16921                    if bytes_to_hex(&digest) != expected {
16922                        // §5.1: the client discards the segment and re-pulls.
16923                        self.schedule_background_retry();
16924                        return Err(SectionError::Abort(
16925                            "sync.invalid_request".to_owned(),
16926                            "segment bytes do not match the content address (§5.1)".to_owned(),
16927                        ));
16928                    }
16929                    self.progress.update(|p| p.phase = ProgressPhase::Import);
16930                    if media_type == MediaType::Sqlite {
16931                        // §5.3: images are whole-table — a paged sqlite
16932                        // descriptor is invalid.
16933                        if row_cursor.is_some() || next_row_cursor.is_some() {
16934                            return Err(SectionError::Abort(
16935                                "sync.invalid_request".to_owned(),
16936                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
16937                                    .to_owned(),
16938                            ));
16939                        }
16940                        let first = !saw_segment;
16941                        saw_segment = true;
16942                        let applied = self.apply_sqlite_segment(
16943                            sub_index,
16944                            &bytes,
16945                            fresh && first,
16946                            row_count,
16947                            as_of_commit_seq,
16948                            &scope_digest,
16949                        )?;
16950                        report.segment_rows_applied += applied;
16951                    } else {
16952                        let segment = decode_rows_segment(&bytes).map_err(|e| {
16953                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
16954                        })?;
16955                        let first = row_cursor.is_none();
16956                        saw_segment = true;
16957                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
16958                        report.segment_rows_applied += applied;
16959                    }
16960                }
16961                Frame::Unknown { .. } => {}
16962                Frame::Error { code, message, .. } => {
16963                    return Err(SectionError::Abort(code, message))
16964                }
16965                _ => {
16966                    return Err(SectionError::Abort(
16967                        "sync.invalid_request".to_owned(),
16968                        "unexpected frame inside a subscription section".to_owned(),
16969                    ));
16970                }
16971            }
16972        }
16973        Ok(())
16974    }
16975
16976    // Both pull and realtime frames use the same durable observation boundary.
16977    fn apply_commit_frame(
16978        &mut self,
16979        tables: &[String],
16980        changes: &[ssp2::model::Change],
16981        commit_seq: Option<i64>,
16982    ) -> Result<(), (String, String)> {
16983        #[cfg(feature = "bench-internals")]
16984        let _phase = self.benchmark_phases.start(Phase::CommitApply);
16985        let was_dirty = self.overlay_dirty.snapshot();
16986        self.begin_observation("syncular_commit")
16987            .map_err(|message| ("storage.failed".into(), message))?;
16988        let applied = (|| {
16989            let mut batch = ChangeAccumulator::default();
16990            self.record_commit_changes(&mut batch, tables, changes);
16991            if let Some(sequence) = commit_seq {
16992                for change in changes {
16993                    let table = tables.get(change.table_index as usize).ok_or_else(|| {
16994                        (
16995                            "sync.invalid_request".to_owned(),
16996                            "change tableIndex out of range".to_owned(),
16997                        )
16998                    })?;
16999                    if self
17000                        .outbox
17001                        .iter()
17002                        .flat_map(|commit| &commit.ops)
17003                        .any(|op| op.table == *table && op.row_id == change.row_id)
17004                    {
17005                        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])
17006                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
17007                    }
17008                    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])
17009                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
17010                }
17011            }
17012            self.apply_commit_changes(tables, changes)?;
17013            self.rebuild_overlay_if_dirty()
17014                .map_err(|message| ("storage.failed".into(), message))?;
17015            self.finish_observation("syncular_commit", batch)
17016                .map_err(|message| ("storage.failed".into(), message))
17017        })();
17018        if applied.is_err() {
17019            self.rollback_observation("syncular_commit");
17020            self.overlay_dirty.restore(was_dirty.clone());
17021        }
17022        applied
17023    }
17024
17025    fn apply_commit_changes(
17026        &mut self,
17027        tables: &[String],
17028        changes: &[ssp2::model::Change],
17029    ) -> Result<(), (String, String)> {
17030        // A clean overlay can reconcile changed primary keys independently
17031        // when their tables have no secondary unique constraints. Such a
17032        // constraint can change whether another row's pending write succeeds,
17033        // so those frames still require the complete FIFO replay below.
17034        let mirror_visible = !self.overlay_dirty.get()
17035            && self.failed_commits.is_empty()
17036            && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
17037                || changes.iter().all(|change| {
17038                    tables
17039                        .get(change.table_index as usize)
17040                        .and_then(|name| self.schema.table(name))
17041                        .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
17042                }));
17043        for change in changes {
17044            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
17045                (
17046                    "sync.invalid_request".to_owned(),
17047                    "change tableIndex out of range".to_owned(),
17048                )
17049            })?;
17050            let table = self.schema.table(table_name).ok_or_else(|| {
17051                (
17052                    "sync.schema_mismatch".to_owned(),
17053                    format!("change targets unknown table {table_name:?}"),
17054                )
17055            })?;
17056            match change.op {
17057                Op::Upsert => {
17058                    let payload = change.row.as_ref().ok_or_else(|| {
17059                        (
17060                            "sync.invalid_request".to_owned(),
17061                            "upsert change without row payload".to_owned(),
17062                        )
17063                    })?;
17064                    // §5.11: decrypt encrypted columns on apply.
17065                    let row = {
17066                        #[cfg(feature = "bench-internals")]
17067                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
17068                        decode_row_bytes(table, payload, &self.encryption)
17069                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?
17070                    };
17071                    let version = change.row_version.unwrap_or(0);
17072                    let table_name = table.name.clone();
17073                    self.write_base_row(&table_name, &row, version)
17074                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
17075                    if mirror_visible {
17076                        self.write_row(&visible_table(&table_name), &table_name, &row, version)
17077                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
17078                    }
17079                }
17080                Op::Delete => {
17081                    self.delete_base_row(table_name, &change.row_id)
17082                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
17083                    if mirror_visible {
17084                        let sql = format!(
17085                            "DELETE FROM {} WHERE {}",
17086                            visible_table(table_name),
17087                            row_id_predicate(table)
17088                        );
17089                        self.conn
17090                            .prepare_cached(&sql)
17091                            .and_then(|mut statement| {
17092                                statement.execute(rusqlite::params![change.row_id])
17093                            })
17094                            .map_err(|error| {
17095                                ("sync.invalid_request".to_owned(), error.to_string())
17096                            })?;
17097                    }
17098                }
17099            }
17100        }
17101        if mirror_visible {
17102            {
17103                let changed_rows: HashSet<(&str, &str)> = changes
17104                    .iter()
17105                    .map(|change| {
17106                        (
17107                            tables[change.table_index as usize].as_str(),
17108                            change.row_id.as_str(),
17109                        )
17110                    })
17111                    .collect();
17112                let acknowledged = self
17113                    .acknowledged_operations(Some(&changed_rows), None)
17114                    .map_err(|error| ("storage.failed".to_owned(), error))?;
17115                self.apply_outbox_ops(&acknowledged)
17116                    .map_err(|message| ("storage.failed".into(), message))?;
17117                self.apply_outbox_ops(
17118                    self.outbox
17119                        .iter()
17120                        .flat_map(|commit| &commit.ops)
17121                        .filter(|op| {
17122                            changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
17123                        }),
17124                )
17125                .map_err(|message| ("storage.failed".into(), message))?;
17126            }
17127            self.overlay_dirty.set(false);
17128        }
17129        Ok(())
17130    }
17131
17132    /// §5.6 segment application: validate against the generated schema,
17133    /// clear the grant on a fresh bootstrap's first page (fail closed
17134    /// without a mapping), then replace-or-upsert each row with its
17135    /// segment-carried server version (§5.2).
17136    fn apply_segment(
17137        &mut self,
17138        sub_index: usize,
17139        segment: &RowsSegment,
17140        first_fresh_page: bool,
17141    ) -> Result<u32, SectionError> {
17142        let (sub_table, effective) = {
17143            let sub = &self.subs[sub_index];
17144            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
17145        };
17146        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
17147            SectionError::Abort(
17148                "sync.schema_mismatch".to_owned(),
17149                format!("subscription table {sub_table:?} is not in the client schema"),
17150            )
17151        })?;
17152        // §5.2: the column table validates against the generated schema —
17153        // order, names, types, nullability; mismatch is fatal. §5.11: the
17154        // server sends the WIRE types (bytes for an encrypted column), so
17155        // validate against wire_columns.
17156        let matches = segment.table == table.name
17157            && segment.schema_version == self.schema.version
17158            && segment.columns.len() == table.wire_columns.len()
17159            && segment
17160                .columns
17161                .iter()
17162                .zip(table.wire_columns.iter())
17163                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
17164        if !matches {
17165            return Err(SectionError::Abort(
17166                "sync.schema_mismatch".to_owned(),
17167                "segment column table does not match the generated schema (§5.2)".to_owned(),
17168            ));
17169        }
17170        let mut applied = 0u32;
17171        let empty = Vec::new();
17172        let blocks = if segment.blocks.is_empty() {
17173            std::slice::from_ref(&empty)
17174        } else {
17175            &segment.blocks
17176        };
17177        for (index, block) in blocks.iter().enumerate() {
17178            let clear = first_fresh_page && index == 0;
17179            if !clear && block.is_empty() {
17180                continue;
17181            }
17182            let was_dirty = self.overlay_dirty.snapshot();
17183            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
17184                && self
17185                    .outbox
17186                    .iter()
17187                    .flat_map(|commit| &commit.ops)
17188                    .any(|op| op.table == table.name);
17189            let mirror_visible = !reconcile_pending
17190                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
17191            self.begin_observation("syncular_segment_block")
17192                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17193            let outcome = (|| {
17194                self.rebuild_overlay_if_dirty()
17195                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17196                let mut changed_keys = Vec::new();
17197                let mut batch = ChangeAccumulator::default();
17198                if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
17199                    batch.table(&table.name);
17200                }
17201                if clear {
17202                    self.purge_scope_rows(&table.name, &effective)
17203                        .map_err(|()| SectionError::FailClosed)?;
17204                }
17205                for (row_index, row) in block.iter().enumerate() {
17206                    let decrypted;
17207                    let values = if table.has_encrypted_columns() {
17208                        let mut values = row.values.clone();
17209                        crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
17210                            .map_err(|message| {
17211                            SectionError::Abort("client.decrypt_failed".into(), message)
17212                        })?;
17213                        decrypted = values;
17214                        &decrypted
17215                    } else {
17216                        &row.values
17217                    };
17218                    self.write_base_row(&table.name, values, row.server_version)
17219                        .map_err(|message| {
17220                            SectionError::Abort("sync.invalid_request".into(), message)
17221                        })?;
17222                    if reconcile_pending {
17223                        changed_keys.push(
17224                            owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
17225                                |message| SectionError::Abort("storage.failed".into(), message),
17226                            )?,
17227                        );
17228                    }
17229                    if mirror_visible {
17230                        self.write_row(
17231                            &visible_table(&table.name),
17232                            &table.name,
17233                            values,
17234                            row.server_version,
17235                        )
17236                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17237                    }
17238                    let processed = u64::from(applied) + row_index as u64 + 1;
17239                    if processed.is_multiple_of(1024) {
17240                        self.progress.update(|p| p.rows_processed = processed);
17241                    }
17242                }
17243                if reconcile_pending {
17244                    self.reconcile_imported_rows(&table, &mut changed_keys)
17245                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17246                }
17247                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
17248                    self.apply_outbox_ops(
17249                        self.outbox
17250                            .iter()
17251                            .flat_map(|commit| &commit.ops)
17252                            .filter(|op| op.table == table.name),
17253                    )
17254                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17255                    self.overlay_dirty.set(false);
17256                } else {
17257                    self.rebuild_overlay_if_dirty()
17258                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17259                }
17260                self.finish_observation("syncular_segment_block", batch)
17261                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
17262            })();
17263            if let Err(error) = outcome {
17264                self.rollback_observation("syncular_segment_block");
17265                self.overlay_dirty.restore(was_dirty.clone());
17266                return Err(error);
17267            }
17268            applied += block.len() as u32;
17269            self.progress
17270                .update(|p| p.rows_processed = u64::from(applied));
17271            std::thread::yield_now();
17272        }
17273        self.progress
17274            .update(|p| p.rows_processed = u64::from(applied));
17275        Ok(applied)
17276    }
17277
17278    /// §5.3 sqlite-image application: validate the in-file metadata
17279    /// against the descriptor, validate column names/order against the
17280    /// generated schema, run the §5.6 first-page clear when fresh, then
17281    /// replace-or-upsert every image row with its `_syncular_version`.
17282    /// Mechanics: the image lands in a temp file read through a second
17283    /// rusqlite connection, equivalent to ATTACH + INSERT…SELECT.
17284    /// Each image chunk commits its writes and revision before readers continue.
17285    fn apply_sqlite_segment(
17286        &mut self,
17287        sub_index: usize,
17288        bytes: &[u8],
17289        first_fresh_page: bool,
17290        row_count: i64,
17291        as_of_commit_seq: i64,
17292        scope_digest: &str,
17293    ) -> Result<u32, SectionError> {
17294        let invalid = |detail: &str| {
17295            SectionError::Abort(
17296                "sync.invalid_request".to_owned(),
17297                format!("sqlite segment rejected: {detail} (§5.3)"),
17298            )
17299        };
17300        let (sub_table, effective) = {
17301            let sub = &self.subs[sub_index];
17302            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
17303        };
17304        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
17305            SectionError::Abort(
17306                "sync.schema_mismatch".to_owned(),
17307                format!("subscription table {sub_table:?} is not in the client schema"),
17308            )
17309        })?;
17310
17311        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
17312        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
17313        let img = match rusqlite::Connection::open_with_flags(
17314            &path,
17315            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
17316        ) {
17317            Ok(conn) => conn,
17318            Err(_) => {
17319                let _ = std::fs::remove_file(&path);
17320                return Err(invalid("bytes do not open as a SQLite database"));
17321            }
17322        };
17323        let outcome = self.apply_sqlite_image(
17324            &img,
17325            &table,
17326            first_fresh_page,
17327            &effective,
17328            row_count,
17329            as_of_commit_seq,
17330            scope_digest,
17331        );
17332        drop(img);
17333        let _ = std::fs::remove_file(&path);
17334        outcome
17335    }
17336
17337    #[allow(clippy::too_many_arguments)]
17338    fn apply_sqlite_image(
17339        &mut self,
17340        img: &rusqlite::Connection,
17341        table: &crate::schema::TableSchema,
17342        first_fresh_page: bool,
17343        effective: &[(String, Vec<String>)],
17344        row_count: i64,
17345        as_of_commit_seq: i64,
17346        scope_digest: &str,
17347    ) -> Result<u32, SectionError> {
17348        let invalid = |detail: String| {
17349            SectionError::Abort(
17350                "sync.invalid_request".to_owned(),
17351                format!("sqlite segment rejected: {detail} (§5.3)"),
17352            )
17353        };
17354
17355        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
17356        //    one row).
17357        type MetaRow = (i64, String, i64, i64, String, i64, i64);
17358        let meta: MetaRow = img
17359            .query_row(
17360                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
17361                        \"scopeDigest\", \"rowCount\",
17362                        (SELECT count(*) FROM _syncular_segment)
17363                 FROM _syncular_segment",
17364                [],
17365                |row| {
17366                    Ok((
17367                        row.get(0)?,
17368                        row.get(1)?,
17369                        row.get(2)?,
17370                        row.get(3)?,
17371                        row.get(4)?,
17372                        row.get(5)?,
17373                        row.get(6)?,
17374                    ))
17375                },
17376            )
17377            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
17378        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
17379        if meta_count != 1 {
17380            return Err(invalid(format!(
17381                "_syncular_segment must contain exactly one row, found {meta_count}"
17382            )));
17383        }
17384        if format != 1 {
17385            return Err(invalid(format!("format {format}")));
17386        }
17387        if meta_table != table.name {
17388            return Err(invalid(format!("image table {meta_table:?}")));
17389        }
17390        if schema_version != i64::from(self.schema.version) {
17391            return Err(invalid(format!("schemaVersion {schema_version}")));
17392        }
17393        if pin != as_of_commit_seq {
17394            return Err(invalid(format!("asOfCommitSeq {pin}")));
17395        }
17396        if digest != scope_digest {
17397            return Err(invalid("scopeDigest mismatch".to_owned()));
17398        }
17399        if meta_rows != row_count {
17400            return Err(invalid(format!("rowCount {meta_rows}")));
17401        }
17402
17403        // 2. Column names and order vs the generated schema (§5.3 rule 3).
17404        let mut names: Vec<String> = Vec::new();
17405        let mut primary_keys: Vec<String> = Vec::new();
17406        {
17407            let mut stmt = img
17408                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
17409                .map_err(|_| invalid("image data table missing".to_owned()))?;
17410            let mut rows = stmt
17411                .query([])
17412                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
17413            while let Some(row) = rows
17414                .next()
17415                .map_err(|_| invalid("image data table unreadable".to_owned()))?
17416            {
17417                if row
17418                    .get::<_, i64>(5)
17419                    .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
17420                    > 0
17421                {
17422                    primary_keys.push(row.get(1).map_err(|error| {
17423                        invalid(Self::sqlite_failure(&self.storage_failure, error))
17424                    })?);
17425                }
17426                names.push(
17427                    row.get::<_, String>(1)
17428                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
17429                );
17430            }
17431        }
17432        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
17433        expected.push("_syncular_version");
17434        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
17435            return Err(SectionError::Abort(
17436                "sync.schema_mismatch".to_owned(),
17437                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
17438            ));
17439        }
17440
17441        if primary_keys != vec![table.primary_key.clone()] {
17442            return Err(invalid(
17443                "image primary key does not match generated schema".into(),
17444            ));
17445        }
17446        let actual_count: i64 = img
17447            .query_row(
17448                &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
17449                [],
17450                |row| row.get(0),
17451            )
17452            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
17453        if actual_count != row_count {
17454            return Err(invalid("image row count does not match descriptor".into()));
17455        }
17456        let insert = self.insert_row_sql(&base_table(&table.name), table);
17457        let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
17458        let column_list = names
17459            .iter()
17460            .map(|n| quote_ident(n))
17461            .collect::<Vec<_>>()
17462            .join(", ");
17463        let mut stmt = img
17464            .prepare(&format!(
17465                "SELECT {column_list} FROM {} ORDER BY {}",
17466                quote_ident(&table.name),
17467                quote_ident(&table.primary_key)
17468            ))
17469            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
17470        let mut rows = stmt
17471            .query([])
17472            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
17473        let mut applied = 0u32;
17474        loop {
17475            let clear = applied == 0 && first_fresh_page;
17476            let was_dirty = self.overlay_dirty.snapshot();
17477            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
17478                && self
17479                    .outbox
17480                    .iter()
17481                    .flat_map(|commit| &commit.ops)
17482                    .any(|op| op.table == table.name);
17483            let mirror_visible = !reconcile_pending
17484                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
17485            self.begin_observation("syncular_image_chunk")
17486                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17487            let result = (|| {
17488                self.rebuild_overlay_if_dirty()
17489                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17490                let mut changed_keys = Vec::new();
17491                let mut batch = ChangeAccumulator::default();
17492                if clear {
17493                    if self.scoped_rows_exist(&table.name, effective) {
17494                        batch.table(&table.name);
17495                    }
17496                    self.purge_scope_rows(&table.name, effective)
17497                        .map_err(|()| SectionError::FailClosed)?;
17498                }
17499                let mut processed = 0u32;
17500                {
17501                    let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
17502                        invalid(Self::sqlite_failure(&self.storage_failure, error))
17503                    })?;
17504                    let mut mirror = if mirror_visible {
17505                        Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
17506                            invalid(Self::sqlite_failure(&self.storage_failure, error))
17507                        })?)
17508                    } else {
17509                        None
17510                    };
17511                    for _ in 0..1024 {
17512                        let Some(row) = rows.next().map_err(|error| {
17513                            invalid(Self::sqlite_failure(&self.storage_failure, error))
17514                        })?
17515                        else {
17516                            break;
17517                        };
17518                        for (i, column) in table.columns.iter().enumerate() {
17519                            let cell = row.get_ref(i).map_err(|error| {
17520                                invalid(Self::sqlite_failure(&self.storage_failure, error))
17521                            })?;
17522                            let param = image_cell_param(column, cell).map_err(invalid)?;
17523                            ins.raw_bind_parameter(i + 1, &param).map_err(|error| {
17524                                invalid(Self::sqlite_failure(&self.storage_failure, error))
17525                            })?;
17526                            if let Some(mirror) = &mut mirror {
17527                                mirror.raw_bind_parameter(i + 1, &param).map_err(|error| {
17528                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
17529                                })?;
17530                            }
17531                        }
17532                        let version_index = table.columns.len();
17533                        let version: i64 = row.get(version_index).map_err(|error| {
17534                            invalid(Self::sqlite_failure(&self.storage_failure, error))
17535                        })?;
17536                        if version < 1 {
17537                            return Err(invalid("image row version must be positive".into()));
17538                        }
17539                        ins.raw_bind_parameter(version_index + 1, version)
17540                            .map_err(|error| {
17541                                invalid(Self::sqlite_failure(&self.storage_failure, error))
17542                            })?;
17543                        ins.raw_execute().map_err(|error| {
17544                            invalid(Self::sqlite_failure(&self.storage_failure, error))
17545                        })?;
17546                        if let Some(mirror) = &mut mirror {
17547                            mirror
17548                                .raw_bind_parameter(version_index + 1, version)
17549                                .map_err(|error| {
17550                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
17551                                })?;
17552                            mirror.raw_execute().map_err(|error| {
17553                                invalid(Self::sqlite_failure(&self.storage_failure, error))
17554                            })?;
17555                        }
17556                        if reconcile_pending {
17557                            changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
17558                                |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
17559                            )?);
17560                        }
17561                        processed += 1;
17562                    }
17563                }
17564                if processed > 0 {
17565                    batch.table(&table.name);
17566                    self.overlay_dirty.table(&table.name);
17567                }
17568                if reconcile_pending {
17569                    self.reconcile_imported_rows(table, &mut changed_keys)
17570                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17571                }
17572                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
17573                    self.apply_outbox_ops(
17574                        self.outbox
17575                            .iter()
17576                            .flat_map(|commit| &commit.ops)
17577                            .filter(|op| op.table == table.name),
17578                    )
17579                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17580                    self.overlay_dirty.set(false);
17581                } else {
17582                    self.rebuild_overlay_if_dirty()
17583                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17584                }
17585                self.finish_observation("syncular_image_chunk", batch)
17586                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17587                Ok(processed)
17588            })();
17589            let processed = match result {
17590                Ok(processed) => processed,
17591                Err(error) => {
17592                    self.rollback_observation("syncular_image_chunk");
17593                    self.overlay_dirty.restore(was_dirty.clone());
17594                    return Err(error);
17595                }
17596            };
17597            applied += processed;
17598            self.progress
17599                .update(|p| p.rows_processed = u64::from(applied));
17600            std::thread::yield_now();
17601            if i64::from(applied) == actual_count {
17602                break;
17603            }
17604        }
17605        Ok(applied)
17606    }
17607
17608    /// Restore only imported and pending row identities before FIFO replay. All pending
17609    /// identities participate: a server row can free a unique value for a different edit.
17610    /// The caller owns the chunk transaction and publishes its revision after replay.
17611    fn reconcile_imported_rows(
17612        &self,
17613        table: &TableSchema,
17614        keys: &mut Vec<SqlValue>,
17615    ) -> Result<(), String> {
17616        let visible = visible_table(&table.name);
17617        let base = base_table(&table.name);
17618        let pk = quote_ident(&table.primary_key);
17619        let predicate = row_id_predicate(table);
17620        let mut lookup = self.conn.prepare_cached(&format!(
17621            "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
17622        )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17623        let mut deleted = HashSet::new();
17624        for op in self
17625            .outbox
17626            .iter()
17627            .flat_map(|commit| &commit.ops)
17628            .filter(|op| op.table == table.name)
17629        {
17630            if op.upsert {
17631                if let Some(values) = &op.values {
17632                    let key = json_to_column_value(
17633                        &table.columns[table.pk_index],
17634                        values.get(&table.primary_key),
17635                    )?;
17636                    keys.push(owned_sql_value(RowParam::Cell(&key))?);
17637                }
17638            } else if deleted.insert(&op.row_id) {
17639                keys.extend(
17640                    lookup
17641                        .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
17642                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
17643                        .collect::<Result<Vec<_>, _>>()
17644                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
17645                );
17646            }
17647        }
17648        // Delete the complete affected set before restoring any base value, so
17649        // old optimistic unique values cannot collide with the new server rows.
17650        for chunk in keys.chunks(400) {
17651            let holes = vec!["?"; chunk.len()].join(",");
17652            self.conn
17653                .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
17654                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
17655                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17656        }
17657        let updates = table
17658            .columns
17659            .iter()
17660            .map(|column| column.name.as_str())
17661            .chain(std::iter::once("_syncular_version"))
17662            .filter(|name| *name != table.primary_key)
17663            .map(|name| {
17664                let name = quote_ident(name);
17665                format!("{name}=excluded.{name}")
17666            })
17667            .collect::<Vec<_>>()
17668            .join(",");
17669        for chunk in keys.chunks(400) {
17670            let holes = vec!["?"; chunk.len()].join(",");
17671            self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
17672                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
17673                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17674        }
17675        Ok(())
17676    }
17677
17678    // -- application-authorized local purge ---------------------------------------
17679
17680    fn compile_local_data_purge(
17681        &self,
17682        input: &LocalDataPurgeInput,
17683    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
17684        let invalid = |message: String| format!("sync.invalid_request: {message}");
17685        if input.purge_id.is_empty()
17686            || input.purge_id.len() > 128
17687            || !is_local_operation_code_like(&input.purge_id)
17688        {
17689            return Err(invalid(
17690                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
17691            ));
17692        }
17693        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
17694            return Err(invalid(format!(
17695                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
17696            )));
17697        }
17698
17699        let mut deduplicated: BTreeMap<
17700            String,
17701            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
17702        > = BTreeMap::new();
17703        for target in &input.targets {
17704            let table = self.schema.table(&target.table).ok_or_else(|| {
17705                invalid(format!(
17706                    "local purge names unknown table {:?}",
17707                    target.table
17708                ))
17709            })?;
17710            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
17711                return Err(invalid(format!(
17712                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
17713                    target.table
17714                )));
17715            }
17716            let mut selectors = Vec::with_capacity(target.selectors.len());
17717            let mut canonical_selectors = BTreeMap::new();
17718            for (column_name, raw_values) in &target.selectors {
17719                let Some((column_index, column)) = table
17720                    .columns
17721                    .iter()
17722                    .enumerate()
17723                    .find(|(_, column)| column.name == *column_name)
17724                else {
17725                    return Err(invalid(format!(
17726                        "local purge target {:?} names unknown column {:?}",
17727                        target.table, column_name
17728                    )));
17729                };
17730                let encrypted = table
17731                    .encrypted_columns
17732                    .iter()
17733                    .any(|candidate| candidate.index == column_index);
17734                if column.ty != ColumnType::String || encrypted {
17735                    return Err(invalid(format!(
17736                        "local purge selector {:?}.{:?} must be a plaintext string column",
17737                        target.table, column_name
17738                    )));
17739                }
17740                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
17741                    return Err(invalid(format!(
17742                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
17743                        target.table, column_name
17744                    )));
17745                }
17746                let mut values = raw_values.clone();
17747                values.sort();
17748                values.dedup();
17749                if values.iter().any(|value| {
17750                    value.is_empty()
17751                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
17752                        || !is_local_operation_code_like(value)
17753                }) {
17754                    return Err(invalid(format!(
17755                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
17756                    )));
17757                }
17758                selectors.push((column_name.clone(), values.clone()));
17759                canonical_selectors.insert(column_name.clone(), values);
17760            }
17761            let canonical = LocalDataPurgeTarget {
17762                table: target.table.clone(),
17763                selectors: canonical_selectors,
17764            };
17765            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
17766            deduplicated.insert(
17767                key,
17768                (
17769                    CompiledLocalDataPurgeTarget {
17770                        table: target.table.clone(),
17771                        selectors,
17772                    },
17773                    canonical,
17774                ),
17775            );
17776        }
17777        let targets = deduplicated
17778            .values()
17779            .map(|(compiled, _)| compiled.clone())
17780            .collect::<Vec<_>>();
17781        let canonical = deduplicated
17782            .values()
17783            .map(|(_, target)| target.clone())
17784            .collect::<Vec<_>>();
17785        let canonical_plan =
17786            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
17787        Ok((targets, canonical_plan))
17788    }
17789
17790    fn local_purge_base_row_ids(
17791        &self,
17792        targets: &[CompiledLocalDataPurgeTarget],
17793    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
17794        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
17795        for target in targets {
17796            let table = self
17797                .schema
17798                .table(&target.table)
17799                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
17800            let mut clauses = Vec::with_capacity(target.selectors.len());
17801            let mut params = Vec::new();
17802            for (column, values) in &target.selectors {
17803                clauses.push(format!(
17804                    "{} IN ({})",
17805                    quote_ident(column),
17806                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
17807                ));
17808                params.extend(values.iter().cloned().map(SqlValue::Text));
17809            }
17810            let sql = format!(
17811                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
17812                quote_ident(&table.primary_key),
17813                base_table(&target.table),
17814                clauses.join(" AND ")
17815            );
17816            let mut statement = self
17817                .conn
17818                .prepare(&sql)
17819                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17820            let rows = statement
17821                .query_map(rusqlite::params_from_iter(params), |row| {
17822                    let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
17823                    render_row_id_json(Some(&value))
17824                        .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
17825                })
17826                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17827            let ids = by_table.entry(target.table.clone()).or_default();
17828            for row in rows {
17829                ids.insert(
17830                    row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
17831                );
17832            }
17833        }
17834        Ok(by_table)
17835    }
17836
17837    fn local_purge_values_match(
17838        target: &CompiledLocalDataPurgeTarget,
17839        values: &Map<String, Value>,
17840    ) -> bool {
17841        target.selectors.iter().all(|(column, allowed)| {
17842            values
17843                .get(column)
17844                .and_then(Value::as_str)
17845                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
17846        })
17847    }
17848
17849    /// Apply one host-authorized local security purge. The host MUST gate the
17850    /// corresponding subscriptions first; this primitive owns local SQLite
17851    /// cleanup and never grants/revokes server authority by itself.
17852    pub fn purge_local_data(
17853        &mut self,
17854        input: &LocalDataPurgeInput,
17855    ) -> Result<LocalDataPurgeResult, String> {
17856        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
17857        let meta_key = format!("localPurge:{}", input.purge_id);
17858        let applied_plan = self
17859            .conn
17860            .query_row(
17861                "SELECT value FROM _syncular_meta WHERE key = ?1",
17862                rusqlite::params![meta_key],
17863                |row| row.get::<_, String>(0),
17864            )
17865            .optional()
17866            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17867        if let Some(applied_plan) = applied_plan {
17868            if applied_plan != canonical_plan {
17869                return Err(format!(
17870                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
17871                    input.purge_id
17872                ));
17873            }
17874            return Ok(LocalDataPurgeResult {
17875                already_applied: true,
17876                purged_rows: 0,
17877                dropped_commits: 0,
17878            });
17879        }
17880
17881        // RFC 0005 D7: the container file cannot be a purge target (its name is
17882        // not in the running schema), so purge drops it as a fixed step, before
17883        // any mirror row is touched and unconditionally.
17884        let _ = self.drop_previous_version();
17885
17886        let prior_outbox = self.outbox.clone();
17887        let prior_failed = self.failed_commits.clone();
17888        let prior_rejections = self.rejections.clone();
17889        let prior_conflicts = self.conflicts.clone();
17890        let prior_overlay_dirty = self.overlay_dirty.snapshot();
17891        self.begin_observation("syncular_local_purge")?;
17892        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
17893            let row_ids = self.local_purge_base_row_ids(&targets)?;
17894            let retained_dropped = self.drop_failed_commits(|table, values| {
17895                targets.iter().any(|target| {
17896                    target.table == table && Self::local_purge_values_match(target, values)
17897                })
17898            })?;
17899            let doomed = self
17900                .outbox
17901                .iter()
17902                .filter(|commit| {
17903                    commit.ops.iter().any(|operation| {
17904                        let matching_targets = targets
17905                            .iter()
17906                            .filter(|target| target.table == operation.table)
17907                            .collect::<Vec<_>>();
17908                        if matching_targets.is_empty() {
17909                            return false;
17910                        }
17911                        if row_ids
17912                            .get(&operation.table)
17913                            .is_some_and(|ids| ids.contains(&operation.row_id))
17914                        {
17915                            return true;
17916                        }
17917                        operation.values.as_ref().is_some_and(|values| {
17918                            matching_targets
17919                                .iter()
17920                                .any(|target| Self::local_purge_values_match(target, values))
17921                        })
17922                    })
17923                })
17924                .cloned()
17925                .collect::<Vec<_>>();
17926            let doomed_ids = doomed
17927                .iter()
17928                .map(|commit| commit.client_commit_id.clone())
17929                .collect::<BTreeSet<_>>();
17930            let mut batch = ChangeAccumulator::default();
17931            for commit in &retained_dropped {
17932                batch.conflicts = true;
17933                batch.rejections = true;
17934                batch.outcomes = true;
17935                for operation in &commit.ops {
17936                    batch.table(&operation.table);
17937                }
17938            }
17939            let mut rejections = Vec::new();
17940            for commit in &doomed {
17941                for operation in &commit.ops {
17942                    batch.table(&operation.table);
17943                }
17944                let results = commit
17945                    .ops
17946                    .iter()
17947                    .enumerate()
17948                    .map(|(op_index, operation)| {
17949                        let rejection = RejectionRecord {
17950                            client_commit_id: commit.client_commit_id.clone(),
17951                            op_index: op_index as i32,
17952                            code: "client.local_data_purged".to_owned(),
17953                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
17954                            retryable: false,
17955                            details: None,
17956                            operation: Some(CommitOperation::from(operation)),
17957                        };
17958                        rejections.push(rejection.clone());
17959                        CommitOperationOutcome::Error { rejection }
17960                    })
17961                    .collect::<Vec<_>>();
17962                self.persist_commit_outcome(
17963                    &commit.client_commit_id,
17964                    CommitOutcomeStatus::Rejected,
17965                    &results,
17966                    Some(&commit.ops),
17967                )?;
17968                self.delete_outbox_persisted(&commit.client_commit_id)?;
17969            }
17970            if !doomed.is_empty() {
17971                self.outbox
17972                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
17973                self.rejections.extend(rejections);
17974                self.prune_commit_outcomes()?;
17975                batch.status = true;
17976                batch.rejections = true;
17977                batch.outcomes = true;
17978            }
17979
17980            let mut purged_rows = 0usize;
17981            for (table, ids) in &row_ids {
17982                if ids.is_empty() {
17983                    continue;
17984                }
17985                batch.table(table);
17986                for row_id in ids {
17987                    self.delete_base_row(table, row_id)?;
17988                    purged_rows += 1;
17989                }
17990            }
17991            self.rebuild_overlay_if_dirty()?;
17992            self.delete_unreferenced_cached_blobs()?;
17993            self.conn
17994                .execute(
17995                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
17996                    rusqlite::params![meta_key, canonical_plan],
17997                )
17998                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17999            Ok((
18000                batch,
18001                LocalDataPurgeResult {
18002                    already_applied: false,
18003                    purged_rows,
18004                    dropped_commits: doomed.len(),
18005                },
18006            ))
18007        })();
18008        let (batch, result) = match applied {
18009            Ok(value) => value,
18010            Err(error) => {
18011                self.rollback_observation("syncular_local_purge");
18012                self.outbox = prior_outbox;
18013                self.failed_commits = prior_failed;
18014                self.rejections = prior_rejections;
18015                self.conflicts = prior_conflicts;
18016                self.overlay_dirty.restore(prior_overlay_dirty.clone());
18017                return Err(error);
18018            }
18019        };
18020        if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
18021            self.rollback_observation("syncular_local_purge");
18022            self.outbox = prior_outbox;
18023            self.failed_commits = prior_failed;
18024            self.rejections = prior_rejections;
18025            self.conflicts = prior_conflicts;
18026            self.overlay_dirty.restore(prior_overlay_dirty.clone());
18027            return Err(error);
18028        }
18029        self.cancel_sync_round();
18030        Ok(result)
18031    }
18032
18033    /// Application-authorized recovery of the replicated projection. Unlike a
18034    /// local security purge, this retains the entire outbox, device identity,
18035    /// lease, outcomes, subscription registrations, and protected bookkeeping.
18036    /// The projection reset, subscription rewind, optimistic replay, and
18037    /// idempotency marker share one savepoint for interruption safety.
18038    pub fn rebootstrap_local_data(
18039        &mut self,
18040        input: &LocalDataRebootstrapInput,
18041    ) -> Result<LocalDataRebootstrapResult, String> {
18042        if input.rebootstrap_id.is_empty()
18043            || input.rebootstrap_id.len() > 128
18044            || !is_local_operation_code_like(&input.rebootstrap_id)
18045        {
18046            return Err(
18047                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
18048                    .to_owned(),
18049            );
18050        }
18051        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
18052        if let Some(persisted) = self.get_meta_strict(&meta_key)? {
18053            let (retained_commits, reset_subscriptions) =
18054                decode_local_rebootstrap_receipt(&persisted)?;
18055            return Ok(LocalDataRebootstrapResult {
18056                already_applied: true,
18057                retained_commits,
18058                reset_subscriptions,
18059            });
18060        }
18061        if self.stopped || self.schema_floor.is_some() {
18062            return Err(
18063                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
18064                    .to_owned(),
18065            );
18066        }
18067
18068        let retained_commits = self.outbox.len();
18069        let reset_subscriptions = self.subs.len();
18070        let prior_subs = self.subs.clone();
18071        let prior_upgrading = self.upgrading;
18072        let prior_stopped = self.stopped;
18073        let prior_schema_floor = self.schema_floor.clone();
18074        let prior_overlay_dirty = self.overlay_dirty.snapshot();
18075        let prior_sync_needed = self.sync_needed;
18076        let prior_sync_intents = self.sync_intent_queue.clone();
18077        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
18078
18079        let prior_active_round = self.active_round;
18080        self.begin_observation("syncular_local_rebootstrap")?;
18081        let mut batch = ChangeAccumulator::default();
18082        let applied = (|| -> Result<(), String> {
18083            self.run_schema_reset_observed(&mut batch, false)?;
18084            self.conn
18085                .execute(
18086                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
18087                    rusqlite::params![meta_key, receipt],
18088                )
18089                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18090            self.sync_needed = true;
18091            self.sync_intent_queue.push_back(SyncIntent::Interactive);
18092            batch.status = true;
18093            Ok(())
18094        })();
18095        if let Err(error) = applied {
18096            self.rollback_observation("syncular_local_rebootstrap");
18097            self.active_round = prior_active_round;
18098            self.subs = prior_subs;
18099            self.upgrading = prior_upgrading;
18100            self.stopped = prior_stopped;
18101            self.schema_floor = prior_schema_floor;
18102            self.overlay_dirty.restore(prior_overlay_dirty.clone());
18103            self.sync_needed = prior_sync_needed;
18104            self.sync_intent_queue = prior_sync_intents;
18105            return Err(error);
18106        }
18107        if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
18108            self.rollback_observation("syncular_local_rebootstrap");
18109            self.active_round = prior_active_round;
18110            self.subs = prior_subs;
18111            self.upgrading = prior_upgrading;
18112            self.stopped = prior_stopped;
18113            self.schema_floor = prior_schema_floor;
18114            self.overlay_dirty.restore(prior_overlay_dirty.clone());
18115            self.sync_needed = prior_sync_needed;
18116            self.sync_intent_queue = prior_sync_intents;
18117            return Err(error);
18118        }
18119
18120        Ok(LocalDataRebootstrapResult {
18121            already_applied: false,
18122            retained_commits,
18123            reset_subscriptions,
18124        })
18125    }
18126
18127    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
18128
18129    /// Delete base rows matching the effective scopes; `Err(())` = no local
18130    /// scope-column mapping for a key (the fail-closed case).
18131    fn purge_scope_rows(
18132        &mut self,
18133        table_name: &str,
18134        effective: &[(String, Vec<String>)],
18135    ) -> Result<(), ()> {
18136        if effective.is_empty() {
18137            return Ok(());
18138        }
18139        let table = self.schema.table(table_name).ok_or(())?.clone();
18140        let mut clauses = Vec::new();
18141        let mut params: Vec<SqlValue> = Vec::new();
18142        for (variable, values) in effective {
18143            let column = table.scope_column(variable).ok_or(())?;
18144            let placeholders: Vec<String> = values
18145                .iter()
18146                .map(|v| {
18147                    params.push(SqlValue::Text(v.clone()));
18148                    "?".to_owned()
18149                })
18150                .collect();
18151            clauses.push(format!(
18152                "{} IN ({})",
18153                quote_ident(column),
18154                placeholders.join(", ")
18155            ));
18156        }
18157        let sql = format!(
18158            "DELETE FROM {} WHERE {}",
18159            base_table(table_name),
18160            clauses.join(" AND ")
18161        );
18162        self.overlay_dirty.table(table_name);
18163        self.conn
18164            .execute(&sql, rusqlite::params_from_iter(&params))
18165            .map_err(|_| ())?;
18166        self.conn
18167            .execute(
18168                &format!(
18169                    "DELETE FROM {} WHERE {}",
18170                    visible_table(table_name),
18171                    clauses.join(" AND ")
18172                ),
18173                rusqlite::params_from_iter(&params),
18174            )
18175            .map_err(|_| ())?;
18176        Ok(())
18177    }
18178
18179    /// §3.3: drop pending commits whose upserts provably land in the
18180    /// revoked effective scopes — whole-commit, never per-operation.
18181    fn drop_doomed_outbox(
18182        &mut self,
18183        table_name: &str,
18184        effective: &[(String, Vec<String>)],
18185    ) -> Result<bool, String> {
18186        if effective.is_empty() {
18187            return Ok(false);
18188        }
18189        let Some(table) = self.schema.table(table_name).cloned() else {
18190            return Ok(false);
18191        };
18192        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
18193        for (variable, values) in effective {
18194            match table.scope_column(variable) {
18195                Some(column) => mappings.push((column, values)),
18196                None => return Ok(false), // not provable without a mapping
18197            }
18198        }
18199        let doomed: Vec<OutboxCommit> = self
18200            .outbox
18201            .iter()
18202            .filter(|commit| {
18203                commit.ops.iter().any(|op| {
18204                    op.upsert
18205                        && op.table == table_name
18206                        && op.values.as_ref().is_some_and(|values| {
18207                            mappings
18208                                .iter()
18209                                .all(|(column, allowed)| match values.get(*column) {
18210                                    Some(Value::String(s)) => allowed.contains(s),
18211                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
18212                                    Some(Value::Bool(b)) => allowed.contains(&b.to_string()),
18213                                    _ => false,
18214                                })
18215                        })
18216                })
18217            })
18218            .cloned()
18219            .collect();
18220        if doomed.is_empty() {
18221            return Ok(false);
18222        }
18223        let mut rejections = Vec::new();
18224        for commit in &doomed {
18225            let results = commit
18226                .ops
18227                .iter()
18228                .enumerate()
18229                .map(|(op_index, operation)| {
18230                    let rejection = RejectionRecord {
18231                        client_commit_id: commit.client_commit_id.clone(),
18232                        op_index: op_index as i32,
18233                        code: "sync.scope_revoked".to_owned(),
18234                        message: "the commit was dropped because its effective scope was revoked"
18235                            .to_owned(),
18236                        retryable: false,
18237                        details: None,
18238                        operation: Some(CommitOperation::from(operation)),
18239                    };
18240                    rejections.push(rejection.clone());
18241                    CommitOperationOutcome::Error { rejection }
18242                })
18243                .collect::<Vec<_>>();
18244            self.persist_commit_outcome(
18245                &commit.client_commit_id,
18246                CommitOutcomeStatus::Rejected,
18247                &results,
18248                Some(&commit.ops),
18249            )?;
18250            self.delete_outbox_persisted(&commit.client_commit_id)?;
18251        }
18252        self.prune_commit_outcomes()?;
18253        let doomed_ids = doomed
18254            .iter()
18255            .map(|commit| commit.client_commit_id.as_str())
18256            .collect::<BTreeSet<_>>();
18257        self.outbox
18258            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
18259        self.rejections.extend(rejections);
18260        Ok(true)
18261    }
18262
18263    // -- blobs (§5.9) ----------------------------------------------------------------
18264
18265    /// §5.9.7: hash bytes into the content address, cache them, queue the
18266    /// upload (flushed before the next push, B4). Returns the canonical
18267    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
18268    /// column value.
18269    pub fn upload_blob(
18270        &mut self,
18271        bytes: &[u8],
18272        media_type: Option<String>,
18273        name: Option<String>,
18274    ) -> Result<Value, String> {
18275        let blob_id = blob_id_for(bytes);
18276        let now = self.clock_now_ms();
18277        let tx = self
18278            .conn
18279            .transaction()
18280            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18281        tx.execute(
18282            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
18283             ON CONFLICT(blob_id) DO NOTHING",
18284            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
18285        )
18286        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18287        tx.execute(
18288            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
18289            rusqlite::params![blob_id, media_type, now],
18290        )
18291        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18292        tx.commit()
18293            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18294        self.enforce_blob_cache_cap();
18295        let mut obj = Map::new();
18296        obj.insert("blobId".to_owned(), Value::from(blob_id));
18297        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
18298        if let Some(mt) = media_type {
18299            obj.insert("mediaType".to_owned(), Value::from(mt));
18300        }
18301        if let Some(n) = name {
18302            obj.insert("name".to_owned(), Value::from(n));
18303        }
18304        Ok(Value::Object(obj))
18305    }
18306
18307    /// §5.9.7: resolve an owned blob body without driver encoding. A
18308    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
18309    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
18310    pub fn fetch_blob_bytes(
18311        &mut self,
18312        transport: &mut dyn Transport,
18313        blob_id_or_ref: &str,
18314    ) -> Result<FetchedBlob, (String, String)> {
18315        let simple = |m: String| ("client.failed".to_owned(), m);
18316        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
18317            blob_id_or_ref.to_owned()
18318        } else {
18319            let value: Value = serde_json::from_str(blob_id_or_ref)
18320                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
18321            value
18322                .get("blobId")
18323                .and_then(Value::as_str)
18324                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
18325                .to_owned()
18326        };
18327        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
18328            return Ok(cached);
18329        }
18330        if !self.transport_enabled {
18331            return Err(("sync.offline".into(), "transport gate is closed".into()));
18332        }
18333        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
18334        // blob.not_found) verbatim so the harness can assert on it. The
18335        // authorized endpoint serves bytes inline OR (always-issue, presign
18336        // configured) a signed url the client fetches directly — no host auth,
18337        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
18338        #[cfg(feature = "bench-internals")]
18339        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
18340        let bytes = match transport
18341            .blob_download(&blob_id)
18342            .map_err(|e| (e.code, e.message))?
18343        {
18344            BlobDownload::Bytes(bytes) => bytes,
18345            BlobDownload::Url {
18346                url,
18347                url_expires_at_ms,
18348            } => {
18349                // §5.9.5: MUST NOT start a fetch at/past expiry.
18350                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
18351                    return Err((
18352                        "sync.segment_expired".to_owned(),
18353                        format!(
18354                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
18355                        ),
18356                    ));
18357                }
18358                transport
18359                    .fetch_blob_url(&url)
18360                    .map_err(|e| (e.code, e.message))?
18361            }
18362        };
18363        #[cfg(feature = "bench-internals")]
18364        drop(download_phase);
18365        #[cfg(feature = "bench-internals")]
18366        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
18367        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
18368        if blob_id_for(&bytes) != blob_id {
18369            return Err(simple(format!(
18370                "blob content address mismatch for {blob_id}"
18371            )));
18372        }
18373        #[cfg(feature = "bench-internals")]
18374        drop(validate_phase);
18375        #[cfg(feature = "bench-internals")]
18376        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
18377        let now = self.clock_now_ms();
18378        self.conn
18379            .execute(
18380                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
18381                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
18382            )
18383            .map_err(|e| simple(e.to_string()))?;
18384        self.conn
18385            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
18386            .map_err(|e| simple(e.to_string()))?;
18387        self.enforce_blob_cache_cap();
18388        #[cfg(feature = "bench-internals")]
18389        drop(insert_phase);
18390        self.get_cached_blob_bytes(&blob_id)
18391            .map_err(simple)?
18392            .ok_or_else(|| simple("blob cache write failed".to_owned()))
18393    }
18394
18395    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
18396        #[cfg(feature = "bench-internals")]
18397        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
18398        let mut stmt = self
18399            .conn
18400            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
18401            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18402        let mut rows = stmt
18403            .query(rusqlite::params![blob_id])
18404            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18405        if let Some(row) = rows
18406            .next()
18407            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
18408        {
18409            let bytes: Vec<u8> = row
18410                .get(0)
18411                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18412            let byte_length: i64 = row
18413                .get(1)
18414                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18415            let media_type: Option<String> = row
18416                .get(2)
18417                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18418            return Ok(Some(FetchedBlob {
18419                blob_id: blob_id.to_owned(),
18420                byte_length,
18421                bytes,
18422                media_type,
18423            }));
18424        }
18425        Ok(None)
18426    }
18427
18428    /// §5.9.7 B4: upload every queued blob before push.
18429    fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
18430        let pending: Vec<(String, Option<String>)> = {
18431            let mut stmt = self
18432                .conn
18433                .prepare(
18434                    "SELECT blob_id, media_type FROM (
18435                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
18436                      UNION ALL
18437                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
18438                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
18439                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
18440                    ) ORDER BY created_at_ms, blob_id",
18441                )
18442                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18443            let rows = stmt
18444                .query_map([], |row| {
18445                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
18446                })
18447                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18448            rows.collect::<Result<_, _>>()
18449                .map_err(|error| match error {
18450                    rusqlite::Error::InvalidColumnType(..)
18451                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
18452                        "sync.local_corrupt",
18453                        "Pending blob upload metadata is invalid",
18454                    ),
18455                    _ => TransportError::new("client.failed", error.to_string()),
18456                })?
18457        };
18458        let mut uploads = Vec::new();
18459        for (blob_id, media_type) in pending {
18460            let bytes = (|| {
18461                let stored: Option<(SqlValue, SqlValue)> = self
18462                    .conn
18463                    .query_row(
18464                        "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
18465                        rusqlite::params![blob_id],
18466                        |row| Ok((row.get(0)?, row.get(1)?)),
18467                    )
18468                    .optional()
18469                    .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18470                let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
18471                    return Err(TransportError::new(
18472                        "sync.local_corrupt",
18473                        "Pending blob upload body is missing or corrupt",
18474                    ));
18475                };
18476                if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
18477                    return Err(TransportError::new(
18478                        "sync.local_corrupt",
18479                        "Pending blob upload body is missing or corrupt",
18480                    ));
18481                }
18482                Ok(bytes)
18483            })();
18484            let failed = bytes.is_err();
18485            uploads.push(crate::round::PendingUpload {
18486                id: blob_id,
18487                bytes,
18488                media_type,
18489            });
18490            if failed {
18491                break;
18492            }
18493        }
18494        Ok(uploads)
18495    }
18496
18497    #[cfg(test)]
18498    fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
18499        for upload in self.pending_blob_uploads()? {
18500            self.upload_one(
18501                transport,
18502                &upload.id,
18503                &upload.bytes?,
18504                upload.media_type.as_deref(),
18505            )?;
18506            self.conn
18507                .execute(
18508                    "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
18509                    rusqlite::params![upload.id],
18510                )
18511                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18512        }
18513        Ok(())
18514    }
18515
18516    /// §5.9.3: upload one blob, preferring the presigned direct-to-storage
18517    /// grant when the transport supports it, else streaming through the direct
18518    /// host-authenticated endpoint (capability, not fallback). A `Url` grant
18519    /// PUTs direct with no host auth; on a grant PUT failure the client streams
18520    /// through the direct endpoint — a *different, host-authenticated
18521    /// capability*, not a fall-through of the grant's authority.
18522    #[cfg(test)]
18523    fn upload_one(
18524        &self,
18525        transport: &mut dyn Transport,
18526        blob_id: &str,
18527        bytes: &[u8],
18528        media_type: Option<&str>,
18529    ) -> Result<(), TransportError> {
18530        crate::round::upload_one(transport, blob_id, bytes, media_type, self.now_ms)
18531    }
18532
18533    fn visible_blob_ids_sql(&self) -> String {
18534        let mut selects = Vec::new();
18535        for table in &self.schema.tables {
18536            for column in table
18537                .columns
18538                .iter()
18539                .filter(|column| column.ty == ColumnType::BlobRef)
18540            {
18541                let identifier = quote_ident(&column.name);
18542                selects.push(format!(
18543                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
18544                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
18545                     AND json_type({identifier}, '$.blobId') = 'text'",
18546                    quote_ident(&table.name),
18547                ));
18548            }
18549        }
18550        if selects.is_empty() {
18551            "SELECT NULL AS blob_id WHERE 0".to_owned()
18552        } else {
18553            selects.join(" UNION ")
18554        }
18555    }
18556
18557    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
18558    /// staged uploads pin bodies directly; no derived refcount is stored.
18559    fn enforce_blob_cache_cap(&self) {
18560        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
18561            return;
18562        };
18563        let mut total: i64 = self
18564            .conn
18565            .query_row(
18566                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
18567                [],
18568                |row| row.get(0),
18569            )
18570            .unwrap_or(0);
18571        if total <= max_bytes {
18572            return;
18573        }
18574        let query = format!(
18575            "SELECT blob_id, byte_length FROM _syncular_blobs
18576             WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
18577               AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
18578               AND blob_id NOT IN ({})
18579             ORDER BY created_at_ms ASC, blob_id ASC",
18580            self.visible_blob_ids_sql(),
18581        );
18582        let candidates: Vec<(String, i64)> = {
18583            let Ok(mut stmt) = self.conn.prepare(&query) else {
18584                return;
18585            };
18586            let Ok(rows) = stmt.query_map([], |row| {
18587                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
18588            }) else {
18589                return;
18590            };
18591            rows.filter_map(Result::ok).collect()
18592        };
18593        for (blob_id, byte_length) in candidates {
18594            if total <= max_bytes {
18595                break;
18596            }
18597            let _ = self.conn.execute(
18598                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
18599                rusqlite::params![blob_id],
18600            );
18601            total -= byte_length;
18602        }
18603    }
18604
18605    fn delete_unreferenced_cached_blobs(&mut self) -> Result<(), String> {
18606        #[cfg(feature = "bench-internals")]
18607        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
18608        if !self.schema_has_blobs() {
18609            return Ok(());
18610        }
18611        self.rebuild_overlay_if_dirty()?;
18612        self.conn
18613            .execute(
18614                &format!(
18615                    "DELETE FROM _syncular_blobs
18616                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
18617                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
18618                   AND blob_id NOT IN ({})",
18619                    self.visible_blob_ids_sql(),
18620                ),
18621                [],
18622            )
18623            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18624        Ok(())
18625    }
18626
18627    // -- local row storage ----------------------------------------------------------
18628
18629    /// The cached per-table primary-key upsert SQL for `full_table` (see
18630    /// the `insert_sql` field: built once, reused per row).
18631    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
18632        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
18633            return sql.clone();
18634        }
18635        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
18636        columns.push(quote_ident("_syncular_version"));
18637        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
18638        let primary_key = quote_ident(&table.primary_key);
18639        let updates = columns
18640            .iter()
18641            .filter(|column| **column != primary_key)
18642            .map(|column| format!("{column}=excluded.{column}"))
18643            .collect::<Vec<_>>()
18644            .join(", ");
18645        let sql = format!(
18646            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
18647            columns.join(", "),
18648            placeholders.join(", ")
18649        );
18650        self.insert_sql
18651            .borrow_mut()
18652            .insert(full_table.to_owned(), sql.clone());
18653        sql
18654    }
18655
18656    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
18657        self.overlay_dirty.table(table_name);
18658        self.write_row(&base_table(table_name), table_name, row, version)
18659    }
18660
18661    fn write_row(
18662        &self,
18663        full_table: &str,
18664        table_name: &str,
18665        row: &Row,
18666        version: i64,
18667    ) -> Result<(), String> {
18668        #[cfg(feature = "bench-internals")]
18669        let _phase = self.benchmark_phases.start(Phase::RowWrite);
18670        let table = self
18671            .schema
18672            .table(table_name)
18673            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
18674        let sql = self.insert_row_sql(full_table, table);
18675        let mut stmt = self
18676            .conn
18677            .prepare_cached(&sql)
18678            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18679        let params = row
18680            .iter()
18681            .map(RowParam::Cell)
18682            .chain(std::iter::once(RowParam::Version(version)));
18683        stmt.execute(rusqlite::params_from_iter(params))
18684            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18685        Ok(())
18686    }
18687
18688    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
18689        let table = self
18690            .schema
18691            .table(table_name)
18692            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
18693        self.overlay_dirty.table(table_name);
18694        let sql = format!(
18695            "DELETE FROM {} WHERE {}",
18696            base_table(table_name),
18697            row_id_predicate(table)
18698        );
18699        let mut stmt = self
18700            .conn
18701            .prepare_cached(&sql)
18702            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18703        stmt.execute(rusqlite::params![row_id])
18704            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18705        Ok(())
18706    }
18707
18708    fn has_acknowledged_rows(&self) -> bool {
18709        self.conn
18710            .query_row(
18711                "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
18712                [],
18713                |row| row.get(0),
18714            )
18715            .expect("read acknowledged intent")
18716    }
18717
18718    fn acknowledged_operations(
18719        &self,
18720        rows: Option<&HashSet<(&str, &str)>>,
18721        tables: Option<&BTreeSet<&str>>,
18722    ) -> Result<Vec<OutboxOp>, String> {
18723        let keys = rows.map(|rows| {
18724            serde_json::to_string(
18725                &rows
18726                    .iter()
18727                    .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
18728                    .collect::<Vec<_>>(),
18729            )
18730            .expect("row keys")
18731        });
18732        let mut statement = self
18733            .conn
18734            .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")
18735            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18736        let rows = statement
18737            .query_map(
18738                rusqlite::params![
18739                    keys,
18740                    tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
18741                ],
18742                |row| {
18743                    #[cfg(test)]
18744                    self.acknowledged_replay_count
18745                        .set(self.acknowledged_replay_count.get() + 1);
18746                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
18747                },
18748            )
18749            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18750        rows.map(|row| {
18751            let (op, intent) =
18752                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18753            let op: CommitOperation =
18754                serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
18755            let table = self
18756                .schema
18757                .table(&op.table)
18758                .ok_or_else(|| "sync.unknown_table".to_owned())?;
18759            let values = if op.op == "upsert" && self.visible_row(table, &op.row_id)?.is_none() {
18760                Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
18761            } else {
18762                op.values
18763            };
18764            Ok(OutboxOp {
18765                table: op.table,
18766                row_id: op.row_id,
18767                upsert: op.op == "upsert",
18768                base_version: op.base_version,
18769                values,
18770            })
18771        })
18772        .collect()
18773    }
18774
18775    fn drop_acknowledged_rows(
18776        &self,
18777        matches: impl Fn(&str, &Map<String, Value>) -> bool,
18778    ) -> Result<Vec<OutboxCommit>, String> {
18779        let mut statement = self
18780            .conn
18781            .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
18782            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18783        let rows = statement
18784            .query_map([], |row| {
18785                Ok((
18786                    row.get::<_, String>(0)?,
18787                    row.get::<_, String>(1)?,
18788                    row.get::<_, String>(2)?,
18789                    row.get::<_, String>(3)?,
18790                ))
18791            })
18792            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18793        let mut doomed = HashSet::new();
18794        for row in rows {
18795            let (id, table, intent, row_id) =
18796                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18797            let values =
18798                serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
18799            let base_matches = self
18800                .schema
18801                .table(&table)
18802                .map(|schema| self.stored_row(schema, &row_id, true))
18803                .transpose()?
18804                .flatten()
18805                .is_some_and(|(base, _)| {
18806                    let schema = self.schema.table(&table).expect("acknowledged table");
18807                    let values = schema
18808                        .columns
18809                        .iter()
18810                        .zip(base.iter())
18811                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
18812                        .collect();
18813                    matches(&table, &values)
18814                });
18815            if matches(&table, &values) || base_matches {
18816                doomed.insert(id);
18817            }
18818        }
18819        let mut dropped = Vec::new();
18820        for id in doomed {
18821            let mut statement = self
18822                .conn
18823                .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
18824                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18825            let operations = statement
18826                .query_map([&id], |row| row.get::<_, String>(0))
18827                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18828            let mut ops = Vec::new();
18829            for operation in operations {
18830                let json = operation
18831                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18832                let op: CommitOperation =
18833                    serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
18834                self.overlay_dirty.table(&op.table);
18835                ops.push(OutboxOp {
18836                    table: op.table,
18837                    row_id: op.row_id,
18838                    upsert: op.op == "upsert",
18839                    base_version: op.base_version,
18840                    values: op.values,
18841                });
18842            }
18843            dropped.push(OutboxCommit {
18844                client_commit_id: id.clone(),
18845                ops,
18846            });
18847            self.conn
18848                .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
18849                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18850        }
18851        Ok(dropped)
18852    }
18853
18854    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
18855    /// the outbox changed since the last rebuild (the no-op sync round).
18856    fn rebuild_overlay_if_dirty(&mut self) -> Result<(), String> {
18857        if self.overlay_dirty.get() {
18858            self.rebuild_overlay()?;
18859        }
18860        Ok(())
18861    }
18862
18863    /// §7.1: local reads see outbox state applied optimistically — rebuild
18864    /// every visible table as (base server state) + (pending outbox replay
18865    /// on top). Optimistic rows carry version `-1`.
18866    fn rebuild_overlay(&mut self) -> Result<(), String> {
18867        #[cfg(feature = "bench-internals")]
18868        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
18869        #[cfg(test)]
18870        self.overlay_rebuild_count
18871            .set(self.overlay_rebuild_count.get() + 1);
18872        let dirty = self.overlay_dirty.snapshot();
18873        let tables: Vec<_> = self
18874            .schema
18875            .tables
18876            .iter()
18877            .filter(|table| {
18878                dirty
18879                    .as_ref()
18880                    .is_none_or(|names| names.is_empty() || names.contains(&table.name))
18881            })
18882            .cloned()
18883            .collect();
18884        let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
18885        self.begin_observation("syncular_overlay")?;
18886        let rebuilt = (|| {
18887            for table in &tables {
18888                for index in &table.fts_indexes {
18889                    self.drop_fts_triggers(index)?;
18890                }
18891                let visible = visible_table(&table.name);
18892                let base = base_table(&table.name);
18893                self.conn
18894                    .execute_batch(&format!(
18895                        "DELETE FROM {visible}; INSERT INTO {visible} SELECT * FROM {base}"
18896                    ))
18897                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18898            }
18899            let acknowledged = self.acknowledged_operations(None, Some(&names))?;
18900            self.apply_outbox_ops(
18901                acknowledged
18902                    .iter()
18903                    .filter(|op| names.contains(op.table.as_str())),
18904            )?;
18905            for (commit, initial) in &self.failed_commits {
18906                for operation in &commit.ops {
18907                    if !names.contains(operation.table.as_str()) || !operation.upsert {
18908                        continue;
18909                    }
18910                    if let Some(table) = self.schema.table(&operation.table) {
18911                        if self.visible_row(table, &operation.row_id)?.is_none()
18912                            && operation.values.as_ref().is_some_and(|values| {
18913                                table
18914                                    .columns
18915                                    .iter()
18916                                    .all(|column| values.contains_key(&column.name))
18917                            })
18918                        {
18919                            let key = serde_json::to_string(&(
18920                                operation.table.clone(),
18921                                operation.row_id.clone(),
18922                            ))
18923                            .expect("row key serialization");
18924                            if let Some(Value::Object(values)) = initial.get(&key) {
18925                                self.apply_outbox_ops(&[OutboxOp {
18926                                    values: Some(values.clone()),
18927                                    ..operation.clone()
18928                                }])?;
18929                            }
18930                        }
18931                    }
18932                }
18933                self.apply_outbox_ops(
18934                    commit
18935                        .ops
18936                        .iter()
18937                        .filter(|op| names.contains(op.table.as_str())),
18938                )?;
18939            }
18940            for commit in &self.outbox {
18941                self.apply_outbox_ops(
18942                    commit
18943                        .ops
18944                        .iter()
18945                        .filter(|op| names.contains(op.table.as_str())),
18946                )?;
18947            }
18948            for table in &tables {
18949                for index in &table.fts_indexes {
18950                    self.rebuild_fts_projection(table, index)?;
18951                    self.create_fts_triggers(table, index)?;
18952                }
18953            }
18954            self.conn
18955                .execute_batch("RELEASE syncular_overlay")
18956                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18957            Ok(())
18958        })();
18959        if rebuilt.is_err() {
18960            self.conn
18961                .execute_batch("ROLLBACK TO syncular_overlay; RELEASE syncular_overlay")
18962                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18963        } else {
18964            self.overlay_dirty.set(false);
18965        }
18966        rebuilt
18967    }
18968
18969    /// §7.1 replay: every operation is applied over the current visible
18970    /// state. An operation that no longer applies (a server row now holds its
18971    /// unique value) stays pending and invisible until a later replay admits
18972    /// it or the server answers its push.
18973    fn apply_outbox_ops<'a>(
18974        &self,
18975        ops: impl IntoIterator<Item = &'a OutboxOp>,
18976    ) -> Result<(), String> {
18977        #[cfg(feature = "bench-internals")]
18978        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
18979        for op in ops {
18980            #[cfg(feature = "bench-internals")]
18981            if let Some(phase) = &mut phase {
18982                phase.unit();
18983            }
18984            if op.upsert {
18985                if let (Some(table), Some(values)) = (
18986                    self.schema
18987                        .table(&op.table)
18988                        .filter(|table| table.indexes.iter().any(|index| index.unique)),
18989                    op.values.as_ref(),
18990                ) {
18991                    let mut intended: Map<String, Value> = self
18992                        .visible_row(table, &op.row_id)?
18993                        .map(|(row, _)| {
18994                            table
18995                                .columns
18996                                .iter()
18997                                .zip(&row)
18998                                .map(|(column, value)| {
18999                                    (column.name.clone(), column_value_to_json(value))
19000                                })
19001                                .collect()
19002                        })
19003                        .unwrap_or_default();
19004                    intended.extend(values.clone());
19005                    if !self.unique_conflicts(table, &intended, false)?.is_empty() {
19006                        continue;
19007                    }
19008                }
19009            }
19010            self.apply_outbox_op(op)?;
19011        }
19012        Ok(())
19013    }
19014
19015    fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
19016        let Some(table) = self.schema.table(&op.table) else {
19017            return Ok(());
19018        };
19019        let visible = visible_table(&table.name);
19020        if !op.upsert {
19021            let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
19022            self.conn
19023                .prepare_cached(&sql)
19024                .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
19025                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19026            return Ok(());
19027        }
19028        let Some(values) = op.values.as_ref() else {
19029            return Ok(());
19030        };
19031        // §7.1: the overlay applies the operation's PRESENT columns
19032        // over the current local row. A partial operation over an
19033        // absent local row leaves it absent — the server answers it
19034        // with sync.row_deleted or sync.row_missing (§6.2).
19035        let full = table
19036            .columns
19037            .iter()
19038            .all(|column| values.contains_key(&column.name));
19039        let local = self.visible_row(table, &op.row_id)?;
19040        if local.is_none() && !full {
19041            return Ok(());
19042        }
19043        let mut row: Row = Vec::with_capacity(table.columns.len());
19044        for (index, column) in table.columns.iter().enumerate() {
19045            let incoming = match values.get(&column.name) {
19046                Some(raw) => json_to_column_value(
19047                    if raw.get("$bytes").is_some()
19048                        && table
19049                            .encrypted_columns
19050                            .iter()
19051                            .any(|encrypted| encrypted.index == index)
19052                    {
19053                        &table.wire_columns[index]
19054                    } else {
19055                        column
19056                    },
19057                    Some(raw),
19058                ),
19059                None => Ok(local
19060                    .as_ref()
19061                    .and_then(|(values, _)| values.get(index).cloned())
19062                    .flatten()),
19063            };
19064            row.push(incoming.map_err(|_| {
19065                "sync.local_corrupt: persisted optimistic row value is invalid".to_owned()
19066            })?);
19067        }
19068        let version = local.as_ref().map_or(-1, |(_, version)| *version);
19069        self.write_row(&visible, &table.name, &row, version)
19070            .map_err(|error| {
19071                if error.starts_with("UNIQUE constraint failed:") {
19072                    "sync.constraint_violation: local write violates a unique constraint".to_owned()
19073                } else {
19074                    error
19075                }
19076            })
19077    }
19078
19079    /// The visible (optimistic) row's values plus version, or `None` when the
19080    /// row is absent from the overlay.
19081    fn visible_row(&self, table: &TableSchema, row_id: &str) -> Result<Option<(Row, i64)>, String> {
19082        self.stored_row(table, row_id, false)
19083    }
19084
19085    fn stored_row(
19086        &self,
19087        table: &TableSchema,
19088        row_id: &str,
19089        base: bool,
19090    ) -> Result<Option<(Row, i64)>, String> {
19091        let sql = format!(
19092            "SELECT * FROM {} WHERE {}",
19093            if base {
19094                base_table(&table.name)
19095            } else {
19096                visible_table(&table.name)
19097            },
19098            row_id_predicate(table)
19099        );
19100        let mut stmt = self
19101            .conn
19102            .prepare_cached(&sql)
19103            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19104        let mut rows = stmt
19105            .query(rusqlite::params![row_id])
19106            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19107        let Some(row) = rows
19108            .next()
19109            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
19110        else {
19111            return Ok(None);
19112        };
19113        let mut values = Vec::with_capacity(table.columns.len());
19114        for (index, column) in table.columns.iter().enumerate() {
19115            let raw = row
19116                .get_ref(index)
19117                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19118            if let rusqlite::types::ValueRef::Blob(bytes) = raw {
19119                if table
19120                    .encrypted_columns
19121                    .iter()
19122                    .any(|encrypted| encrypted.index == index)
19123                {
19124                    values.push(Some(ColumnValue::Bytes(bytes.to_vec())));
19125                    continue;
19126                }
19127            }
19128            let json = sql_ref_to_json(column, raw);
19129            values.push(json_to_column_value(column, Some(&json)).map_err(|_| {
19130                "sync.local_corrupt: persisted local row value is invalid".to_owned()
19131            })?);
19132        }
19133        let version: i64 = row
19134            .get(table.columns.len())
19135            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19136        Ok(Some((values, version)))
19137    }
19138
19139    // -- realtime (§8) ---------------------------------------------------------------
19140
19141    /// §8.8: the configured realtime policy.
19142    #[must_use]
19143    pub fn realtime_policy(&self) -> RealtimePolicy {
19144        self.realtime_policy
19145    }
19146
19147    /// §8.8: the explicit realtime availability state.
19148    #[must_use]
19149    pub fn realtime_state(&self) -> RealtimeState {
19150        self.realtime_state
19151    }
19152
19153    /// §8.8: set the realtime policy. Refused while the socket is connected:
19154    /// a policy change never silently re-routes an ownership state.
19155    pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
19156        if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
19157            return Err(
19158                "sync.invalid_request: disconnect realtime before changing the realtime policy"
19159                    .to_owned(),
19160            );
19161        }
19162        self.realtime_policy = policy;
19163        match policy {
19164            RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
19165            RealtimePolicy::Required | RealtimePolicy::Optional
19166                if self.realtime_state == RealtimeState::Disabled =>
19167            {
19168                self.set_realtime_state(RealtimeState::Disconnected, None);
19169            }
19170            RealtimePolicy::Required | RealtimePolicy::Optional => {}
19171        }
19172        Ok(())
19173    }
19174
19175    /// Record an availability transition, keeping the reason code bounded and
19176    /// code-like (§7.6) and clearing it where it cannot apply.
19177    fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
19178        self.realtime_state = state;
19179        self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
19180        if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
19181            self.realtime_reason_code = None;
19182            self.realtime_retry_delay_ms = None;
19183        }
19184    }
19185
19186    #[must_use]
19187    fn realtime_connected(&self) -> bool {
19188        self.realtime_state == RealtimeState::Connected
19189    }
19190
19191    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
19192        if !self.transport_enabled {
19193            return Err("sync.offline: transport gate is closed".into());
19194        }
19195        if self.realtime_policy == RealtimePolicy::Off {
19196            return Err(
19197                "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
19198            );
19199        }
19200        if self.realtime_connected() {
19201            return Ok(());
19202        }
19203        self.realtime_state = RealtimeState::Connecting;
19204        match transport.realtime_connect_for_client(&self.client_id) {
19205            Ok(()) => {
19206                self.set_realtime_state(RealtimeState::Connected, None);
19207                Ok(())
19208            }
19209            Err(error) => {
19210                self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
19211                Err(format!("{}: {}", error.code, error.message))
19212            }
19213        }
19214    }
19215
19216    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
19217        self.cancel_sync_round();
19218        // A deliberately disconnected or policy-disabled client has nothing
19219        // to release; a `lost` socket still does (the host owns the handle).
19220        if self.realtime_state == RealtimeState::Disconnected
19221            || self.realtime_state == RealtimeState::Disabled
19222        {
19223            return;
19224        }
19225        let _ = transport.realtime_close();
19226        self.set_realtime_state(
19227            if self.realtime_policy == RealtimePolicy::Off {
19228                RealtimeState::Disabled
19229            } else {
19230                RealtimeState::Disconnected
19231            },
19232            None,
19233        );
19234        self.presence.clear(); // §8.6.1: presence is per-connection
19235    }
19236
19237    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
19238    /// document for `scope_key`. Requires a live socket; the document is
19239    /// ephemeral (lost on disconnect). Authorization is the connection's
19240    /// registration (§8.6.3) — an unheld key is rejected loudly by the
19241    /// server with `presence.forbidden`.
19242    pub fn set_presence(
19243        &mut self,
19244        transport: &mut dyn Transport,
19245        scope_key: &str,
19246        doc: Option<&Value>,
19247    ) -> Result<(), String> {
19248        if !self.transport_enabled {
19249            return Err("sync.offline: transport gate is closed".into());
19250        }
19251        if !self.realtime_connected() {
19252            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
19253        }
19254        let text = encode_presence_publish(scope_key, doc);
19255        transport
19256            .realtime_send(&text)
19257            .map_err(|e| format!("{}: {}", e.code, e.message))
19258    }
19259
19260    /// §8.6: the peers currently present on a scope key (ephemeral).
19261    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
19262        self.presence
19263            .get(scope_key)
19264            .map(|peers| peers.values().cloned().collect())
19265            .unwrap_or_default()
19266    }
19267
19268    /// §8.6 apply an inbound presence fanout to the local map.
19269    fn apply_presence(
19270        &mut self,
19271        scope_key: String,
19272        kind: Option<PresenceKind>,
19273        actor_id: Option<String>,
19274        client_id: Option<String>,
19275        doc: Option<Value>,
19276        error: Option<String>,
19277    ) {
19278        // The publisher-directed error variant is out-of-band; nothing to
19279        // record in the peer map.
19280        if error.is_some() {
19281            return;
19282        }
19283        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
19284            return;
19285        };
19286        let peer_key = format!("{actor_id} {client_id}");
19287        match kind {
19288            PresenceKind::Leave => {
19289                if let Some(peers) = self.presence.get_mut(&scope_key) {
19290                    peers.remove(&peer_key);
19291                    if peers.is_empty() {
19292                        self.presence.remove(&scope_key);
19293                    }
19294                }
19295            }
19296            _ => {
19297                let doc = match doc {
19298                    Some(Value::Object(_)) => doc.unwrap(),
19299                    _ => return,
19300                };
19301                self.presence.entry(scope_key).or_default().insert(
19302                    peer_key,
19303                    PresencePeer {
19304                        actor_id,
19305                        client_id,
19306                        doc,
19307                    },
19308                );
19309            }
19310        }
19311    }
19312
19313    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
19314    pub fn on_realtime_text(&mut self, text: &str) {
19315        if self.stopped || !self.transport_enabled || self.security_preflight {
19316            return;
19317        }
19318        match parse_control(text) {
19319            Ok(ControlMessage::Hello { requires_sync, .. }) => {
19320                if requires_sync {
19321                    // §8.1: pull before trusting the socket for continuity.
19322                    self.set_sync_needed(true, true);
19323                }
19324            }
19325            Ok(ControlMessage::Presence {
19326                scope_key,
19327                kind,
19328                actor_id,
19329                client_id,
19330                doc,
19331                error,
19332                ..
19333            }) => {
19334                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
19335            }
19336            Ok(ControlMessage::Wake { .. }) => {
19337                // §8.3: any wake-up means "run a pull soon", never data.
19338                self.set_sync_needed(true, true);
19339            }
19340            _ => {}
19341        }
19342    }
19343
19344    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
19345    /// a pull response per section; an unapplied delta is a wake-up.
19346    /// Mailbox hosts send durable acknowledgements on their I/O executor.
19347    pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
19348        let mut transport = crate::round::DownloadResults::default();
19349        self.on_realtime_binary(&mut transport, bytes);
19350        transport.controls
19351    }
19352
19353    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
19354        if self.stopped || !self.transport_enabled || self.security_preflight {
19355            return;
19356        }
19357        #[cfg(feature = "bench-internals")]
19358        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
19359        let message = match decode_message(bytes) {
19360            Ok(m) if m.msg_kind == MsgKind::Response => m,
19361            _ => {
19362                self.set_sync_needed(true, true);
19363                return;
19364            }
19365        };
19366        #[cfg(feature = "bench-internals")]
19367        drop(decode_phase);
19368        let mut frames = message.frames.into_iter();
19369        let mut applied_cursor: Option<i64> = None;
19370        let mut any_covered = false;
19371        let mut dropped = false;
19372        while let Some(frame) = frames.next() {
19373            let Frame::SubStart {
19374                id,
19375                status,
19376                effective_scopes,
19377                ..
19378            } = frame
19379            else {
19380                continue;
19381            };
19382            let mut body = Vec::new();
19383            let mut next_cursor: Option<i64> = None;
19384            for inner in frames.by_ref() {
19385                match inner {
19386                    Frame::SubEnd {
19387                        next_cursor: nc, ..
19388                    } => {
19389                        next_cursor = Some(nc);
19390                        break;
19391                    }
19392                    Frame::Unknown { .. } => {}
19393                    other => body.push(other),
19394                }
19395            }
19396            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
19397                dropped = true;
19398                continue;
19399            };
19400            let sub = &self.subs[sub_index];
19401            // §8.2: only locally active, not mid-bootstrap subscriptions
19402            // apply; skipped sections are repaired by the next pull.
19403            if status != SubStatus::Active
19404                || sub.state != SubState::Active
19405                || sub.bootstrap_state.is_some()
19406                || !sub.synced_once
19407            {
19408                dropped = true;
19409                continue;
19410            }
19411            if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
19412                // Idempotent redelivery of an already-covered window.
19413                any_covered = true;
19414                continue;
19415            }
19416            let previous_effective = self.subs[sub_index].effective.clone();
19417            let previous_cursor = self.subs[sub_index].cursor;
19418            self.subs[sub_index].effective = Some(effective_scopes);
19419            let mut failed = false;
19420            for inner in body {
19421                match inner {
19422                    Frame::Commit {
19423                        tables,
19424                        changes,
19425                        commit_seq,
19426                        ..
19427                    } => {
19428                        if self
19429                            .apply_commit_frame(&tables, &changes, Some(commit_seq))
19430                            .is_err()
19431                        {
19432                            failed = true;
19433                            break;
19434                        }
19435                    }
19436                    _ => {
19437                        failed = true;
19438                        break;
19439                    }
19440                }
19441            }
19442            let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
19443                self.apply_sub_end(sub_index, cursor, None)
19444                    .ok()
19445                    .map(|()| cursor)
19446            });
19447            let Some(next_cursor) = committed else {
19448                self.subs[sub_index].effective = previous_effective;
19449                self.subs[sub_index].cursor = previous_cursor;
19450                dropped = true;
19451                continue;
19452            };
19453            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
19454        }
19455        if let Some(cursor) = applied_cursor {
19456            // §8.2 ack point: the highest applied SUB_END.nextCursor.
19457            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
19458            let _ = transport.realtime_send(&ack);
19459        } else if !any_covered || dropped {
19460            // §8.2: a delta not applied at all is treated as a wake-up.
19461            self.set_sync_needed(true, true);
19462        }
19463    }
19464
19465    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
19466    /// cursor across active, non-bootstrapping subscriptions that have
19467    /// synced at least once. No such subscription, no ack.
19468    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
19469        if !self.realtime_connected() {
19470            return;
19471        }
19472        let floor = self
19473            .subs
19474            .iter()
19475            .filter(|s| {
19476                s.state == SubState::Active
19477                    && s.bootstrap_state.is_none()
19478                    && s.synced_once
19479                    && s.cursor >= 0
19480            })
19481            .map(|s| s.cursor)
19482            .min();
19483        if let Some(cursor) = floor {
19484            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
19485            let _ = transport.realtime_send(&ack);
19486        }
19487    }
19488}
19489
19490#[cfg(test)]
19491mod previous_version_wiring_tests {
19492    use super::*;
19493    use serde_json::{json, Value};
19494
19495    fn previous_version_schema(version: i32) -> Value {
19496        json!({
19497            "version": version,
19498            "tables": [{
19499                "name": "tasks",
19500                "primaryKey": "id",
19501                "columns": [
19502                    { "name": "id", "type": "string", "nullable": false },
19503                    { "name": "note", "type": "string", "nullable": true }
19504                ],
19505                "scopes": []
19506            }]
19507        })
19508    }
19509
19510    fn previous_version_limits() -> crate::api::ClientLimits {
19511        crate::api::ClientLimits {
19512            previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
19513                enabled: true,
19514                ..Default::default()
19515            }),
19516            ..Default::default()
19517        }
19518    }
19519
19520    /// The configuration the live client carries, for a direct capture call.
19521    fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
19522        previous_version_limits()
19523            .previous_version_context
19524            .expect("configured")
19525    }
19526
19527    /// Capture the container the way a bump would, so a read test starts from a
19528    /// container this client's own state will then decide about.
19529    fn recapture_previous_version(client: &SyncClient) {
19530        let replica_path = client
19531            .previous_version_replica_path()
19532            .expect("file-backed replica");
19533        let outcome = crate::previous_version::capture_previous_version_from_replica(
19534            &client.conn,
19535            &replica_path,
19536            2,
19537            2,
19538            &previous_version_config(),
19539            1,
19540        )
19541        .expect("capture");
19542        assert!(matches!(
19543            outcome,
19544            crate::previous_version::CaptureOutcome::Captured(_)
19545        ));
19546    }
19547
19548    fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
19549        Subscription {
19550            id: "wiring-sub".to_owned(),
19551            table: "tasks".to_owned(),
19552            requested: Vec::new(),
19553            params: None,
19554            cursor,
19555            bootstrap_state: bootstrap_state.map(str::to_owned),
19556            state,
19557            reason_code: None,
19558            effective: None,
19559            synced_once: false,
19560        }
19561    }
19562
19563    #[test]
19564    fn schema_downgrade_refuses_all_opens_and_recreate_without_mutation() {
19565        let path =
19566            std::env::temp_dir().join(format!("syncular-downgrade-{}.db", uuid::Uuid::new_v4()));
19567        let path_str = path.to_str().expect("temp path");
19568        let schema = previous_version_schema(3);
19569        let mut older = previous_version_schema(2);
19570        older["tables"][0]["columns"] =
19571            json!([{ "name": "id", "type": "string", "nullable": false }]);
19572        let mut client = SyncClient::open_path(
19573            "downgrade".into(),
19574            &previous_version_schema(1),
19575            previous_version_limits(),
19576            path_str,
19577        )
19578        .expect("fresh");
19579        client
19580            .mutate(vec![Mutation::Upsert {
19581                table: "tasks".into(),
19582                values: Map::from_iter([
19583                    ("id".into(), json!("accepted")),
19584                    ("note".into(), json!("old")),
19585                ]),
19586                base_version: None,
19587            }])
19588            .expect("v1 write");
19589        client
19590            .recreate_with_schema(&schema)
19591            .expect("upgrade captures context");
19592        let commit = client
19593            .mutate(vec![Mutation::Upsert {
19594                table: "tasks".into(),
19595                values: Map::from_iter([
19596                    ("id".into(), json!("queued")),
19597                    ("note".into(), json!("v3-only")),
19598                ]),
19599                base_version: None,
19600            }])
19601            .expect("queued v3 write");
19602        let changes = client.conn.total_changes();
19603        let outbox = client.pending_commit_ids();
19604        assert!(outbox.contains(&commit));
19605        let descriptor = client.get_meta("localSchemaDescriptor");
19606        let container = format!(
19607            "{path_str}{}",
19608            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
19609        );
19610        let context = std::fs::read(&container).expect("captured context");
19611        assert!(client
19612            .recreate_with_schema(&older)
19613            .expect_err("refuse recreate")
19614            .starts_with("client.schema_downgrade:"));
19615        assert_eq!(client.schema.version, 3);
19616        assert_eq!(client.conn.total_changes(), changes);
19617        assert_eq!(client.pending_commit_ids(), outbox);
19618        assert_eq!(client.get_meta("localSchemaDescriptor"), descriptor);
19619        assert_eq!(
19620            client
19621                .conn
19622                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
19623                    row.get::<_, String>(0)
19624                })
19625                .expect("queued row"),
19626            "v3-only"
19627        );
19628        assert_eq!(std::fs::read(&container).expect("context remains"), context);
19629        drop(client);
19630        let before = std::fs::read(&path).expect("replica bytes");
19631        // open_path uses DELETE journaling. The identity open must refuse
19632        // before its normal WAL configuration changes this database.
19633        for entry in 0..3 {
19634            let error = match entry {
19635                0 => SyncClient::open_path(
19636                    "downgrade".into(),
19637                    &older,
19638                    previous_version_limits(),
19639                    path_str,
19640                ),
19641                1 => SyncClient::open_path_with_identity(
19642                    None,
19643                    &older,
19644                    previous_version_limits(),
19645                    path_str,
19646                ),
19647                _ => SyncClient::with_connection(
19648                    "downgrade".into(),
19649                    &older,
19650                    previous_version_limits(),
19651                    Connection::open(&path).expect("connection"),
19652                ),
19653            }
19654            .err()
19655            .expect("refuse open");
19656            assert!(error.starts_with("client.schema_downgrade:"));
19657            let conn = Connection::open(&path).expect("inspect");
19658            assert_eq!(
19659                conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
19660                    .expect("journal mode"),
19661                "delete"
19662            );
19663            drop(conn);
19664            assert_eq!(std::fs::read(&path).expect("replica unchanged"), before);
19665            assert_eq!(
19666                std::fs::read(&container).expect("context unchanged"),
19667                context
19668            );
19669        }
19670        let reopened = SyncClient::open_path(
19671            "downgrade".into(),
19672            &schema,
19673            previous_version_limits(),
19674            path_str,
19675        )
19676        .expect("compatible reopen");
19677        assert_eq!(reopened.pending_commit_ids(), outbox);
19678        assert_eq!(
19679            reopened
19680                .conn
19681                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
19682                    row.get::<_, String>(0)
19683                })
19684                .expect("queued row remains"),
19685            "v3-only"
19686        );
19687        reopened.delete_meta(LOCAL_SCHEMA_VERSION_KEY);
19688        reopened.delete_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
19689        drop(reopened);
19690        let legacy = SyncClient::open_path(
19691            "downgrade".into(),
19692            &schema,
19693            previous_version_limits(),
19694            path_str,
19695        )
19696        .expect("legacy no-marker reopen");
19697        assert_eq!(legacy.pending_commit_ids(), outbox);
19698        assert!(!legacy.upgrading());
19699        assert_eq!(
19700            legacy
19701                .conn
19702                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
19703                    row.get::<_, String>(0)
19704                })
19705                .expect("legacy row remains"),
19706            "v3-only"
19707        );
19708        drop(legacy);
19709        std::fs::remove_file(path).expect("remove replica");
19710        let _ = std::fs::remove_file(container);
19711    }
19712
19713    #[test]
19714    fn schema_marker_errors_refuse_open_and_recreate_without_mutation() {
19715        for marker in [
19716            "''",
19717            "'bad'",
19718            "'0'",
19719            "'-1'",
19720            "'2.5'",
19721            "'03'",
19722            "'3\n'",
19723            "' 3'",
19724            "'+3'",
19725            "'3e0'",
19726            "'2147483648'",
19727            "NULL",
19728            "X'33'",
19729        ] {
19730            let conn = Connection::open_in_memory().expect("connection");
19731            conn.execute_batch(&format!("CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value); INSERT INTO _syncular_meta VALUES ('localSchemaVersion', {marker});")).expect("invalid marker fixture");
19732            let error = SyncClient::with_connection(
19733                "marker".into(),
19734                &previous_version_schema(3),
19735                ClientLimits::default(),
19736                conn,
19737            )
19738            .err()
19739            .expect("refuse marker");
19740            assert!(
19741                error.starts_with("sync.local_corrupt:"),
19742                "{marker}: {error}"
19743            );
19744        }
19745        for fault in [
19746            "UPDATE _syncular_meta SET value = 'bad' WHERE key = 'localSchemaVersion'",
19747            "ALTER TABLE _syncular_meta RENAME COLUMN value TO unreadable_value",
19748        ] {
19749            let mut client = SyncClient::new(
19750                "marker".into(),
19751                &previous_version_schema(3),
19752                ClientLimits::default(),
19753            )
19754            .expect("fresh");
19755            let commit = client
19756                .mutate(vec![Mutation::Upsert {
19757                    table: "tasks".into(),
19758                    values: Map::from_iter([("id".into(), json!("queued"))]),
19759                    base_version: None,
19760                }])
19761                .expect("queued write");
19762            client.conn.execute_batch(fault).expect("marker fault");
19763            let changes = client.conn.total_changes();
19764            let error = client
19765                .recreate_with_schema(&previous_version_schema(4))
19766                .expect_err("refuse corrupt marker");
19767            assert!(error.starts_with("sync.local_corrupt:"));
19768            assert_eq!(client.conn.total_changes(), changes);
19769            assert_eq!(client.schema.version, 3);
19770            assert_eq!(client.pending_commit_ids(), vec![commit]);
19771        }
19772        for setup in [
19773            "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY)",
19774            "CREATE VIEW _syncular_meta AS SELECT 'localSchemaVersion' AS key, '3' AS value",
19775        ] {
19776            let path =
19777                std::env::temp_dir().join(format!("syncular-marker-{}.db", uuid::Uuid::new_v4()));
19778            let conn = Connection::open(&path).expect("connection");
19779            conn.execute_batch(setup)
19780                .expect("unreadable metadata fixture");
19781            drop(conn);
19782            let before = std::fs::read(&path).expect("before");
19783            let error = SyncClient::open_path_with_identity(
19784                None,
19785                &previous_version_schema(3),
19786                ClientLimits::default(),
19787                path.to_str().expect("path"),
19788            )
19789            .err()
19790            .expect("refuse unreadable marker");
19791            assert!(error.starts_with("sync.local_corrupt:"));
19792            assert_eq!(std::fs::read(&path).expect("after"), before);
19793            std::fs::remove_file(path).expect("remove fixture");
19794        }
19795    }
19796
19797    std::thread_local! {
19798        /// The pending concurrent upgrade a busy handler commits, so a lock
19799        /// collision deterministically becomes the marker race under test.
19800        static CONCURRENT_UPGRADE: RefCell<Option<Connection>> =
19801            const { RefCell::new(None) };
19802    }
19803
19804    fn commit_concurrent_upgrade(attempts: i32) -> bool {
19805        CONCURRENT_UPGRADE.with(|slot| {
19806            if let Some(banner) = slot.borrow_mut().take() {
19807                banner
19808                    .execute_batch(
19809                        "UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion'; COMMIT",
19810                    )
19811                    .expect("commit the concurrent upgrade");
19812            }
19813        });
19814        attempts < 10
19815    }
19816
19817    fn journal_mode(conn: &Connection) -> String {
19818        conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
19819            .expect("journal mode")
19820    }
19821
19822    fn temp_replica(label: &str) -> std::path::PathBuf {
19823        std::env::temp_dir().join(format!("syncular-{label}-{}.db", uuid::Uuid::new_v4()))
19824    }
19825
19826    #[test]
19827    fn file_identity_constructor_requires_the_returned_wal_mode() {
19828        for path in [":memory:", ""] {
19829            let error = SyncClient::open_path_with_identity(
19830                None,
19831                &previous_version_schema(1),
19832                ClientLimits::default(),
19833                path,
19834            )
19835            .err()
19836            .expect("temporary SQLite databases cannot enter WAL mode");
19837            assert_eq!(
19838                error,
19839                "sync.invalid_request: file-backed client requires WAL journal mode"
19840            );
19841        }
19842        let path = temp_replica("confirmed-wal");
19843        let client = SyncClient::open_path_with_identity(
19844            None,
19845            &previous_version_schema(1),
19846            ClientLimits::default(),
19847            path.to_str().unwrap(),
19848        )
19849        .unwrap();
19850        assert_eq!(journal_mode(&client.conn), "wal");
19851        drop(client);
19852        std::fs::remove_file(path).unwrap();
19853    }
19854
19855    #[test]
19856    fn non_positive_generated_schema_versions_are_refused_before_storage() {
19857        for version in [json!(0), json!(-1), json!(i32::MIN), json!(2147483648i64)] {
19858            let mut schema = previous_version_schema(1);
19859            schema["version"] = version.clone();
19860            let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
19861                .err()
19862                .expect("refuse an out-of-range generated version");
19863            assert_eq!(
19864                error,
19865                "sync.invalid_request: generated schema version must be an integer in 1..=2147483647"
19866            );
19867            // The file-backed seam refuses before the replica gains a table.
19868            let path = temp_replica("bad-version");
19869            let path_str = path.to_str().expect("path");
19870            for identity in [false, true] {
19871                let error = if identity {
19872                    SyncClient::open_path_with_identity(
19873                        None,
19874                        &schema,
19875                        ClientLimits::default(),
19876                        path_str,
19877                    )
19878                } else {
19879                    SyncClient::open_path(
19880                        "bad-version".into(),
19881                        &schema,
19882                        ClientLimits::default(),
19883                        path_str,
19884                    )
19885                }
19886                .err()
19887                .expect("refuse before storage");
19888                assert!(
19889                    error.starts_with("sync.invalid_request:"),
19890                    "{version}: {error}"
19891                );
19892                assert!(
19893                    !path.exists(),
19894                    "{version}: a refused constructor creates no replica file"
19895                );
19896            }
19897        }
19898        // A missing version is the same request error, not a parse failure.
19899        let mut schema = previous_version_schema(1);
19900        schema
19901            .as_object_mut()
19902            .expect("schema object")
19903            .remove("version");
19904        let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
19905            .err()
19906            .expect("refuse a missing version");
19907        assert!(error.starts_with("sync.invalid_request:"), "{error}");
19908    }
19909
19910    #[test]
19911    fn a_duplicated_schema_marker_is_refused_without_a_reset() {
19912        let path = temp_replica("duplicate-marker");
19913        let path_str = path.to_str().expect("path");
19914        {
19915            // A hand-built/legacy metadata table without its primary key, with
19916            // the older marker first: `query_row` would pick it and reset.
19917            let conn = Connection::open(path_str).expect("fixture");
19918            conn.execute_batch(
19919                "CREATE TABLE _syncular_meta(key TEXT, value);
19920                 INSERT INTO _syncular_meta VALUES
19921                   ('localSchemaVersion', '1'),
19922                   ('localSchemaVersion', '2'),
19923                   ('clientId', 'duplicate');",
19924            )
19925            .expect("duplicate marker fixture");
19926        }
19927        let error = SyncClient::open_path_with_identity(
19928            None,
19929            &previous_version_schema(3),
19930            ClientLimits::default(),
19931            path_str,
19932        )
19933        .err()
19934        .expect("refuse a duplicated marker");
19935        assert!(error.starts_with("sync.local_corrupt:"), "{error}");
19936        let probe = Connection::open(path_str).expect("probe");
19937        assert_eq!(
19938            probe
19939                .query_row(
19940                    "SELECT COUNT(*) FROM _syncular_meta WHERE key = 'localSchemaVersion'",
19941                    [],
19942                    |row| row.get::<_, i64>(0),
19943                )
19944                .expect("marker rows"),
19945            2,
19946            "the duplicated rows are left for the operator, not collapsed"
19947        );
19948        assert_eq!(
19949            probe
19950                .query_row(
19951                    "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'",
19952                    [],
19953                    |row| row.get::<_, i64>(0),
19954                )
19955                .expect("synced tables"),
19956            0,
19957            "a duplicated marker never drives the reset that recreates v3 tables"
19958        );
19959        assert_eq!(
19960            journal_mode(&probe),
19961            "delete",
19962            "the refusal leaves the journal mode alone"
19963        );
19964        drop(probe);
19965        std::fs::remove_file(path).expect("remove replica");
19966    }
19967
19968    #[test]
19969    fn a_refused_open_does_not_persist_wal_or_mutate_the_replica() {
19970        let path = temp_replica("refused-wal");
19971        let path_str = path.to_str().expect("path");
19972        // `open_path` leaves the default journal mode, so a refused open is
19973        // the only thing that could switch this replica to WAL.
19974        drop(
19975            SyncClient::open_path(
19976                "owner".into(),
19977                &previous_version_schema(2),
19978                ClientLimits::default(),
19979                path_str,
19980            )
19981            .expect("v2 replica"),
19982        );
19983        let before = std::fs::read(&path).expect("before");
19984        let error = SyncClient::open_path_with_identity(
19985            Some("intruder".into()),
19986            &previous_version_schema(2),
19987            ClientLimits::default(),
19988            path_str,
19989        )
19990        .err()
19991        .expect("refuse the identity mismatch");
19992        assert!(error.starts_with("client.identity_mismatch:"), "{error}");
19993        let probe = Connection::open(path_str).expect("probe");
19994        assert_eq!(
19995            journal_mode(&probe),
19996            "delete",
19997            "a refused open must not persist WAL"
19998        );
19999        assert_eq!(std::fs::read(&path).expect("after"), before);
20000        assert_eq!(
20001            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
20002            Some("2")
20003        );
20004        drop(probe);
20005        std::fs::remove_file(path).expect("remove replica");
20006    }
20007
20008    #[test]
20009    fn a_concurrent_marker_upgrade_refuses_a_stale_open() {
20010        let path = temp_replica("marker-race");
20011        let path_str = path.to_str().expect("path");
20012        drop(
20013            SyncClient::open_path_with_identity(
20014                Some("stale".into()),
20015                &previous_version_schema(1),
20016                ClientLimits::default(),
20017                path_str,
20018            )
20019            .expect("v1 replica"),
20020        );
20021        // Another process holds the write lock with an uncommitted upgrade; a
20022        // stale open reads the v1 marker first and only then meets the lock.
20023        // The first busy therefore lands after this replica became v2.
20024        let banner = Connection::open(path_str).expect("banner");
20025        banner
20026            .execute_batch(
20027                "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
20028            )
20029            .expect("pending upgrade");
20030        CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
20031        let conn = Connection::open(path_str).expect("stale connection");
20032        conn.busy_handler(Some(commit_concurrent_upgrade))
20033            .expect("busy handler");
20034        let error = SyncClient::with_connection(
20035            "stale".into(),
20036            &previous_version_schema(1),
20037            ClientLimits::default(),
20038            conn,
20039        )
20040        .err()
20041        .expect("a concurrent upgrade must refuse the stale open");
20042        assert!(error.starts_with("client.schema_downgrade:"), "{error}");
20043        let probe = Connection::open(path_str).expect("probe");
20044        assert_eq!(
20045            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
20046            Some("2"),
20047            "the concurrent upgrade survives"
20048        );
20049        drop(probe);
20050        std::fs::remove_file(path).expect("remove replica");
20051    }
20052
20053    #[test]
20054    fn a_concurrent_marker_upgrade_refuses_a_stale_recreate() {
20055        let path = temp_replica("recreate-race");
20056        let path_str = path.to_str().expect("path");
20057        let mut client = SyncClient::open_path_with_identity(
20058            Some("stale".into()),
20059            &previous_version_schema(1),
20060            ClientLimits::default(),
20061            path_str,
20062        )
20063        .expect("v1 replica");
20064        client
20065            .mutate(vec![Mutation::Upsert {
20066                table: "tasks".into(),
20067                values: Map::from_iter([("id".into(), json!("queued"))]),
20068                base_version: None,
20069            }])
20070            .expect("queued write");
20071        let commit = client.pending_commit_ids();
20072        let banner = Connection::open(path_str).expect("banner");
20073        banner
20074            .execute_batch(
20075                "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
20076            )
20077            .expect("pending upgrade");
20078        CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
20079        client
20080            .conn
20081            .busy_handler(Some(commit_concurrent_upgrade))
20082            .expect("busy handler");
20083        let error = client
20084            .recreate_with_schema(&previous_version_schema(1))
20085            .expect_err("a concurrent upgrade must refuse the stale recreate");
20086        assert!(error.starts_with("client.schema_downgrade:"), "{error}");
20087        assert_eq!(client.schema.version, 1, "the compiled schema is restored");
20088        assert_eq!(
20089            client.pending_commit_ids(),
20090            commit,
20091            "the outbox is preserved"
20092        );
20093        let probe = Connection::open(path_str).expect("probe");
20094        assert_eq!(
20095            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
20096            Some("2")
20097        );
20098        drop(probe);
20099        drop(client);
20100        std::fs::remove_file(path).expect("remove replica");
20101    }
20102
20103    #[test]
20104    fn failed_schema_record_writes_roll_back_recreation() {
20105        for key in [
20106            LOCAL_SCHEMA_VERSION_KEY,
20107            crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY,
20108        ] {
20109            let mut client = SyncClient::new(
20110                "schema-record".into(),
20111                &previous_version_schema(1),
20112                ClientLimits::default(),
20113            )
20114            .expect("v1");
20115            let marker = client.get_meta(LOCAL_SCHEMA_VERSION_KEY);
20116            let descriptor = client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
20117            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");
20118            client
20119                .recreate_with_schema(&previous_version_schema(2))
20120                .expect_err("both record writes must propagate failure");
20121            assert_eq!(client.schema.version, 1);
20122            assert_eq!(client.get_meta(LOCAL_SCHEMA_VERSION_KEY), marker);
20123            assert_eq!(
20124                client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY),
20125                descriptor
20126            );
20127            client
20128                .conn
20129                .execute_batch("DROP TRIGGER refuse_schema_record")
20130                .expect("remove fault");
20131            client
20132                .recreate_with_schema(&previous_version_schema(2))
20133                .expect("retry");
20134            assert_eq!(client.schema.version, 2);
20135        }
20136    }
20137
20138    #[test]
20139    fn a_failed_outer_commit_restores_every_reset_side_effect() {
20140        let path = temp_replica("outer-commit");
20141        let path_str = path.to_str().expect("path");
20142        // A rollback-journal replica (no WAL), so a second read connection can
20143        // hold SHARED across the recreated transaction's COMMIT.
20144        let conn = Connection::open(path_str).expect("replica connection");
20145        let mut client = SyncClient::with_connection(
20146            "outer-commit".into(),
20147            &previous_version_schema(1),
20148            previous_version_limits(),
20149            conn,
20150        )
20151        .expect("v1 replica");
20152        assert_eq!(journal_mode(&client.conn), "delete");
20153        let commit = client
20154            .mutate(vec![Mutation::Upsert {
20155                table: "tasks".into(),
20156                values: Map::from_iter([
20157                    ("id".into(), json!("queued")),
20158                    ("note".into(), json!("dropped-by-v2")),
20159                ]),
20160                base_version: None,
20161            }])
20162            .expect("queued write");
20163        client.drain_change_batches();
20164        // v2 drops `note`, so the queued upsert cannot re-encode.
20165        let mut dropped = previous_version_schema(2);
20166        dropped["tables"][0]["columns"] =
20167            json!([{ "name": "id", "type": "string", "nullable": false }]);
20168        // Prior database and memory state: the refused COMMIT must restore all
20169        // of it.
20170        let marker = meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY);
20171        let revision = client.local_revision();
20172        let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
20173        let columns: i64 = client
20174            .conn
20175            .query_row(
20176                "SELECT count(*) FROM pragma_table_info('tasks')",
20177                [],
20178                |row| row.get(0),
20179            )
20180            .expect("column count");
20181        let rebuilds = client.overlay_rebuild_count.get();
20182        let prior_subs = client.subs.len();
20183        let prior_changes = client.change_queue.len();
20184        let prior_intents = client.sync_intent_queue.len();
20185        let prior_last_change = client.last_change.clone();
20186        let prior_overlay_dirty = client.overlay_dirty.get();
20187        let prior_sync_needed = client.sync_needed;
20188        let prior_upgrading = client.upgrading;
20189        let prior_stopped = client.stopped;
20190        let prior_schema_floor = client.schema_floor.clone();
20191        let prior_active_round = client.active_round;
20192        // The reader holds SHARED for the whole attempt: the writer takes
20193        // RESERVED, runs the reset, and fails when COMMIT needs EXCLUSIVE.
20194        let reader = Connection::open(path_str).expect("reader");
20195        reader
20196            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
20197            .expect("reader snapshot");
20198        client
20199            .conn
20200            .busy_timeout(std::time::Duration::ZERO)
20201            .expect("busy timeout");
20202        let error = client
20203            .recreate_with_schema(&dropped)
20204            .expect_err("a blocked COMMIT must refuse the recreation");
20205        assert!(error.starts_with("client.storage_busy:"), "{error}");
20206        assert!(
20207            client.overlay_rebuild_count.get() > rebuilds,
20208            "the reset ran to its overlay rebuild inside the transaction"
20209        );
20210        assert!(client.conn.is_autocommit(), "the transaction is released");
20211        // Database: the rolled-back reset left nothing behind.
20212        assert_eq!(meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY), marker);
20213        assert_eq!(client.local_revision(), revision);
20214        assert_eq!(
20215            client.query("SELECT * FROM tasks", &[]).expect("rows"),
20216            rows
20217        );
20218        assert_eq!(
20219            client
20220                .conn
20221                .query_row(
20222                    "SELECT count(*) FROM pragma_table_info('tasks')",
20223                    [],
20224                    |row| { row.get::<_, i64>(0) }
20225                )
20226                .expect("column count"),
20227            columns,
20228            "the previous table layout survives the rollback"
20229        );
20230        assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
20231        // Memory: every side effect of the successful reset is restored with
20232        // the rolled-back database.
20233        assert_eq!(client.schema.version, 1, "compiled schema");
20234        assert_eq!(client.subs.len(), prior_subs);
20235        assert_eq!(client.change_queue.len(), prior_changes);
20236        assert_eq!(client.sync_intent_queue.len(), prior_intents);
20237        assert_eq!(client.last_change, prior_last_change);
20238        assert_eq!(client.overlay_dirty.get(), prior_overlay_dirty);
20239        assert_eq!(client.sync_needed, prior_sync_needed);
20240        assert_eq!(client.upgrading, prior_upgrading);
20241        assert_eq!(client.stopped, prior_stopped);
20242        assert_eq!(client.schema_floor, prior_schema_floor);
20243        assert_eq!(client.active_round, prior_active_round);
20244        assert!(
20245            client.rejections.is_empty(),
20246            "the dropped-column rejection is rolled back too"
20247        );
20248        // The capture committed in its separate file before the failed replica
20249        // COMMIT. It belongs to v2 and cannot be exposed by the restored v1 core.
20250        let container = crate::previous_version::previous_version_container_path(path_str);
20251        assert!(std::path::Path::new(&container).exists());
20252        // The reader no longer needs its snapshot, so discard can clean the
20253        // replica's audit records along with the stale sibling file.
20254        drop(reader);
20255        let snapshot = client
20256            .previous_version_snapshot(&PreviousVersionReadSpec {
20257                table: "tasks".into(),
20258                ..Default::default()
20259            })
20260            .expect("refuse stale capture");
20261        assert!(!snapshot.available);
20262        assert_eq!(snapshot.current_version, 1);
20263        assert!(!std::path::Path::new(&container).exists());
20264        // The client remains usable: releasing the reader lets the identical
20265        // recreation run to completion, and the reset preserves the
20266        // incompatible intent until the send-time prepass classifies it
20267        // (§7.4.3/§7.4.4: the reset never touches the outbox).
20268        client
20269            .conn
20270            .busy_timeout(std::time::Duration::from_secs(1))
20271            .expect("busy timeout");
20272        client
20273            .recreate_with_schema(&dropped)
20274            .expect("the recreation succeeds once the reader releases");
20275        assert_eq!(client.schema.version, 2);
20276        assert_eq!(
20277            client.pending_commit_ids(),
20278            vec![commit.clone()],
20279            "the reset kept the incompatible commit pending"
20280        );
20281        assert!(
20282            client.rejections.is_empty(),
20283            "no rejection exists before the send-time prepass"
20284        );
20285        // The send boundary classifies and drops it.
20286        client
20287            .prepare_sync_round(false)
20288            .expect("the send prepass runs during round preparation");
20289        assert!(client.pending_commit_ids().is_empty());
20290        assert!(
20291            client
20292                .rejections
20293                .iter()
20294                .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE),
20295            "the dropped-column commit surfaced as a rejection at send time"
20296        );
20297        client.previous_version_discard();
20298        drop(client);
20299        std::fs::remove_file(path).expect("remove replica");
20300    }
20301
20302    #[test]
20303    fn the_send_time_incompatible_drop_is_atomic_and_publishes_once() {
20304        let v1 = previous_version_schema(1);
20305        let mut v2 = previous_version_schema(2);
20306        // v2 drops `note`, so the queued `dropped` upsert cannot re-encode.
20307        v2["tables"][0]["columns"] = json!([{ "name": "id", "type": "string", "nullable": false }]);
20308        let mut client = SyncClient::new("drop".to_string(), &v1, ClientLimits::default()).unwrap();
20309        let dropped = client
20310            .mutate(vec![Mutation::Upsert {
20311                table: "tasks".into(),
20312                values: Map::from_iter([
20313                    ("id".into(), json!("dropped")),
20314                    ("note".into(), json!("gone")),
20315                ]),
20316                base_version: None,
20317            }])
20318            .unwrap();
20319        // A full-row v1 upsert always carries the v1 columns, so the surviving
20320        // commit is a value-free delete (it encodes under any schema that still
20321        // has the table).
20322        let survivor = client
20323            .mutate(vec![Mutation::Delete {
20324                table: "tasks".into(),
20325                row_id: "gone".into(),
20326                base_version: None,
20327            }])
20328            .unwrap();
20329        client
20330            .recreate_with_schema(&v2)
20331            .expect("the reset preserves the outbox");
20332        assert_eq!(
20333            client.pending_commit_ids(),
20334            vec![dropped.clone(), survivor.clone()],
20335            "the reset preserves both commits"
20336        );
20337        assert!(client.rejections.is_empty(), "the reset classifies nothing");
20338        client.drain_change_batches();
20339        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
20340        assert_eq!(rows.len(), 1, "only the dropped upsert has a visible row");
20341
20342        // A fault anywhere in the atomic drop leaves the replica, the outbox,
20343        // the durable outcomes, the visible projection, the revision, and the
20344        // publish surface untouched. Clearing `syncNeeded` first keeps the
20345        // round's own status flip out of the assertion, so any published change
20346        // can only be the drop's.
20347        for fault in [
20348            "outcome-insert",
20349            "outbox-delete",
20350            "overlay-rebuild",
20351            "publish-revision",
20352        ] {
20353            client.set_sync_needed(false, false);
20354            client.drain_change_batches();
20355            let revision = client.local_revision();
20356            let trigger = match fault {
20357                "outcome-insert" => {
20358                    "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20359                }
20360                "outbox-delete" => {
20361                    "CREATE TRIGGER fail_drop BEFORE DELETE ON _syncular_outbox BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20362                }
20363                // The optimized replay removes only the dropped projection, so
20364                // the replay fault belongs on the DELETE; a publish fault lands
20365                // on the revision write that `finish_observation` performs after
20366                // every durable effect already succeeded.
20367                "publish-revision" => {
20368                    "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20369                }
20370                _ => {
20371                    "CREATE TRIGGER fail_drop BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20372                }
20373            };
20374            client.conn.execute_batch(trigger).unwrap();
20375            let outcome = client
20376                .prepare_sync_round(false)
20377                .err()
20378                .unwrap_or_else(|| panic!("{fault}: a fault in the drop must fail the round"));
20379            assert!(
20380                matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "storage.failed"),
20381                "{fault}: {outcome:?}"
20382            );
20383            client.conn.execute_batch("DROP TRIGGER fail_drop").unwrap();
20384            assert!(client.conn.is_autocommit(), "{fault}: no leaked savepoint");
20385            assert_eq!(
20386                client.pending_commit_ids(),
20387                vec![dropped.clone(), survivor.clone()],
20388                "{fault}: the outbox is restored"
20389            );
20390            assert!(
20391                client.rejections.is_empty(),
20392                "{fault}: no in-memory rejection survives"
20393            );
20394            assert!(
20395                client.commit_outcome(&dropped).unwrap().is_none(),
20396                "{fault}: the durable outcome rolled back"
20397            );
20398            assert_eq!(client.local_revision(), revision, "{fault}: revision");
20399            assert_eq!(
20400                client.query("SELECT * FROM tasks", &[]).unwrap(),
20401                rows,
20402                "{fault}: the visible projection is unchanged"
20403            );
20404            assert!(
20405                client.drain_change_batches().is_empty(),
20406                "{fault}: nothing is published"
20407            );
20408        }
20409
20410        // A nested failure leaves the caller's transaction and earlier writes
20411        // intact; only the drop's savepoint is rolled back.
20412        client
20413            .conn
20414            .execute_batch(
20415                "BEGIN; INSERT INTO _syncular_meta(key,value) VALUES ('outerProbe','kept');
20416             CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes
20417             BEGIN SELECT RAISE(ABORT,'nested drop fault'); END;",
20418            )
20419            .unwrap();
20420        assert!(client.drop_incompatible_outbox().is_err());
20421        assert!(!client.conn.is_autocommit());
20422        assert_eq!(client.get_meta("outerProbe").as_deref(), Some("kept"));
20423        assert_eq!(
20424            client.pending_commit_ids(),
20425            vec![dropped.clone(), survivor.clone()]
20426        );
20427        assert!(client.commit_outcome(&dropped).unwrap().is_none());
20428        client
20429            .conn
20430            .execute_batch("DROP TRIGGER fail_drop; COMMIT")
20431            .unwrap();
20432
20433        // The successful drop publishes exactly once and leaves the surviving
20434        // intent queued for the same round. A log epoch is required before a
20435        // round builds push frames at all, so the classification provably runs
20436        // before any operation of the dropped commit is sent.
20437        client.set_meta(LOG_EPOCH_KEY, "epoch");
20438        client.set_sync_needed(false, false);
20439        client.drain_change_batches();
20440        let revision = client.local_revision();
20441        let prepared = client.prepare_sync_round(false).expect("the drop succeeds");
20442        assert_eq!(client.pending_commit_ids(), vec![survivor.clone()]);
20443        assert_eq!(
20444            prepared.meta.pushed_ids,
20445            vec![survivor.clone()],
20446            "the incompatible commit is classified before any operation is sent"
20447        );
20448        let rejection = client
20449            .rejections
20450            .iter()
20451            .find(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE)
20452            .expect("a sync.outbox_incompatible rejection");
20453        assert_eq!(rejection.client_commit_id, dropped);
20454        assert!(
20455            !rejection.retryable,
20456            "a schema-incompatible commit is final"
20457        );
20458        assert_eq!(
20459            client
20460                .commit_outcome(&dropped)
20461                .unwrap()
20462                .expect("a durable rejected outcome")
20463                .status,
20464            CommitOutcomeStatus::Rejected
20465        );
20466        assert_eq!(client.local_revision(), revision + 1, "one revision");
20467        let batches = client.drain_change_batches();
20468        assert_eq!(batches.len(), 1, "one change batch");
20469        assert!(
20470            batches[0].rejections_changed
20471                && batches[0].outcomes_changed
20472                && batches[0].status.is_some(),
20473            "{:?}",
20474            batches[0]
20475        );
20476        assert!(
20477            batches[0].tables.iter().any(|entry| entry.table == "tasks"),
20478            "{:?}",
20479            batches[0]
20480        );
20481        let visible = client.query("SELECT * FROM tasks", &[]).unwrap();
20482        assert!(
20483            visible.is_empty(),
20484            "the dropped projection is undone: {visible:?}"
20485        );
20486
20487        // The observation's outer RELEASE (transaction COMMIT) is guarded too:
20488        // a rollback-journal reader holding SHARED makes it fail with the typed
20489        // busy classification, and releasing the reader lets the retry succeed.
20490        let path = temp_replica("outbox-drop-commit");
20491        let path_str = path.to_str().expect("path");
20492        let conn = Connection::open(path_str).expect("replica connection");
20493        let mut client =
20494            SyncClient::with_connection("drop-commit".into(), &v1, ClientLimits::default(), conn)
20495                .expect("v1 replica");
20496        let dropped = client
20497            .mutate(vec![Mutation::Upsert {
20498                table: "tasks".into(),
20499                values: Map::from_iter([
20500                    ("id".into(), json!("dropped")),
20501                    ("note".into(), json!("gone")),
20502                ]),
20503                base_version: None,
20504            }])
20505            .unwrap();
20506        client
20507            .recreate_with_schema(&v2)
20508            .expect("the reset preserves the outbox");
20509        assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
20510        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
20511        assert_eq!(rows.len(), 1, "the optimistic row is visible");
20512        client.set_sync_needed(false, false);
20513        client.drain_change_batches();
20514        let revision = client.local_revision();
20515        let reader = Connection::open(path_str).expect("reader");
20516        reader
20517            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
20518            .expect("reader snapshot");
20519        client
20520            .conn
20521            .busy_timeout(std::time::Duration::ZERO)
20522            .expect("busy timeout");
20523        let outcome = client
20524            .prepare_sync_round(false)
20525            .err()
20526            .expect("a blocked COMMIT must fail the round");
20527        assert!(
20528            matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "client.storage_busy"),
20529            "{outcome:?}"
20530        );
20531        assert!(client.conn.is_autocommit(), "the savepoint is released");
20532        assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
20533        assert!(client.rejections.is_empty());
20534        assert!(client.commit_outcome(&dropped).unwrap().is_none());
20535        assert_eq!(client.local_revision(), revision);
20536        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
20537        assert!(client.drain_change_batches().is_empty());
20538        // The same round succeeds once the reader releases.
20539        drop(reader);
20540        client
20541            .conn
20542            .busy_timeout(std::time::Duration::from_secs(1))
20543            .expect("busy timeout");
20544        client
20545            .prepare_sync_round(false)
20546            .expect("the drop succeeds after the reader releases");
20547        assert!(client.pending_commit_ids().is_empty());
20548        assert!(client
20549            .rejections
20550            .iter()
20551            .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE));
20552        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
20553        drop(client);
20554        std::fs::remove_file(path).expect("remove replica");
20555    }
20556
20557    #[test]
20558    fn previous_version_read_reasons_come_from_live_client_state() {
20559        use crate::previous_version::{
20560            PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
20561        };
20562        let path = std::env::temp_dir()
20563            .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
20564            .to_string_lossy()
20565            .into_owned();
20566        let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
20567        {
20568            let mut setup = SyncClient::open_path(
20569                "wiring".to_owned(),
20570                &previous_version_schema(1),
20571                previous_version_limits(),
20572                &path,
20573            )
20574            .expect("v1 install");
20575            setup
20576                .mutate(vec![Mutation::Upsert {
20577                    table: "tasks".to_owned(),
20578                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
20579                    base_version: None,
20580                }])
20581                .expect("seed a row");
20582        }
20583        let mut client = SyncClient::open_path(
20584            "wiring".to_owned(),
20585            &previous_version_schema(2),
20586            previous_version_limits(),
20587            &path,
20588        )
20589        .expect("v2 bump captures");
20590        assert!(std::path::Path::new(&container).exists());
20591        let spec = PreviousVersionReadSpec {
20592            table: "tasks".to_owned(),
20593            ..Default::default()
20594        };
20595
20596        // §7.3.5 lease error ⇒ lease-inactive, and the read discards.
20597        recapture_previous_version(&client);
20598        client.lease_state = Some(LeaseState {
20599            lease_id: None,
20600            expires_at_ms: None,
20601            error_code: Some("lease.expired".to_owned()),
20602        });
20603        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20604        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
20605        assert!(!snapshot.available);
20606        assert!(!std::path::Path::new(&container).exists());
20607
20608        // An expired lease uses the client clock (injected, no sleeps).
20609        recapture_previous_version(&client);
20610        client.set_now_ms(1_000);
20611        client.lease_state = Some(LeaseState {
20612            lease_id: Some("lease".to_owned()),
20613            expires_at_ms: Some(1_000),
20614            error_code: None,
20615        });
20616        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20617        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
20618        assert!(!std::path::Path::new(&container).exists());
20619        client.lease_state = None;
20620
20621        // A revoked subscription ⇒ scope-revoked.
20622        recapture_previous_version(&client);
20623        client.subs.push(subscription(SubState::Revoked, 0, None));
20624        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20625        assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
20626        assert!(!std::path::Path::new(&container).exists());
20627        client.subs.clear();
20628
20629        // Coverage completion: every ACTIVE subscription has a cursor and no
20630        // resume token — a still-resuming subscription is not completion.
20631        recapture_previous_version(&client);
20632        client
20633            .subs
20634            .push(subscription(SubState::Active, 0, Some("resume")));
20635        assert!(!client.previous_version_coverage_complete());
20636        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20637        assert!(snapshot.available);
20638        assert_eq!(snapshot.previous_version, Some(2));
20639        assert_eq!(snapshot.rows.len(), 1);
20640
20641        recapture_previous_version(&client);
20642        client.subs[0].bootstrap_state = None;
20643        assert!(client.previous_version_coverage_complete());
20644        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20645        assert_eq!(
20646            snapshot.reason,
20647            Some(PreviousVersionReason::CoverageComplete)
20648        );
20649        assert!(!std::path::Path::new(&container).exists());
20650        client.subs.clear();
20651
20652        // With no active subscription at all, coverage is NOT complete.
20653        assert!(!client.previous_version_coverage_complete());
20654
20655        drop(client);
20656        let _ = std::fs::remove_file(&path);
20657    }
20658
20659    /// RFC 0005 D7 lifetime drops. The read surface already proves the reasons
20660    /// come from live state; this proves the OTHER caller —
20661    /// `previous_version_lifetime_check`, the once-per-sync-round trigger —
20662    /// actually removes the container for lease-inactivity, coverage
20663    /// completion and scope revocation, and leaves it alone while the
20664    /// replacement bootstrap is still resuming.
20665    #[test]
20666    fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
20667        let path = std::env::temp_dir()
20668            .join(format!(
20669                "syncular-prev-lifetime-{}.db",
20670                uuid::Uuid::new_v4()
20671            ))
20672            .to_string_lossy()
20673            .into_owned();
20674        let container = format!(
20675            "{path}{}",
20676            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
20677        );
20678        let exists = || std::path::Path::new(&container).exists();
20679        {
20680            let mut setup = SyncClient::open_path(
20681                "lifetime".to_owned(),
20682                &previous_version_schema(1),
20683                previous_version_limits(),
20684                &path,
20685            )
20686            .expect("v1 install");
20687            setup
20688                .mutate(vec![Mutation::Upsert {
20689                    table: "tasks".to_owned(),
20690                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
20691                    base_version: None,
20692                }])
20693                .expect("seed a row");
20694        }
20695        let mut client = SyncClient::open_path(
20696            "lifetime".to_owned(),
20697            &previous_version_schema(2),
20698            previous_version_limits(),
20699            &path,
20700        )
20701        .expect("v2 bump captures");
20702        // Pin the clock so the 24h TTL never fires in this test (the TTL has
20703        // its own coverage); the capture record's createdAtMs is 1.
20704        client.set_now_ms(1_000);
20705        assert!(exists());
20706
20707        // §7.3.5 lease error ⇒ the lifetime check drops the container.
20708        client.lease_state = Some(LeaseState {
20709            lease_id: None,
20710            expires_at_ms: None,
20711            error_code: Some("lease.expired".to_owned()),
20712        });
20713        client.previous_version_lifetime_check();
20714        assert!(!exists());
20715        client.lease_state = None;
20716
20717        // §7.4.5 coverage completion (every active sub has a cursor and no
20718        // resume token) ⇒ drop.
20719        recapture_previous_version(&client);
20720        client.subs.push(subscription(SubState::Active, 0, None));
20721        client.previous_version_lifetime_check();
20722        assert!(!exists());
20723        client.subs.clear();
20724
20725        // §3.3 scope revocation ⇒ drop.
20726        recapture_previous_version(&client);
20727        client.subs.push(subscription(SubState::Revoked, 0, None));
20728        client.previous_version_lifetime_check();
20729        assert!(!exists());
20730        client.subs.clear();
20731
20732        // Negative control: a still-resuming active subscription is NOT
20733        // coverage completion, so the container survives the same call.
20734        recapture_previous_version(&client);
20735        client
20736            .subs
20737            .push(subscription(SubState::Active, 0, Some("resume")));
20738        client.previous_version_lifetime_check();
20739        assert!(exists());
20740        client.subs.clear();
20741
20742        drop(client);
20743        let _ = std::fs::remove_file(&path);
20744        let _ = std::fs::remove_file(&container);
20745        let _ = std::fs::remove_file(format!("{container}-wal"));
20746        let _ = std::fs::remove_file(format!("{container}-shm"));
20747    }
20748}