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    SubscriptionRequest, SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport,
41    SyncStatusSnapshot, TableChange, WindowBase, WindowChange, WindowCoverage, WindowState,
42    WindowUnitRef, CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
43    MAX_DIAGNOSTIC_QUERY_FAILURES,
44};
45use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
46#[cfg(feature = "bench-internals")]
47use crate::bench::{Phase, Recorder};
48use crate::previous_version::{
49    capture_previous_version_from_replica, first_incompatibility,
50    reconcile_previous_version_at_boot, set_local_schema_descriptor,
51    sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
52    PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
53};
54use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
55use crate::transport::{BlobDownload, SegmentRequest, Transport, TransportError};
56use crate::values::{
57    bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
58    encode_sparse_row_json, full_row_values, 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    #[test]
287    fn subscribe_many_registers_all_or_nothing() {
288        let request = |id: &str, project: &str| SubscriptionRequest {
289            id: id.to_owned(),
290            table: "tasks".to_owned(),
291            scopes: vec![("project_id".to_owned(), vec![project.to_owned()])],
292            params: None,
293        };
294        let mut client = client();
295        client
296            .subscribe_many(vec![request("a", "p1"), request("b", "p2")])
297            .unwrap();
298        let persisted = |client: &SyncClient| -> i64 {
299            client
300                .conn
301                .query_row("SELECT count(*) FROM _syncular_subscriptions", [], |row| {
302                    row.get(0)
303                })
304                .unwrap()
305        };
306        assert_eq!(persisted(&client), 2);
307        // `b` names another project: the whole call fails, `c` stays out.
308        let error = client
309            .subscribe_many(vec![request("c", "p3"), request("b", "p9")])
310            .unwrap_err();
311        assert!(error.starts_with("client.subscription_intent_mismatch:"));
312        assert!(client.subscription_state("c").is_none());
313        assert_eq!(persisted(&client), 2);
314        assert!(client.conn.is_autocommit());
315    }
316
317    #[cfg(feature = "e2ee")]
318    #[test]
319    fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
320        let schema = json!({
321            "version": 1,
322            "tables": [{
323                "name": "secrets",
324                "primaryKey": "id",
325                "columns": [
326                    { "name": "id", "type": "string", "nullable": false },
327                    { "name": "encryption_key_id", "type": "string", "nullable": true },
328                    { "name": "note", "type": "bytes", "nullable": true,
329                      "encrypted": true, "declaredType": "string" }
330                ],
331                "scopes": []
332            }]
333        });
334        let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
335            .expect("test client");
336        let mut config = crate::values::EncryptionConfig::default();
337        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
338        config
339            .key_id_columns
340            .insert("secrets".to_owned(), "encryption_key_id".to_owned());
341        client.set_encryption(config);
342        // The row exists, but its key selector is absent. The encrypted edit
343        // authors locally and fails at the push seam.
344        client
345            .write_base_row(
346                "secrets",
347                &vec![Some(ColumnValue::String("ghost".into())), None, None],
348                1,
349            )
350            .unwrap();
351        client.rebuild_overlay().expect("rebuild overlay");
352        let commit_id = client
353            .patch(
354                "secrets",
355                "ghost",
356                Map::from_iter([("note".to_owned(), json!("x"))]),
357                None,
358            )
359            .expect("patch records without an author-time encrypt failure");
360        assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
361        // ...and the push seam raises a durable local rejection, not a throw.
362        assert!(client.drop_unencodable_outbox(true, None).unwrap());
363        assert!(client.pending_commit_ids().is_empty());
364        let rejections = client.rejections();
365        assert_eq!(rejections.len(), 1);
366        assert_eq!(rejections[0].code, "client.encrypt_failed");
367        assert_eq!(rejections[0].client_commit_id, commit_id);
368        assert_eq!(
369            client
370                .commit_outcome(&commit_id)
371                .unwrap()
372                .map(|outcome| outcome.status),
373            Some(CommitOutcomeStatus::Rejected)
374        );
375    }
376
377    #[test]
378    fn patch_scope_read_preserves_sqlite_failure() {
379        let mut client = client();
380        client
381            .conn
382            .execute_batch(
383                "DROP TABLE tasks;
384            CREATE VIEW tasks AS SELECT 't1' AS id, abs(-9223372036854775808) AS project_id;",
385            )
386            .unwrap();
387        let error = client
388            .patch(
389                "tasks",
390                "t1",
391                Map::from_iter([("project_id".to_owned(), json!("p1"))]),
392                None,
393            )
394            .expect_err("the scope read must fail");
395        assert_ne!(error.code, "sync.invalid_request");
396        assert_eq!(error.code, "client.failed");
397        assert!(error.details.as_ref().unwrap()["legacyCause"]
398            .as_str()
399            .unwrap()
400            .contains("integer overflow"));
401        assert!(client.pending_commit_ids().is_empty());
402        assert!(client.conn.is_autocommit());
403    }
404
405    #[test]
406    fn patch_with_a_scope_column_matches_the_stored_local_row() {
407        let mut client = client();
408        client.create_synced_tables().unwrap();
409        client
410            .conn
411            .execute(
412                "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
413                [],
414            )
415            .unwrap();
416
417        // §3.4 rule 5: a value equal to the stored local row is a no-op, so
418        // the scope column leaves the presence set.
419        client
420            .patch(
421                "tasks",
422                "t1",
423                Map::from_iter([
424                    ("project_id".to_owned(), json!("p1")),
425                    ("id".to_owned(), json!("t1")),
426                ]),
427                None,
428            )
429            .expect("an equal scope column is a no-op");
430        let values = client.outbox[0].ops[0]
431            .values
432            .clone()
433            .expect("the patch records values");
434        assert_eq!(values.get("id"), Some(&json!("t1")));
435        assert!(!values.contains_key("project_id"));
436
437        // A differing value stays rejected.
438        let differing = client.patch(
439            "tasks",
440            "t1",
441            Map::from_iter([("project_id".to_owned(), json!("p2"))]),
442            None,
443        );
444        let differing = differing.expect_err("a differing scope column is rejected");
445        assert_eq!(differing.code, "sync.invalid_request");
446        assert_eq!(differing.message, "the authoring request is invalid");
447        assert!(
448            differing
449                .details
450                .as_ref()
451                .and_then(|details| details["legacyCause"].as_str())
452                .is_some_and(|cause| cause.contains("patch cannot write scope column")),
453            "{differing:?}"
454        );
455
456        // An absent local row leaves nothing to prove equality against, so
457        // the patch fails closed.
458        let absent = client.patch(
459            "tasks",
460            "ghost",
461            Map::from_iter([("project_id".to_owned(), json!("p1"))]),
462            None,
463        );
464        let absent = absent.expect_err("an absent local row is rejected");
465        assert_eq!(absent.code, "sync.row_missing");
466        assert!(absent.message.contains("requires a local row"));
467        assert_eq!(client.pending_commit_ids().len(), 1);
468    }
469
470    #[test]
471    fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
472        let schema = json!({
473            "version": 1,
474            "tables": [
475                {
476                    "name": "tenants",
477                    "primaryKey": "tenant_id",
478                    "columns": [
479                        { "name": "tenant_id", "type": "string", "nullable": false },
480                        { "name": "body", "type": "string", "nullable": false }
481                    ],
482                    "scopes": [{ "pattern": "tenant:{tenant_id}" }]
483                },
484                {
485                    "name": "buckets",
486                    "primaryKey": "id",
487                    "columns": [
488                        { "name": "id", "type": "string", "nullable": false },
489                        { "name": "bucket", "type": "float", "nullable": false },
490                        { "name": "body", "type": "string", "nullable": false }
491                    ],
492                    "scopes": [{ "pattern": "bucket:{bucket}" }]
493                }
494            ]
495        });
496        let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
497            .expect("test client");
498        client.create_synced_tables().unwrap();
499
500        // A primary key that is also a scope column still needs a local base.
501        client
502            .conn
503            .execute(
504                "INSERT INTO tenants (tenant_id, body, _syncular_version) VALUES ('t1', 'seed', 1)",
505                [],
506            )
507            .unwrap();
508        client
509            .patch(
510                "tenants",
511                "t1",
512                Map::from_iter([
513                    ("tenant_id".to_owned(), json!("t1")),
514                    ("body".to_owned(), json!("created")),
515                ]),
516                None,
517            )
518            .expect("the primary-key scope column is proven equal by construction");
519
520        // A stored REAL scope value coerces from a supplied integer.
521        client
522            .conn
523            .execute(
524                "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
525                [],
526            )
527            .unwrap();
528        client
529            .patch(
530                "buckets",
531                "b1",
532                Map::from_iter([
533                    ("bucket".to_owned(), json!(2)),
534                    ("body".to_owned(), json!("coerced")),
535                ]),
536                None,
537            )
538            .expect("the stored REAL value coerces from a supplied integer");
539        let values = client.outbox[1].ops[0]
540            .values
541            .clone()
542            .expect("the patch records values");
543        assert!(!values.contains_key("bucket"));
544
545        // A differing value stays rejected.
546        let differing = client.patch(
547            "buckets",
548            "b1",
549            Map::from_iter([("bucket".to_owned(), json!(3))]),
550            None,
551        );
552        let differing = differing.expect_err("a differing scope value is rejected");
553        assert_eq!(differing.code, "sync.invalid_request");
554        assert_eq!(differing.message, "the authoring request is invalid");
555        assert!(
556            differing
557                .details
558                .as_ref()
559                .and_then(|details| details["legacyCause"].as_str())
560                .is_some_and(|cause| cause.contains("patch cannot write scope column")),
561            "{differing:?}"
562        );
563    }
564
565    #[cfg(feature = "e2ee")]
566    #[test]
567    fn stored_key_fallback_resolves_an_integer_primary_key() {
568        let schema = json!({
569            "version": 1,
570            "tables": [{
571                "name": "patients",
572                "primaryKey": "id",
573                "columns": [
574                    { "name": "id", "type": "integer", "nullable": false },
575                    { "name": "encryption_key_id", "type": "string", "nullable": true },
576                    { "name": "note", "type": "bytes", "nullable": true,
577                      "encrypted": true, "declaredType": "string" }
578                ],
579                "scopes": []
580            }]
581        });
582        let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
583            .expect("test client");
584        client.create_synced_tables().unwrap();
585        client
586            .conn
587            .execute(
588                "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
589                 VALUES (5, 'k1', NULL, 1)",
590                [],
591            )
592            .unwrap();
593        let mut config = crate::values::EncryptionConfig::default();
594        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
595        config
596            .key_id_columns
597            .insert("patients".to_owned(), "encryption_key_id".to_owned());
598        client.set_encryption(config);
599        let table = client.schema.table("patients").unwrap();
600        let fallback = client
601            .stored_key_fallback(table, "5")
602            .expect("the stored integer key resolves the fallback");
603        assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
604    }
605
606    #[test]
607    fn patch_types_an_integer_primary_key_from_its_canonical_row_id() {
608        let schema = json!({
609            "version": 1,
610            "tables": [{
611                "name": "counters",
612                "primaryKey": "id",
613                "columns": [
614                    { "name": "id", "type": "integer", "nullable": false },
615                    { "name": "title", "type": "string", "nullable": false }
616                ],
617                "scopes": []
618            }]
619        });
620        let mut client = SyncClient::new("int-patch".into(), &schema, ClientLimits::default())
621            .expect("test client");
622        client
623            .write_base_row(
624                "counters",
625                &vec![
626                    Some(ColumnValue::Integer(42)),
627                    Some(ColumnValue::String("before".into())),
628                ],
629                1,
630            )
631            .unwrap();
632        client.rebuild_overlay().expect("rebuild overlay");
633        client
634            .patch(
635                "counters",
636                "42",
637                Map::from_iter([
638                    ("id".to_owned(), json!(42)),
639                    ("title".to_owned(), json!("after")),
640                ]),
641                None,
642            )
643            .expect("an integer key that renders as the rowId is not a change");
644        let rows = client.query("SELECT id, title FROM counters", &[]).unwrap();
645        assert_eq!(Value::from(rows), json!([{"id": 42, "title": "after"}]));
646        for row_id in ["042", "+42", "42.0"] {
647            let error = client
648                .patch(
649                    "counters",
650                    row_id,
651                    Map::from_iter([("title".to_owned(), json!("x"))]),
652                    None,
653                )
654                .expect_err("a non-canonical integer rowId names no row");
655            assert_eq!(error.code, "sync.row_missing", "{row_id}");
656        }
657    }
658
659    #[test]
660    fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
661        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
662            {"name":"id","type":"string","nullable":false},
663            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
664        for state in ["absent", "cached", "pinned"] {
665            let path = std::env::temp_dir().join(format!(
666                "syncular-blob-stage-{}.sqlite",
667                uuid::Uuid::new_v4()
668            ));
669            let mut client = SyncClient::open_path(
670                "stage-test".into(),
671                &schema,
672                Default::default(),
673                path.to_str().unwrap(),
674            )
675            .unwrap();
676            let bytes = b"atomic stage";
677            if state != "absent" {
678                client
679                    .upload_blob(bytes, Some("text/plain".into()), None)
680                    .unwrap();
681                if state == "cached" {
682                    client
683                        .conn
684                        .execute("DELETE FROM _syncular_blob_uploads", [])
685                        .unwrap();
686                }
687            }
688            let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
689            client
690                .conn
691                .execute_batch(
692                    "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
693                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
694                 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
695                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
696                )
697                .unwrap();
698            let error = client
699                .upload_blob(bytes, Some("application/octet-stream".into()), None)
700                .unwrap_err();
701            assert!(error.contains("injected stage failure"), "{state}: {error}");
702            assert!(client.conn.is_autocommit());
703            let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
704            drop(client);
705            let mut reopened = SyncClient::open_path(
706                "stage-test".into(),
707                &schema,
708                Default::default(),
709                path.to_str().unwrap(),
710            )
711            .unwrap();
712            let durable = reopened
713                .query("SELECT * FROM _syncular_blobs", &[])
714                .unwrap();
715            reopened
716                .conn
717                .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
718                .unwrap();
719            let retried = reopened.upload_blob(bytes, None, None).unwrap();
720            assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
721            assert_eq!(
722                reopened
723                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
724                    .unwrap(),
725                vec![Map::from_iter([(
726                    "blob_id".into(),
727                    Value::from(blob_id_for(bytes)),
728                )])]
729            );
730            drop(reopened);
731            std::fs::remove_file(&path).unwrap();
732            assert_eq!(durable, bodies, "{state}: reopened body state");
733            assert_eq!(immediate, bodies, "{state}: immediate body state");
734        }
735    }
736
737    #[test]
738    fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
739        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
740            {"name":"id","type":"string","nullable":false},
741            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
742        for fault in [
743            "missing",
744            "body-type",
745            "length",
746            "hash",
747            "pin-metadata",
748            "read",
749            "pin-delete",
750        ] {
751            let path = std::env::temp_dir().join(format!(
752                "syncular-blob-upload-{}.sqlite",
753                uuid::Uuid::new_v4()
754            ));
755            let mut client = SyncClient::open_path(
756                "upload-test".into(),
757                &schema,
758                Default::default(),
759                path.to_str().unwrap(),
760            )
761            .unwrap();
762            let bytes = b"durable upload";
763            let ref_value = client
764                .upload_blob(bytes, Some("text/plain".into()), None)
765                .unwrap();
766            let commit = client
767                .mutate(vec![Mutation::Upsert {
768                    table: "attachments".into(),
769                    values: Map::from_iter([
770                        ("id".into(), Value::from("pending")),
771                        (
772                            "file".into(),
773                            Value::from(format!(
774                                "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
775                                blob_id_for(bytes),
776                                bytes.len()
777                            )),
778                        ),
779                    ]),
780                    base_version: None,
781                }])
782                .unwrap();
783            client
784                .conn
785                .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
786                .unwrap();
787            client.conn.execute_batch(match fault {
788                "missing" => "DELETE FROM _syncular_blobs",
789                "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
790                "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
791                "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
792                "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
793                "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
794                _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
795            }).unwrap();
796            let state = if fault == "read" {
797                Vec::new()
798            } else {
799                client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
800            };
801            let mut transport = CountingRealtimeTransport::default();
802            let error = client.flush_blob_uploads(&mut transport).unwrap_err();
803            assert_eq!(
804                error.code,
805                if matches!(fault, "read" | "pin-delete") {
806                    "client.failed"
807                } else {
808                    "sync.local_corrupt"
809                },
810                "{fault}"
811            );
812            assert_eq!(
813                transport.blob_uploads.len(),
814                usize::from(fault == "pin-delete"),
815                "{fault}: corrupt bytes must never be uploaded"
816            );
817            assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
818            if fault != "read" {
819                assert_eq!(
820                    client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
821                    state
822                );
823            } else {
824                client
825                    .conn
826                    .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
827                    .unwrap();
828            }
829            drop(client);
830            let mut reopened = SyncClient::open_path(
831                "upload-test".into(),
832                &schema,
833                Default::default(),
834                path.to_str().unwrap(),
835            )
836            .unwrap();
837            assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
838            if fault != "read" {
839                assert_eq!(
840                    reopened
841                        .query("SELECT * FROM _syncular_blobs", &[])
842                        .unwrap(),
843                    state
844                );
845            }
846            if fault == "pin-delete" {
847                reopened
848                    .conn
849                    .execute_batch("DROP TRIGGER fail_pin_delete")
850                    .unwrap();
851            }
852            if fault == "pin-metadata" {
853                reopened
854                    .conn
855                    .execute(
856                        "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
857                        [],
858                    )
859                    .unwrap();
860            }
861            reopened
862                .conn
863                .execute_batch(
864                    "DELETE FROM _syncular_blobs;
865                     INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
866                )
867                .unwrap();
868            transport.blob_uploads.clear();
869            reopened.flush_blob_uploads(&mut transport).unwrap();
870            assert_eq!(
871                transport.blob_uploads,
872                [(blob_id_for(bytes), bytes.to_vec())]
873            );
874            assert_eq!(
875                reopened
876                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
877                    .unwrap(),
878                Vec::<Map<String, Value>>::new()
879            );
880            assert_eq!(reopened.pending_commit_ids(), [commit]);
881            assert_eq!(
882                reopened
883                    .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
884                    .unwrap()
885                    .unwrap()
886                    .bytes,
887                bytes
888            );
889            drop(reopened);
890            std::fs::remove_file(path).unwrap();
891        }
892    }
893
894    /// §1.4 rule 4: `sections` subscriptions, each with `commits` COMMIT
895    /// frames of `rows` rows. Returns the client with its subscriptions
896    /// registered and the response.
897    fn coalesced_fixture(sections: usize, commits: usize, rows: usize) -> (SyncClient, Message) {
898        let mut client = client();
899        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
900        let mut response = Message {
901            wire_version: WIRE_VERSION,
902            msg_kind: MsgKind::Response,
903            frames: vec![Frame::RespHeader {
904                required_schema_version: None,
905                latest_schema_version: None,
906                log_epoch: Some("epoch-1".into()),
907                reset_required: Some(false),
908            }],
909        };
910        let mut seq = 0;
911        for section in 0..sections {
912            let project = format!("p{section}");
913            let scopes = vec![("project_id".to_owned(), vec![project.clone()])];
914            client
915                .subscribe(format!("s{section}"), "tasks".into(), scopes.clone(), None)
916                .unwrap();
917            let sub = client.subs.last_mut().unwrap();
918            sub.cursor = 0;
919            sub.synced_once = true;
920            sub.effective = Some(scopes.clone());
921            let sub = sub.clone();
922            client.persist_sub(&sub).unwrap();
923            response.frames.push(Frame::SubStart {
924                id: format!("s{section}"),
925                status: SubStatus::Active,
926                reason_code: "".into(),
927                effective_scopes: scopes,
928                bootstrap: false,
929            });
930            for _ in 0..commits {
931                seq += 1;
932                let changes = (0..rows)
933                    .map(|row| {
934                        let id = format!("{project}-{seq}-{row}");
935                        let values = Map::from_iter([
936                            ("id".into(), Value::from(id.clone())),
937                            ("project_id".into(), Value::from(project.clone())),
938                        ]);
939                        ssp2::model::Change {
940                            table_index: 0,
941                            row_id: id.clone(),
942                            op: Op::Upsert,
943                            row_version: Some(seq),
944                            scopes: vec![("project_id".into(), project.clone())],
945                            row: Some(
946                                encode_row_json(
947                                    client.schema.table("tasks").unwrap(),
948                                    &id,
949                                    &values,
950                                    &client.encryption,
951                                )
952                                .unwrap(),
953                            ),
954                        }
955                    })
956                    .collect();
957                response.frames.push(Frame::Commit {
958                    commit_seq: seq,
959                    actor_id: "writer".into(),
960                    created_at_ms: 1,
961                    tables: vec!["tasks".into()],
962                    changes,
963                });
964            }
965            response.frames.push(Frame::SubEnd {
966                next_cursor: seq,
967                bootstrap_state: None,
968            });
969        }
970        client.drain_change_batches();
971        (client, response)
972    }
973
974    fn count_commits(client: &SyncClient) -> std::sync::Arc<std::sync::atomic::AtomicUsize> {
975        let commits = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
976        let counter = commits.clone();
977        client
978            .conn
979            .commit_hook(Some(move || {
980                counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
981                false
982            }))
983            .expect("commit hook");
984        commits
985    }
986
987    #[test]
988    fn a_response_of_many_small_sections_commits_once() {
989        for realtime in [false, true] {
990            let (mut client, response) = coalesced_fixture(70, 1, 1);
991            client.realtime_state = RealtimeState::Connected;
992            client.realtime_reason_code = None;
993            let mut transport = CountingRealtimeTransport::default();
994            let (_, meta) = client.build_request(false).unwrap();
995            let commits = count_commits(&client);
996            if realtime {
997                client.on_realtime_binary(&mut transport, &encode_message(&response));
998            } else {
999                assert!(matches!(
1000                    client.process_response(&mut transport, response, &meta),
1001                    SyncOutcome::Ok(_)
1002                ));
1003            }
1004            assert_eq!(
1005                commits.load(std::sync::atomic::Ordering::SeqCst),
1006                1,
1007                "{realtime}"
1008            );
1009            assert!(client.conn.is_autocommit());
1010            assert!(client.subs.iter().all(|sub| sub.cursor > 0));
1011            // Every frame keeps its own revision, published in order.
1012            let revisions = client
1013                .drain_change_batches()
1014                .into_iter()
1015                .filter(|batch| !batch.tables.is_empty())
1016                .map(|batch| batch.revision)
1017                .collect::<Vec<_>>();
1018            assert_eq!(
1019                revisions,
1020                (1..=70).map(|r: u64| r.to_string()).collect::<Vec<_>>()
1021            );
1022            assert_eq!(client.query("SELECT id FROM tasks", &[]).unwrap().len(), 70);
1023            // The ack follows the COMMIT.
1024            assert_eq!(
1025                transport
1026                    .messages
1027                    .iter()
1028                    .filter(|m| m.contains("\"ack\""))
1029                    .count(),
1030                1,
1031                "{realtime}"
1032            );
1033        }
1034    }
1035
1036    #[test]
1037    fn a_response_past_the_row_bound_commits_per_bound() {
1038        // 3 frames of 600 rows: no two fit one 1,024-row transaction.
1039        let (mut client, response) = coalesced_fixture(1, 3, 600);
1040        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1041        let (_, meta) = client.build_request(false).unwrap();
1042        let commits = count_commits(&client);
1043        assert!(matches!(
1044            client.process_response(&mut transport, response, &meta),
1045            SyncOutcome::Ok(_)
1046        ));
1047        assert_eq!(commits.load(std::sync::atomic::Ordering::SeqCst), 3);
1048        assert_eq!(
1049            client.query("SELECT id FROM tasks", &[]).unwrap().len(),
1050            1800
1051        );
1052        assert_eq!(client.subs[0].cursor, 3);
1053    }
1054
1055    #[test]
1056    fn a_failed_coalesced_commit_restores_memory_and_publishes_nothing() {
1057        // Two sections of two frames of 600 rows: the bound commits the first
1058        // frame alone, so the deferred violation it carries fails that
1059        // mid-response COMMIT, and every later frame of the response fails.
1060        let (mut client, response) = coalesced_fixture(2, 2, 600);
1061        client
1062            .conn
1063            .execute_batch(
1064                "PRAGMA foreign_keys = ON; CREATE TABLE fail_parent(id INTEGER PRIMARY KEY); CREATE TABLE fail_child(id INTEGER REFERENCES fail_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_commit AFTER INSERT ON _syncular_base_tasks WHEN NEW.id = 'p0-1-0' BEGIN INSERT INTO fail_child VALUES(1); END",
1065            )
1066            .unwrap();
1067        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1068        let (_, meta) = client.build_request(false).unwrap();
1069        let subs = client.subs.clone();
1070        let revision = client.local_revision();
1071        let outcome = client.process_response(&mut transport, response.clone(), &meta);
1072        assert!(
1073            matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == "storage.failed"),
1074            "{outcome:?}"
1075        );
1076        assert!(client.conn.is_autocommit());
1077        assert!(client.coalesced.is_none() && client.coalesced_failure.is_none());
1078        assert_eq!(client.local_revision(), revision);
1079        assert!(client
1080            .drain_change_batches()
1081            .iter()
1082            .all(|batch| batch.tables.is_empty()));
1083        assert!(client
1084            .query("SELECT id FROM tasks", &[])
1085            .unwrap()
1086            .is_empty());
1087        assert_eq!(
1088            client.subs.iter().map(|s| s.cursor).collect::<Vec<_>>(),
1089            subs.iter().map(|s| s.cursor).collect::<Vec<_>>()
1090        );
1091        // The next response applies in full.
1092        client
1093            .conn
1094            .execute_batch("DROP TRIGGER fail_commit")
1095            .unwrap();
1096        let (_, meta) = client.build_request(false).unwrap();
1097        assert!(matches!(
1098            client.process_response(&mut transport, response, &meta),
1099            SyncOutcome::Ok(_)
1100        ));
1101        assert_eq!(
1102            client.query("SELECT id FROM tasks", &[]).unwrap().len(),
1103            2400
1104        );
1105    }
1106
1107    #[test]
1108    fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
1109        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1110            {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
1111            "scopes":[{"pattern":"project:{project_id}"}]}]});
1112        for realtime in [false, true] {
1113            for fault in [
1114                "none",
1115                "row",
1116                "revision",
1117                "commit",
1118                "cursor",
1119                "error",
1120                "missing-end",
1121            ] {
1122                if realtime && fault == "missing-end" {
1123                    continue;
1124                }
1125                let path = std::env::temp_dir()
1126                    .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
1127                let mut client = SyncClient::open_path(
1128                    "frame-test".into(),
1129                    &schema,
1130                    Default::default(),
1131                    path.to_str().unwrap(),
1132                )
1133                .unwrap();
1134                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1135                client
1136                    .subscribe(
1137                        "tasks".into(),
1138                        "tasks".into(),
1139                        vec![("project_id".into(), vec!["p1".into()])],
1140                        None,
1141                    )
1142                    .unwrap();
1143                client.subs[0].cursor = 0;
1144                client.subs[0].synced_once = true;
1145                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1146                client.persist_sub(&client.subs[0]).unwrap();
1147                client.realtime_state = RealtimeState::Connected;
1148                client.realtime_reason_code = None;
1149                client.drain_change_batches();
1150                let mut transport = CountingRealtimeTransport::default();
1151                let (_, meta) = client.build_request(false).unwrap();
1152                let mut response = Message {
1153                    wire_version: WIRE_VERSION,
1154                    msg_kind: MsgKind::Response,
1155                    frames: vec![
1156                        Frame::RespHeader {
1157                            required_schema_version: None,
1158                            latest_schema_version: None,
1159                            log_epoch: Some("epoch-1".into()),
1160                            reset_required: Some(false),
1161                        },
1162                        Frame::SubStart {
1163                            id: "tasks".into(),
1164                            status: SubStatus::Active,
1165                            reason_code: "".into(),
1166                            effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1167                            bootstrap: false,
1168                        },
1169                    ],
1170                };
1171                for (index, id) in ["first", "second"].iter().enumerate() {
1172                    let values = Map::from_iter([
1173                        ("id".into(), Value::from(*id)),
1174                        ("project_id".into(), Value::from("p1")),
1175                    ]);
1176                    response.frames.push(Frame::Commit {
1177                        commit_seq: index as i64 + 1,
1178                        actor_id: "writer".into(),
1179                        created_at_ms: 1,
1180                        tables: vec!["tasks".into()],
1181                        changes: vec![ssp2::model::Change {
1182                            table_index: 0,
1183                            row_id: (*id).into(),
1184                            op: Op::Upsert,
1185                            row_version: Some(index as i64 + 1),
1186                            scopes: vec![("project_id".into(), "p1".into())],
1187                            row: Some(
1188                                encode_row_json(
1189                                    client.schema.table("tasks").unwrap(),
1190                                    id,
1191                                    &values,
1192                                    &client.encryption,
1193                                )
1194                                .unwrap(),
1195                            ),
1196                        }],
1197                    });
1198                }
1199                response.frames.push(Frame::SubEnd {
1200                    next_cursor: 2,
1201                    bootstrap_state: None,
1202                });
1203                let valid = response.clone();
1204                match fault {
1205                    "row" => {
1206                        let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
1207                        let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
1208                    },
1209                    "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(),
1210                    "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(),
1211                    "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
1212                    "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});},
1213                    "missing-end" => {response.frames.truncate(3);},
1214                    _ => {}
1215                }
1216                if realtime {
1217                    client.on_realtime_binary(&mut transport, &encode_message(&response));
1218                } else {
1219                    assert_eq!(
1220                        matches!(
1221                            client.process_response(&mut transport, response, &meta),
1222                            SyncOutcome::Ok(_)
1223                        ),
1224                        fault == "none",
1225                        "{fault}"
1226                    );
1227                }
1228                assert!(
1229                    client.conn.is_autocommit(),
1230                    "no open transaction after {realtime}/{fault}"
1231                );
1232                // §1.4 rule 4: the deferred foreign key of the "commit" fault
1233                // fails the response's coalesced COMMIT, which rolls back both
1234                // frames; the other faults roll back only the failing frame.
1235                let count = match fault {
1236                    "none" | "cursor" => 2,
1237                    "commit" => 0,
1238                    _ => 1,
1239                };
1240                let expected_revision = count + u64::from(realtime && fault != "none");
1241                assert_eq!(
1242                    client.local_revision(),
1243                    expected_revision,
1244                    "{realtime}/{fault}"
1245                );
1246                let batches = client.drain_change_batches();
1247                assert_eq!(
1248                    batches.len(),
1249                    expected_revision as usize,
1250                    "{realtime}/{fault}"
1251                );
1252                let row_batches: Vec<_> = batches
1253                    .iter()
1254                    .filter(|batch| !batch.tables.is_empty())
1255                    .collect();
1256                assert_eq!(row_batches.len(), count as usize);
1257                assert_eq!(
1258                    row_batches
1259                        .iter()
1260                        .map(|b| b.revision.clone())
1261                        .collect::<Vec<_>>(),
1262                    (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
1263                );
1264                for table in ["tasks", "_syncular_base_tasks"] {
1265                    assert_eq!(
1266                        client
1267                            .conn
1268                            .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
1269                                .get::<_, u64>(0))
1270                            .unwrap(),
1271                        count,
1272                        "{realtime}/{fault}/{table}"
1273                    );
1274                }
1275                assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1276                assert_eq!(
1277                    transport
1278                        .messages
1279                        .iter()
1280                        .filter(|m| m.contains("\"ack\""))
1281                        .count(),
1282                    usize::from(fault == "none")
1283                );
1284                if matches!(fault, "revision" | "commit" | "cursor") {
1285                    client
1286                        .conn
1287                        .execute_batch("DROP TRIGGER fail_frame")
1288                        .unwrap();
1289                }
1290                drop(client);
1291                let mut reopened = SyncClient::open_path(
1292                    "frame-test".into(),
1293                    &schema,
1294                    Default::default(),
1295                    path.to_str().unwrap(),
1296                )
1297                .unwrap();
1298                assert_eq!(reopened.local_revision(), expected_revision);
1299                assert_eq!(
1300                    reopened
1301                        .query("SELECT id FROM tasks ORDER BY id", &[])
1302                        .unwrap()
1303                        .len(),
1304                    count as usize
1305                );
1306                assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1307                let (_, meta) = reopened.build_request(false).unwrap();
1308                assert!(matches!(
1309                    reopened.process_response(&mut transport, valid, &meta),
1310                    SyncOutcome::Ok(_)
1311                ));
1312                assert_eq!(
1313                    reopened
1314                        .query("SELECT id FROM tasks ORDER BY id", &[])
1315                        .unwrap()
1316                        .len(),
1317                    2
1318                );
1319                assert_eq!(reopened.subs[0].cursor, 2);
1320                drop(reopened);
1321                std::fs::remove_file(path).unwrap();
1322            }
1323        }
1324    }
1325
1326    #[test]
1327    fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
1328        for status in [SubStatus::Reset, SubStatus::Revoked] {
1329            for fault in ["subscription", "revision"] {
1330                let mut client = client();
1331                client
1332                    .subscribe(
1333                        "tasks".into(),
1334                        "tasks".into(),
1335                        vec![("project_id".into(), vec!["p1".into()])],
1336                        None,
1337                    )
1338                    .unwrap();
1339                client.subs[0].cursor = 41;
1340                client.subs[0].synced_once = true;
1341                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1342                client.persist_sub(&client.subs[0]).unwrap();
1343                client
1344                    .mutate(vec![Mutation::Upsert {
1345                        table: "tasks".into(),
1346                        values: Map::from_iter([
1347                            ("id".into(), Value::from("pending")),
1348                            ("project_id".into(), Value::from("p1")),
1349                        ]),
1350                        base_version: None,
1351                    }])
1352                    .unwrap();
1353                let ids = client.pending_commit_ids();
1354                let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
1355                let revision = client.local_revision();
1356                client.drain_change_batches();
1357                let (_, meta) = client.build_request(false).unwrap();
1358                client.conn.execute_batch(if fault == "subscription" {
1359                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
1360                } else {
1361                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
1362                }).unwrap();
1363                let mut transport = CountingRealtimeTransport::default();
1364                let mut report = SyncReport::default();
1365                // Reset changes window completeness and therefore needs a revision.
1366                if status == SubStatus::Reset {
1367                    client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
1368                }
1369                let result = client.process_section(
1370                    &mut transport,
1371                    "tasks",
1372                    status,
1373                    "sync.scope_revoked",
1374                    vec![],
1375                    vec![],
1376                    Some((42, None)),
1377                    &meta,
1378                    &mut report,
1379                );
1380                assert!(result.is_err(), "{status:?}/{fault}");
1381                assert!(client.conn.is_autocommit());
1382                assert_eq!(client.subs[0].cursor, 41);
1383                assert_eq!(client.subs[0].state, SubState::Active);
1384                assert_eq!(client.pending_commit_ids(), ids);
1385                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
1386                assert!(client.rejections.is_empty());
1387                assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
1388                assert_eq!(client.local_revision(), revision);
1389                assert!(client.drain_change_batches().is_empty());
1390                assert!(report.revoked.is_empty() && report.resets.is_empty());
1391            }
1392        }
1393    }
1394
1395    #[test]
1396    fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
1397        let mut client = client();
1398        client
1399            .subscribe(
1400                "tasks".into(),
1401                "tasks".into(),
1402                vec![("project_id".into(), vec!["p1".into()])],
1403                None,
1404            )
1405            .unwrap();
1406        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1407        client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
1408            CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
1409        let row = |id: &str| ssp2::segment::SegmentRow {
1410            server_version: 1,
1411            values: vec![
1412                Some(ColumnValue::String(id.into())),
1413                Some(ColumnValue::String("p1".into())),
1414            ],
1415        };
1416        let segment = RowsSegment {
1417            table: "tasks".into(),
1418            schema_version: 1,
1419            columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
1420            blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
1421        };
1422        assert!(client.apply_segment(0, &segment, true).is_err());
1423        assert!(client.conn.is_autocommit());
1424        assert_eq!(
1425            client
1426                .query("SELECT id FROM tasks ORDER BY id", &[])
1427                .unwrap(),
1428            vec![Map::from_iter([("id".into(), Value::from("first"))])]
1429        );
1430        assert_eq!(client.local_revision(), 1);
1431        assert_eq!(client.drain_change_batches().len(), 1);
1432        assert_eq!(client.subs[0].cursor, -1);
1433        client
1434            .conn
1435            .execute_batch("DROP TRIGGER fail_block")
1436            .unwrap();
1437        assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
1438        assert_eq!(client.local_revision(), 3);
1439        assert_eq!(client.drain_change_batches().len(), 2);
1440    }
1441
1442    #[test]
1443    fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
1444        assert_eq!(
1445            client().diagnostics_storage().blob_cache_bytes_approx,
1446            Some(0)
1447        );
1448        let client = SyncClient::new(
1449            "diagnostic-storage".to_owned(),
1450            &json!({
1451                "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
1452                    "columns": [{"name": "id", "type": "string", "nullable": false},
1453                        {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
1454            }),
1455            ClientLimits {
1456                blob_cache_max_bytes: Some(5),
1457                ..Default::default()
1458            },
1459        )
1460        .unwrap();
1461        let empty = client.diagnostics_storage();
1462        assert_eq!(empty.status, "healthy");
1463        assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
1464        assert_eq!(empty.retained_outcome_entries, Some(0));
1465        assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1466        assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1467        client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1468            INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1469                VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1470            INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1471                VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1472        let written = client.diagnostics_storage();
1473        assert_eq!(written.status, "pressure");
1474        assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1475        assert_eq!(written.retained_outcome_entries, Some(2));
1476        assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1477        assert_eq!(written.blob_cache_bytes_approx, Some(8));
1478        assert_eq!(
1479            written.pressure_reason_code.as_deref(),
1480            Some("client.blob_cache_over_limit")
1481        );
1482        client
1483            .conn
1484            .execute_batch(
1485                "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1486            DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1487            )
1488            .unwrap();
1489        let staged = client.diagnostics_storage();
1490        assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1491        assert_eq!(staged.retained_outcome_entries, Some(0));
1492        assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1493        assert_eq!(staged.status, "healthy");
1494        client.conn.execute_batch("ROLLBACK").unwrap();
1495        assert_eq!(client.diagnostics_storage(), written);
1496        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1497        let unreadable = client.diagnostics_storage();
1498        assert_eq!(unreadable.status, "unreadable");
1499        assert!(unreadable.database_bytes_approx.is_none());
1500        assert!(unreadable.pending_outbox_bytes_approx.is_none());
1501        assert!(unreadable.retained_outcome_entries.is_none());
1502        assert!(unreadable.retained_outcome_bytes_approx.is_none());
1503        assert!(unreadable.blob_cache_bytes_approx.is_none());
1504        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1505        let restored = client.diagnostics_storage();
1506        assert_eq!(restored.status, written.status);
1507        assert_eq!(
1508            restored.retained_outcome_bytes_approx,
1509            written.retained_outcome_bytes_approx
1510        );
1511        client.conn.set_prepared_statement_cache_capacity(0);
1512        assert_eq!(client.diagnostics_storage(), restored);
1513    }
1514
1515    /// Answers every round with one server `ERROR` frame (§1.6).
1516    struct ServerErrorTransport {
1517        code: &'static str,
1518        retryable: bool,
1519    }
1520
1521    impl Transport for ServerErrorTransport {
1522        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1523            Ok(encode_message(&Message {
1524                wire_version: WIRE_VERSION,
1525                msg_kind: MsgKind::Response,
1526                frames: vec![
1527                    Frame::RespHeader {
1528                        required_schema_version: None,
1529                        latest_schema_version: None,
1530                        log_epoch: Some("epoch-1".to_owned()),
1531                        reset_required: Some(false),
1532                    },
1533                    Frame::Error {
1534                        code: self.code.to_owned(),
1535                        message: "server error".to_owned(),
1536                        category: "internal".to_owned(),
1537                        retryable: self.retryable,
1538                        recommended_action: "retryLater".to_owned(),
1539                        details: None,
1540                    },
1541                ],
1542            }))
1543        }
1544
1545        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1546            self.sync(request)
1547        }
1548
1549        fn download_segment(
1550            &mut self,
1551            _request: &SegmentRequest,
1552            _on_progress: &mut dyn FnMut(u64),
1553        ) -> Result<Vec<u8>, TransportError> {
1554            Err(TransportError::new("sync.transport_failed", "offline"))
1555        }
1556
1557        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1558            Ok(())
1559        }
1560
1561        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1562            Ok(())
1563        }
1564
1565        fn realtime_close(&mut self) -> Result<(), TransportError> {
1566            Ok(())
1567        }
1568    }
1569
1570    #[test]
1571    fn retryable_server_error_schedules_a_background_retry() {
1572        let mut client = client();
1573        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1574        let mut transport = ServerErrorTransport {
1575            code: "sync.internal_error",
1576            retryable: true,
1577        };
1578        assert!(matches!(
1579            client.sync(&mut transport),
1580            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1581        ));
1582        assert!(matches!(
1583            client.drain_sync_intents().as_slice(),
1584            [SyncIntent::Background { delay_ms: 250 }]
1585        ));
1586        assert_eq!(
1587            client.progress().snapshot().unwrap().retry_delay_ms,
1588            Some(250)
1589        );
1590        let mut transport = ServerErrorTransport {
1591            code: "sync.invalid_request",
1592            retryable: false,
1593        };
1594        assert!(matches!(
1595            client.sync(&mut transport),
1596            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1597        ));
1598        assert!(client.drain_sync_intents().is_empty());
1599        let progress = client.progress().snapshot().unwrap();
1600        assert_eq!(progress.state, ProgressState::Failed);
1601        assert_eq!(progress.retry_delay_ms, None);
1602    }
1603
1604    #[test]
1605    fn sync_until_idle_reports_budget_exhaustion_or_failure() {
1606        // §7.7: the exhausted outcome is a partial success, not a failure,
1607        // and serializes with an explicit flag.
1608        let exhausted = SyncOutcome::BudgetExhausted(SyncReport::default()).to_json();
1609        assert_eq!(exhausted["ok"], json!(true));
1610        assert_eq!(exhausted["budgetExhausted"], json!(true));
1611        assert!(exhausted["report"].is_object());
1612
1613        let mut client = client();
1614        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1615        // Zero explicit budget is a caller error, not an exhaustion.
1616        let mut idle_transport = HostTransport::new_from_config(&json!({})).unwrap();
1617        let zero = client.sync_until_idle(&mut idle_transport, Some(0));
1618        assert!(
1619            matches!(zero, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"),
1620            "{zero:?}"
1621        );
1622        // A real server failure stays a failure across the budget loop.
1623        let mut erroring = ServerErrorTransport {
1624            code: "sync.internal_error",
1625            retryable: false,
1626        };
1627        let failed = client.sync_until_idle(&mut erroring, Some(3));
1628        assert!(
1629            matches!(failed, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"),
1630            "{failed:?}"
1631        );
1632    }
1633
1634    /// Returns one pre-built response per round so a test can pin a successful
1635    /// work-bearing round followed by a failure or a schema floor.
1636    struct ScriptedSyncTransport {
1637        responses: std::collections::VecDeque<Message>,
1638        calls: usize,
1639    }
1640
1641    impl Transport for ScriptedSyncTransport {
1642        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1643            self.calls += 1;
1644            let response = self.responses.pop_front().expect("scripted response");
1645            Ok(encode_message(&response))
1646        }
1647
1648        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1649            self.sync(request)
1650        }
1651
1652        fn download_segment(
1653            &mut self,
1654            _request: &SegmentRequest,
1655            _on_progress: &mut dyn FnMut(u64),
1656        ) -> Result<Vec<u8>, TransportError> {
1657            Err(TransportError::new("sync.transport_failed", "offline"))
1658        }
1659
1660        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1661            Ok(())
1662        }
1663
1664        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1665            Ok(())
1666        }
1667
1668        fn realtime_close(&mut self) -> Result<(), TransportError> {
1669            Ok(())
1670        }
1671    }
1672
1673    #[test]
1674    fn sync_until_idle_success_then_failure_keeps_earlier_work() {
1675        let path = std::env::temp_dir().join(format!(
1676            "syncular-budget-script-{}.sqlite",
1677            uuid::Uuid::new_v4()
1678        ));
1679        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1680            {"name":"id","type":"string","nullable":false},
1681            {"name":"project_id","type":"string","nullable":false}],
1682            "scopes":[{"pattern":"project:{project_id}"}]}]});
1683        let mut client = SyncClient::open_path(
1684            "budget-script".into(),
1685            &schema,
1686            ClientLimits::default(),
1687            path.to_str().unwrap(),
1688        )
1689        .expect("open client");
1690        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1691        client
1692            .subscribe(
1693                "tasks".into(),
1694                "tasks".into(),
1695                vec![("project_id".into(), vec!["p1".into()])],
1696                None,
1697            )
1698            .unwrap();
1699        client.subs[0].cursor = 0;
1700        client.subs[0].synced_once = true;
1701        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1702        client.persist_sub(&client.subs[0]).unwrap();
1703        client.drain_change_batches();
1704
1705        let row = encode_row_json(
1706            client.schema.table("tasks").unwrap(),
1707            "kept",
1708            &Map::from_iter([
1709                ("id".into(), json!("kept")),
1710                ("project_id".into(), json!("p1")),
1711            ]),
1712            &client.encryption,
1713        )
1714        .unwrap();
1715        let success = Message {
1716            wire_version: WIRE_VERSION,
1717            msg_kind: MsgKind::Response,
1718            frames: vec![
1719                Frame::RespHeader {
1720                    required_schema_version: None,
1721                    latest_schema_version: None,
1722                    log_epoch: Some("epoch-1".into()),
1723                    reset_required: Some(false),
1724                },
1725                Frame::SubStart {
1726                    id: "tasks".into(),
1727                    status: SubStatus::Active,
1728                    reason_code: String::new(),
1729                    effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1730                    bootstrap: false,
1731                },
1732                Frame::Commit {
1733                    commit_seq: 1,
1734                    created_at_ms: 1,
1735                    actor_id: "writer".into(),
1736                    tables: vec!["tasks".into()],
1737                    changes: vec![ssp2::model::Change {
1738                        table_index: 0,
1739                        row_id: "kept".into(),
1740                        op: Op::Upsert,
1741                        row_version: Some(1),
1742                        scopes: vec![("project_id".into(), "p1".into())],
1743                        row: Some(row),
1744                    }],
1745                },
1746                Frame::SubEnd {
1747                    next_cursor: 1,
1748                    bootstrap_state: None,
1749                },
1750            ],
1751        };
1752        let failure = Message {
1753            wire_version: WIRE_VERSION,
1754            msg_kind: MsgKind::Response,
1755            frames: vec![
1756                Frame::RespHeader {
1757                    required_schema_version: None,
1758                    latest_schema_version: None,
1759                    log_epoch: Some("epoch-1".into()),
1760                    reset_required: Some(false),
1761                },
1762                Frame::Error {
1763                    code: "sync.internal_error".into(),
1764                    message: "second round failed".into(),
1765                    category: "internal".into(),
1766                    retryable: false,
1767                    recommended_action: "retryLater".into(),
1768                    details: None,
1769                },
1770            ],
1771        };
1772        let mut transport = ScriptedSyncTransport {
1773            responses: std::collections::VecDeque::from([success, failure]),
1774            calls: 0,
1775        };
1776        let outcome = client.sync_until_idle(&mut transport, Some(2));
1777        assert!(
1778            matches!(outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"),
1779            "{outcome:?}"
1780        );
1781        assert_eq!(
1782            transport.calls, 2,
1783            "one successful round then the failing round"
1784        );
1785        assert_eq!(
1786            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
1787            json!("kept"),
1788            "the earlier round's durable work survives the later failure"
1789        );
1790        drop(client);
1791        let _ = std::fs::remove_file(&path);
1792    }
1793
1794    #[test]
1795    fn sync_until_idle_stops_on_a_schema_floor() {
1796        let mut client = client();
1797        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1798        let floor = Message {
1799            wire_version: WIRE_VERSION,
1800            msg_kind: MsgKind::Response,
1801            frames: vec![Frame::RespHeader {
1802                required_schema_version: Some(2),
1803                latest_schema_version: Some(2),
1804                log_epoch: Some("epoch-1".into()),
1805                reset_required: Some(false),
1806            }],
1807        };
1808        let mut transport = ScriptedSyncTransport {
1809            responses: std::collections::VecDeque::from([floor.clone(), floor]),
1810            calls: 0,
1811        };
1812        let outcome = client.sync_until_idle(&mut transport, Some(3));
1813        match outcome {
1814            SyncOutcome::Ok(report) => assert_eq!(
1815                report
1816                    .schema_floor
1817                    .as_ref()
1818                    .and_then(|floor| floor.required_schema_version),
1819                Some(2),
1820                "the stop retains the latest schema floor"
1821            ),
1822            other => panic!("expected a schema-floor stop, got {other:?}"),
1823        }
1824        assert_eq!(
1825            transport.calls, 1,
1826            "a schema floor stops the budget loop after one round"
1827        );
1828    }
1829
1830    #[test]
1831    fn sync_report_merge_schema_floor_is_latest_state() {
1832        let mut aggregate = SyncReport {
1833            schema_floor: Some(SchemaFloor {
1834                required_schema_version: Some(2),
1835                latest_schema_version: Some(2),
1836            }),
1837            ..SyncReport::default()
1838        };
1839        aggregate.merge(&SyncReport::default());
1840        assert!(
1841            aggregate.schema_floor.is_none(),
1842            "a later round without a floor clears it"
1843        );
1844    }
1845
1846    #[test]
1847    fn push_capacity_limits_are_hard_and_keep_intent() {
1848        // The configured operation cap is hard for the first commit.
1849        let mut c = client();
1850        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1851        c.limits.max_push_operations_per_request = Some(1);
1852        let id = c
1853            .mutate(vec![
1854                Mutation::Upsert {
1855                    table: "tasks".into(),
1856                    values: Map::from_iter([
1857                        ("id".into(), json!("a")),
1858                        ("project_id".into(), json!("p1")),
1859                    ]),
1860                    base_version: None,
1861                },
1862                Mutation::Upsert {
1863                    table: "tasks".into(),
1864                    values: Map::from_iter([
1865                        ("id".into(), json!("b")),
1866                        ("project_id".into(), json!("p1")),
1867                    ]),
1868                    base_version: None,
1869                },
1870            ])
1871            .unwrap();
1872        let error = c.build_request(false).unwrap_err();
1873        let SyncOutcome::Failed {
1874            error_code,
1875            details,
1876            ..
1877        } = *error
1878        else {
1879            panic!("expected a capacity failure");
1880        };
1881        assert_eq!(error_code, "client.push_request_too_large");
1882        let details = details.expect("capacity details");
1883        assert_eq!(details["kind"], json!("operations"));
1884        assert_eq!(details["limit"], json!(1));
1885        assert_eq!(details["size"], json!(2));
1886        assert_eq!(details["clientCommitId"], json!(id));
1887        assert_eq!(c.pending_commit_ids(), vec![id]);
1888
1889        // The commit-count cap defers whole later commits.
1890        let mut c = client();
1891        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1892        c.limits.max_push_commits_per_request = Some(1);
1893        let first = c
1894            .mutate(vec![Mutation::Upsert {
1895                table: "tasks".into(),
1896                values: Map::from_iter([
1897                    ("id".into(), json!("one")),
1898                    ("project_id".into(), json!("p1")),
1899                ]),
1900                base_version: None,
1901            }])
1902            .unwrap();
1903        let second = c
1904            .mutate(vec![Mutation::Upsert {
1905                table: "tasks".into(),
1906                values: Map::from_iter([
1907                    ("id".into(), json!("two")),
1908                    ("project_id".into(), json!("p1")),
1909                ]),
1910                base_version: None,
1911            }])
1912            .unwrap();
1913        let (_, meta) = c.build_request(false).unwrap();
1914        assert_eq!(meta.pushed_ids, vec![first.clone()]);
1915        assert_eq!(meta.deferred_commits, 1);
1916        assert_eq!(c.pending_commit_ids(), vec![first, second]);
1917    }
1918
1919    #[test]
1920    fn push_request_byte_cap_defers_the_prefix_boundary_and_errors_the_head() {
1921        // Two small commits: a cap just below the full request defers the
1922        // last commit and sends the fitting prefix (header + first commit).
1923        let mut c = client();
1924        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1925        let first = c
1926            .mutate(vec![Mutation::Upsert {
1927                table: "tasks".into(),
1928                values: Map::from_iter([
1929                    ("id".into(), json!("one")),
1930                    ("project_id".into(), json!("p1")),
1931                ]),
1932                base_version: None,
1933            }])
1934            .unwrap();
1935        let second = c
1936            .mutate(vec![Mutation::Upsert {
1937                table: "tasks".into(),
1938                values: Map::from_iter([
1939                    ("id".into(), json!("two")),
1940                    ("project_id".into(), json!("p1")),
1941                ]),
1942                base_version: None,
1943            }])
1944            .unwrap();
1945        let (message, meta) = c.build_request(false).unwrap();
1946        assert_eq!(meta.pushed_ids.len(), 2);
1947        let full = encode_message(&message).len();
1948        c.limits.max_push_request_bytes = Some(full - 1);
1949        let (_, meta) = c.build_request(false).unwrap();
1950        assert_eq!(meta.pushed_ids, vec![first.clone()]);
1951        assert_eq!(meta.deferred_commits, 1);
1952        assert_eq!(c.pending_commit_ids(), vec![first, second]);
1953
1954        // A single commit larger than the budget is a typed capacity error and
1955        // stays queued, reporting the full projected request size.
1956        let mut c = client();
1957        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1958        let huge = c
1959            .mutate(vec![Mutation::Upsert {
1960                table: "tasks".into(),
1961                values: Map::from_iter([
1962                    ("id".into(), json!("x".repeat(20_000))),
1963                    ("project_id".into(), json!("p1")),
1964                ]),
1965                base_version: None,
1966            }])
1967            .unwrap();
1968        let (message, meta) = c.build_request(false).unwrap();
1969        assert_eq!(meta.pushed_ids, vec![huge.clone()]);
1970        let with_huge = encode_message(&message).len();
1971        c.limits.max_push_request_bytes = Some(with_huge - 1);
1972        let error = c.build_request(false).unwrap_err();
1973        let SyncOutcome::Failed {
1974            error_code,
1975            details,
1976            ..
1977        } = *error
1978        else {
1979            panic!("expected a capacity failure");
1980        };
1981        assert_eq!(error_code, "client.push_request_too_large");
1982        let details = details.expect("capacity details");
1983        assert_eq!(details["kind"], json!("bytes"));
1984        assert_eq!(details["limit"], json!((with_huge - 1) as u64));
1985        assert_eq!(details["size"], json!(with_huge as u64));
1986        assert_eq!(details["clientCommitId"], json!(huge));
1987        assert_eq!(c.pending_commit_ids(), vec![huge]);
1988
1989        // A cap smaller than the fixed frames reports no blocked commit.
1990        let mut c = client();
1991        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1992        c.limits.max_push_request_bytes = Some(1);
1993        let error = c.build_request(false).unwrap_err();
1994        let SyncOutcome::Failed { details, .. } = *error else {
1995            panic!("expected a capacity failure");
1996        };
1997        let details = details.expect("capacity details");
1998        assert_eq!(details["kind"], json!("bytes"));
1999        assert!(details.get("clientCommitId").is_none(), "{details}");
2000    }
2001
2002    #[test]
2003    fn push_limits_validate_at_construction() {
2004        for limits in [
2005            ClientLimits {
2006                max_push_commits_per_request: Some(0),
2007                ..Default::default()
2008            },
2009            ClientLimits {
2010                max_push_operations_per_request: Some(0),
2011                ..Default::default()
2012            },
2013            ClientLimits {
2014                max_push_request_bytes: Some(0),
2015                ..Default::default()
2016            },
2017            ClientLimits {
2018                max_push_request_bytes: Some(u32::MAX as usize + 1),
2019                ..Default::default()
2020            },
2021        ] {
2022            let schema = json!({"version":1,"tables":[]});
2023            let error = SyncClient::with_connection(
2024                "bad-limits".into(),
2025                &schema,
2026                limits,
2027                Connection::open_in_memory().unwrap(),
2028            )
2029            .err();
2030            assert_eq!(
2031                error.as_deref(),
2032                Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2033            );
2034        }
2035    }
2036
2037    #[test]
2038    fn open_path_rejects_invalid_push_limits_before_storage() {
2039        for identity in [false, true] {
2040            let path = std::env::temp_dir().join(format!(
2041                "syncular-bad-limits-{}.sqlite",
2042                uuid::Uuid::new_v4()
2043            ));
2044            let schema = json!({"version":1,"tables":[]});
2045            let limits = ClientLimits {
2046                max_push_request_bytes: Some(0),
2047                ..Default::default()
2048            };
2049            let error = if identity {
2050                SyncClient::open_path_with_identity(None, &schema, limits, path.to_str().unwrap())
2051            } else {
2052                SyncClient::open_path("bad-limits".into(), &schema, limits, path.to_str().unwrap())
2053            }
2054            .err();
2055            assert_eq!(
2056                error.as_deref(),
2057                Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2058            );
2059            assert!(
2060                !path.exists(),
2061                "an invalid limit is rejected before the database file is created"
2062            );
2063        }
2064    }
2065
2066    #[test]
2067    fn failed_progress_retry_delay_doubles_to_the_cap() {
2068        let mut client = client();
2069        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2070        let mut transport = ServerErrorTransport {
2071            code: "sync.internal_error",
2072            retryable: true,
2073        };
2074        let delays: Vec<Option<u64>> = (0..9)
2075            .map(|_| {
2076                client.sync(&mut transport);
2077                client.progress().snapshot().unwrap().retry_delay_ms
2078            })
2079            .collect();
2080        assert_eq!(
2081            delays,
2082            [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
2083        );
2084    }
2085
2086    #[test]
2087    fn background_retry_deadlines_back_off_and_reset() {
2088        let mut client = client();
2089        client.schedule_background_retry();
2090        assert!(matches!(
2091            client.drain_sync_intents().as_slice(),
2092            [SyncIntent::Background { delay_ms: 250 }]
2093        ));
2094        client.schedule_background_retry();
2095        assert!(matches!(
2096            client.drain_sync_intents().as_slice(),
2097            [SyncIntent::Background { delay_ms: 500 }]
2098        ));
2099        client.reset_background_retry();
2100        client.schedule_background_retry();
2101        assert!(matches!(
2102            client.drain_sync_intents().as_slice(),
2103            [SyncIntent::Background { delay_ms: 250 }]
2104        ));
2105    }
2106
2107    #[test]
2108    fn log_epoch_reset_requests_an_immediate_follow_up_round() {
2109        let mut client = client();
2110        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2111        client
2112            .subscribe(
2113                "epoch-tasks".to_owned(),
2114                "tasks".to_owned(),
2115                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2116                None,
2117            )
2118            .expect("subscribe");
2119        client.drain_sync_intents();
2120
2121        client
2122            .run_log_epoch_reset("epoch-2")
2123            .expect("reset partition log epoch");
2124
2125        assert!(matches!(
2126            client.drain_sync_intents().as_slice(),
2127            [SyncIntent::Interactive]
2128        ));
2129    }
2130
2131    #[derive(Default)]
2132    struct CountingRealtimeTransport {
2133        connects: usize,
2134        closes: usize,
2135        messages: Vec<String>,
2136        blob_uploads: Vec<(String, Vec<u8>)>,
2137        blob_download: Option<Vec<u8>>,
2138        sync_calls: usize,
2139        realtime_sync_calls: usize,
2140        refuse_connect: bool,
2141    }
2142
2143    impl Transport for CountingRealtimeTransport {
2144        fn blob_upload(
2145            &mut self,
2146            blob_id: &str,
2147            bytes: &[u8],
2148            _media_type: Option<&str>,
2149        ) -> Result<(), TransportError> {
2150            self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
2151            Ok(())
2152        }
2153
2154        fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
2155            self.blob_download
2156                .clone()
2157                .map(BlobDownload::Bytes)
2158                .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
2159        }
2160
2161        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2162            self.sync_calls += 1;
2163            Err(TransportError::new("sync.transport_failed", "offline"))
2164        }
2165
2166        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2167            self.realtime_sync_calls += 1;
2168            Err(TransportError::new("sync.transport_failed", "offline"))
2169        }
2170
2171        fn download_segment(
2172            &mut self,
2173            _request: &SegmentRequest,
2174            _on_progress: &mut dyn FnMut(u64),
2175        ) -> Result<Vec<u8>, TransportError> {
2176            Err(TransportError::new("sync.transport_failed", "offline"))
2177        }
2178
2179        fn realtime_connect(&mut self) -> Result<(), TransportError> {
2180            self.connects += 1;
2181            if self.refuse_connect {
2182                return Err(TransportError::new("transport.failed", "refused"));
2183            }
2184            Ok(())
2185        }
2186
2187        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
2188            self.messages.push(text.to_owned());
2189            Ok(())
2190        }
2191
2192        fn realtime_close(&mut self) -> Result<(), TransportError> {
2193            self.closes += 1;
2194            Ok(())
2195        }
2196    }
2197
2198    #[test]
2199    fn blob_results_own_downloaded_bytes() {
2200        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
2201            {"name":"id","type":"string","nullable":false},
2202            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
2203        let bytes = b"owned typed result".to_vec();
2204        let blob_id = blob_id_for(&bytes);
2205        let mut transport = CountingRealtimeTransport {
2206            blob_download: Some(bytes.clone()),
2207            ..Default::default()
2208        };
2209        let result = {
2210            let mut client =
2211                SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
2212                    .unwrap();
2213            let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
2214            assert_eq!(result.blob_id, blob_id);
2215            assert_eq!(result.byte_length, bytes.len() as i64);
2216            assert_eq!(result.bytes, bytes);
2217            assert_eq!(result.media_type, None);
2218            assert_eq!(
2219                client
2220                    .conn
2221                    .query_row(
2222                        "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
2223                        rusqlite::params![blob_id],
2224                        |row| row.get::<_, i64>(0),
2225                    )
2226                    .unwrap(),
2227                0
2228            );
2229            result
2230        };
2231        assert_eq!(result.bytes, bytes);
2232
2233        let mut client =
2234            SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
2235        assert_eq!(
2236            client
2237                .fetch_blob_bytes(&mut transport, "not json")
2238                .unwrap_err(),
2239            (
2240                "client.failed".to_owned(),
2241                "blob ref is not JSON".to_owned()
2242            )
2243        );
2244    }
2245
2246    #[test]
2247    fn visible_blob_references_survive_cache_trimming() {
2248        let schema = json!({"version":1,"tables":[
2249            {"name":"attachments","primaryKey":"id","columns":[
2250                {"name":"id","type":"string","nullable":false},
2251                {"name":"body","type":"blob_ref","nullable":true},
2252                {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
2253            {"name":"avatars","primaryKey":"id","columns":[
2254                {"name":"id","type":"string","nullable":false},
2255                {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
2256        ]});
2257        let bytes = b"referenced above cap".to_vec();
2258        let blob_id = blob_id_for(&bytes);
2259        let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
2260        let mut client = SyncClient::new(
2261            "visible-blob-refs".to_owned(),
2262            &schema,
2263            ClientLimits {
2264                blob_cache_max_bytes: Some(1),
2265                ..Default::default()
2266            },
2267        )
2268        .unwrap();
2269        client.conn.execute(
2270            "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
2271            rusqlite::params![reference, reference, reference, reference],
2272        ).unwrap();
2273        client
2274            .conn
2275            .execute(
2276                "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
2277                rusqlite::params![reference],
2278            )
2279            .unwrap();
2280        client.conn.execute(
2281            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
2282            [],
2283        ).unwrap();
2284        let mut transport = CountingRealtimeTransport {
2285            blob_download: Some(bytes.clone()),
2286            ..Default::default()
2287        };
2288
2289        assert_eq!(
2290            client
2291                .fetch_blob_bytes(&mut transport, &blob_id)
2292                .unwrap()
2293                .bytes,
2294            bytes
2295        );
2296        let rows = client
2297            .conn
2298            .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
2299            .unwrap()
2300            .query_map([], |row| row.get::<_, String>(0))
2301            .unwrap()
2302            .collect::<Result<Vec<_>, _>>()
2303            .unwrap();
2304        assert_eq!(rows, vec![blob_id]);
2305    }
2306
2307    #[test]
2308    fn closed_transport_never_invokes_network_and_reopen_is_host_owned() {
2309        let mut transport = CountingRealtimeTransport::default();
2310        let path = std::env::temp_dir().join(format!(
2311            "syncular-transport-gate-{}.db",
2312            uuid::Uuid::new_v4()
2313        ));
2314        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
2315            {"name":"id","type":"string","nullable":false},
2316            {"name":"project_id","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
2317        let mut client = SyncClient::open_path(
2318            "gate".into(),
2319            &schema,
2320            ClientLimits::default(),
2321            path.to_str().unwrap(),
2322        )
2323        .unwrap();
2324        client.connect_realtime(&mut transport).unwrap();
2325        client.set_transport_enabled(&mut transport, false);
2326        assert_eq!(transport.closes, 1);
2327        client.begin_security_preflight();
2328        client.activate_security(Default::default()).unwrap();
2329        client
2330            .subscribe(
2331                "tasks".into(),
2332                "tasks".into(),
2333                vec![("project_id".into(), vec!["p1".into()])],
2334                None,
2335            )
2336            .unwrap();
2337        for id in ["one", "two"] {
2338            client
2339                .mutate(vec![Mutation::Upsert {
2340                    table: "tasks".into(),
2341                    values: Map::from_iter([
2342                        ("id".into(), json!(id)),
2343                        ("project_id".into(), json!("p1")),
2344                    ]),
2345                    base_version: None,
2346                }])
2347                .unwrap();
2348        }
2349        assert_eq!(
2350            client
2351                .query("SELECT id FROM tasks ORDER BY id", &[])
2352                .unwrap()
2353                .len(),
2354            2
2355        );
2356        assert!(
2357            matches!(client.sync(&mut transport), SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
2358        );
2359        assert!(client
2360            .connect_realtime(&mut transport)
2361            .unwrap_err()
2362            .starts_with("sync.offline:"));
2363        assert!(client
2364            .set_presence(&mut transport, "project:p1", None)
2365            .unwrap_err()
2366            .starts_with("sync.offline:"));
2367        assert_eq!(transport.sync_calls, 0);
2368        assert_eq!(transport.realtime_sync_calls, 0);
2369        assert_eq!(transport.connects, 1);
2370        assert!(transport.messages.is_empty());
2371        assert!(matches!(
2372            client.drain_sync_intents().as_slice(),
2373            [SyncIntent::None]
2374        ));
2375        let pending = client.pending_commit_ids();
2376        drop(client);
2377        let mut reopened = SyncClient::open_path(
2378            "gate".into(),
2379            &schema,
2380            ClientLimits::default(),
2381            path.to_str().unwrap(),
2382        )
2383        .unwrap();
2384        assert!(reopened.transport_enabled());
2385        reopened.set_transport_enabled(&mut transport, false);
2386        assert_eq!(reopened.pending_commit_ids(), pending);
2387        assert_eq!(
2388            reopened.query("SELECT id FROM tasks", &[]).unwrap().len(),
2389            2
2390        );
2391        reopened.set_transport_enabled(&mut transport, true);
2392        assert!(matches!(
2393            reopened.drain_sync_intents().as_slice(),
2394            [SyncIntent::Interactive]
2395        ));
2396        reopened.set_transport_enabled(&mut transport, true);
2397        assert!(reopened.drain_sync_intents().is_empty());
2398        drop(reopened);
2399        std::fs::remove_file(path).unwrap();
2400    }
2401
2402    #[test]
2403    fn realtime_connection_ownership_is_idempotent() {
2404        let mut client = client();
2405        let mut transport = CountingRealtimeTransport {
2406            connects: 0,
2407            closes: 0,
2408            ..Default::default()
2409        };
2410        client
2411            .connect_realtime(&mut transport)
2412            .expect("first connect");
2413        client
2414            .connect_realtime(&mut transport)
2415            .expect("idempotent connect");
2416        assert_eq!(transport.connects, 1);
2417        client.disconnect_realtime(&mut transport);
2418        client.disconnect_realtime(&mut transport);
2419        assert_eq!(transport.closes, 1);
2420        client
2421            .connect_realtime(&mut transport)
2422            .expect("deliberate reconnect");
2423        assert_eq!(transport.connects, 2);
2424    }
2425
2426    #[test]
2427    fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
2428        let mut client = client();
2429        client
2430            .set_realtime_policy(RealtimePolicy::Required)
2431            .expect("set policy");
2432        let mut transport = CountingRealtimeTransport::default();
2433        match client.sync(&mut transport) {
2434            SyncOutcome::RealtimeUnavailable {
2435                state,
2436                reason_code,
2437                retry_delay_ms,
2438            } => {
2439                assert_eq!(state, RealtimeState::Disconnected);
2440                assert_eq!(reason_code, None);
2441                assert_eq!(retry_delay_ms, 250);
2442            }
2443            other => panic!("expected RealtimeUnavailable, got {other:?}"),
2444        }
2445        assert_eq!(transport.sync_calls, 0);
2446        assert!(matches!(
2447            client.drain_sync_intents().as_slice(),
2448            [SyncIntent::Background { delay_ms: 250 }]
2449        ));
2450        let snapshot = client
2451            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2452            .expect("diagnostics");
2453        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
2454        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
2455        assert_eq!(snapshot.host.realtime_reason_code, None);
2456        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
2457        assert_eq!(
2458            snapshot
2459                .last_round
2460                .as_ref()
2461                .and_then(|round| round.error_code.clone()),
2462            Some(REALTIME_UNAVAILABLE_CODE.to_owned())
2463        );
2464    }
2465
2466    #[test]
2467    fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
2468        let mut client = client();
2469        client
2470            .set_realtime_policy(RealtimePolicy::Required)
2471            .expect("set policy");
2472        let mut transport = CountingRealtimeTransport {
2473            refuse_connect: true,
2474            ..Default::default()
2475        };
2476        assert!(client.connect_realtime(&mut transport).is_err());
2477        assert_eq!(client.realtime_state(), RealtimeState::Refused);
2478        let snapshot = client
2479            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2480            .expect("diagnostics");
2481        assert_eq!(
2482            snapshot.host.realtime_reason_code.as_deref(),
2483            Some("transport.failed")
2484        );
2485        match client.sync(&mut transport) {
2486            SyncOutcome::RealtimeUnavailable {
2487                state, reason_code, ..
2488            } => {
2489                assert_eq!(state, RealtimeState::Refused);
2490                assert_eq!(reason_code.as_deref(), Some("transport.failed"));
2491            }
2492            other => panic!("expected RealtimeUnavailable, got {other:?}"),
2493        }
2494        assert_eq!(transport.sync_calls, 0);
2495        assert_eq!(transport.realtime_sync_calls, 0);
2496    }
2497
2498    #[test]
2499    fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
2500        let mut client = client();
2501        client
2502            .set_realtime_policy(RealtimePolicy::Required)
2503            .expect("set policy");
2504        let mut transport = CountingRealtimeTransport::default();
2505        client.connect_realtime(&mut transport).expect("connect");
2506        assert_eq!(client.realtime_state(), RealtimeState::Connected);
2507        assert!(
2508            client.set_realtime_policy(RealtimePolicy::Off).is_err(),
2509            "a policy change cannot silently re-route a connected socket"
2510        );
2511        assert!(matches!(
2512            client.sync(&mut transport),
2513            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
2514        ));
2515        assert_eq!(client.realtime_state(), RealtimeState::Lost);
2516        assert_eq!(transport.realtime_sync_calls, 1);
2517        assert_eq!(transport.sync_calls, 0);
2518        let snapshot = client
2519            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2520            .expect("diagnostics");
2521        assert_eq!(
2522            snapshot.host.realtime_reason_code.as_deref(),
2523            Some("sync.transport_failed")
2524        );
2525        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
2526        match client.sync(&mut transport) {
2527            SyncOutcome::RealtimeUnavailable {
2528                state, reason_code, ..
2529            } => {
2530                assert_eq!(state, RealtimeState::Lost);
2531                assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
2532            }
2533            other => panic!("expected RealtimeUnavailable, got {other:?}"),
2534        }
2535        assert_eq!(transport.sync_calls, 0);
2536        client.disconnect_realtime(&mut transport);
2537        assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
2538        assert_eq!(transport.closes, 1);
2539        client.connect_realtime(&mut transport).expect("reconnect");
2540        assert_eq!(client.realtime_state(), RealtimeState::Connected);
2541        assert_eq!(transport.connects, 2);
2542    }
2543
2544    #[test]
2545    fn off_policy_uses_http_and_refuses_an_explicit_connect() {
2546        let mut client = client();
2547        client
2548            .set_realtime_policy(RealtimePolicy::Off)
2549            .expect("set policy");
2550        let mut transport = CountingRealtimeTransport::default();
2551        assert!(client.connect_realtime(&mut transport).is_err());
2552        assert_eq!(client.realtime_state(), RealtimeState::Disabled);
2553        assert!(matches!(
2554            client.sync(&mut transport),
2555            SyncOutcome::Failed { .. }
2556        ));
2557        assert_eq!(transport.sync_calls, 1);
2558        assert_eq!(transport.realtime_sync_calls, 0);
2559        let snapshot = client
2560            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2561            .expect("diagnostics");
2562        assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
2563        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
2564    }
2565
2566    #[test]
2567    fn optional_policy_keeps_the_http_round() {
2568        let mut client = client();
2569        let mut transport = CountingRealtimeTransport::default();
2570        assert!(matches!(
2571            client.sync(&mut transport),
2572            SyncOutcome::Failed { .. }
2573        ));
2574        assert_eq!(transport.sync_calls, 1);
2575        assert_eq!(transport.realtime_sync_calls, 0);
2576        let snapshot = client
2577            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2578            .expect("diagnostics");
2579        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
2580        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
2581    }
2582
2583    #[test]
2584    fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
2585        let mut client = client();
2586        client
2587            .subscribe(
2588                "repair-tasks".to_owned(),
2589                "tasks".to_owned(),
2590                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2591                None,
2592            )
2593            .expect("subscribe");
2594        {
2595            let sub = client
2596                .subs
2597                .iter_mut()
2598                .find(|sub| sub.id == "repair-tasks")
2599                .expect("subscription");
2600            sub.cursor = 42;
2601            sub.synced_once = true;
2602            let persisted = sub.clone();
2603            client.persist_sub(&persisted).unwrap();
2604        }
2605        for table in ["_syncular_base_tasks", "tasks"] {
2606            client
2607                .conn
2608                .execute(
2609                    &format!(
2610                        "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
2611                    ),
2612                    rusqlite::params!["server-row", "p1"],
2613                )
2614                .expect("seed server row");
2615        }
2616        let pending = client
2617            .mutate(vec![Mutation::Upsert {
2618                table: "tasks".to_owned(),
2619                values: Map::from_iter([
2620                    ("id".to_owned(), Value::from("offline-row")),
2621                    ("project_id".to_owned(), Value::from("p1")),
2622                ]),
2623                base_version: None,
2624            }])
2625            .expect("queue offline work");
2626        client.drain_change_batches();
2627        client.drain_sync_intents();
2628
2629        assert_eq!(
2630            client
2631                .rebootstrap_local_data(&LocalDataRebootstrapInput {
2632                    rebootstrap_id: "support-case-001".to_owned(),
2633                })
2634                .expect("rebootstrap"),
2635            LocalDataRebootstrapResult {
2636                already_applied: false,
2637                retained_commits: 1,
2638                reset_subscriptions: 1,
2639            }
2640        );
2641        let visible_ids = client
2642            .conn
2643            .prepare("SELECT id FROM tasks ORDER BY id")
2644            .expect("prepare visible ids")
2645            .query_map([], |row| row.get::<_, String>(0))
2646            .expect("query visible ids")
2647            .collect::<Result<Vec<_>, _>>()
2648            .expect("collect visible ids");
2649        assert_eq!(visible_ids, vec!["offline-row"]);
2650        assert_eq!(client.pending_commit_ids(), vec![pending]);
2651        assert_eq!(
2652            client
2653                .subscription_state("repair-tasks")
2654                .expect("subscription")
2655                .cursor,
2656            -1
2657        );
2658        assert!(client.upgrading());
2659        assert!(client.sync_needed());
2660        assert!(matches!(
2661            client.drain_sync_intents().as_slice(),
2662            [SyncIntent::Interactive]
2663        ));
2664        assert_eq!(client.drain_change_batches().len(), 1);
2665
2666        assert_eq!(
2667            client
2668                .rebootstrap_local_data(&LocalDataRebootstrapInput {
2669                    rebootstrap_id: "support-case-001".to_owned(),
2670                })
2671                .expect("idempotent retry"),
2672            LocalDataRebootstrapResult {
2673                already_applied: true,
2674                retained_commits: 1,
2675                reset_subscriptions: 1,
2676            }
2677        );
2678        assert!(client.drain_change_batches().is_empty());
2679    }
2680
2681    #[test]
2682    fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
2683        let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
2684        assert_eq!(
2685            decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
2686            (3, 4)
2687        );
2688        assert_eq!(
2689            decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
2690            (0, 0)
2691        );
2692        for malformed in [
2693            "",
2694            "{}",
2695            r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
2696            r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
2697            r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
2698        ] {
2699            assert_eq!(
2700                decode_local_rebootstrap_receipt(malformed)
2701                    .expect_err("malformed receipt must fail"),
2702                "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
2703            );
2704        }
2705    }
2706
2707    #[test]
2708    fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
2709        let path = std::env::temp_dir().join(format!(
2710            "syncular-rebootstrap-receipt-{}.db",
2711            uuid::Uuid::new_v4()
2712        ));
2713        let schema = json!({
2714            "version": 1,
2715            "tables": [{
2716                "name": "tasks",
2717                "primaryKey": "id",
2718                "columns": [
2719                    { "name": "id", "type": "string", "nullable": false },
2720                    { "name": "project_id", "type": "string", "nullable": false }
2721                ],
2722                "scopes": [{ "pattern": "project:{project_id}" }]
2723            }]
2724        });
2725        let path_string = path.to_str().expect("UTF-8 temp path");
2726
2727        {
2728            let mut first = SyncClient::open_path(
2729                "repair-restart-client".to_owned(),
2730                &schema,
2731                ClientLimits::default(),
2732                path_string,
2733            )
2734            .expect("first open");
2735            first
2736                .subscribe(
2737                    "repair-tasks".to_owned(),
2738                    "tasks".to_owned(),
2739                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2740                    None,
2741                )
2742                .expect("subscribe");
2743            first
2744                .mutate(vec![Mutation::Upsert {
2745                    table: "tasks".to_owned(),
2746                    values: Map::from_iter([
2747                        ("id".to_owned(), Value::from("offline-row")),
2748                        ("project_id".to_owned(), Value::from("p1")),
2749                    ]),
2750                    base_version: None,
2751                }])
2752                .expect("queue offline work");
2753            assert_eq!(
2754                first
2755                    .rebootstrap_local_data(&LocalDataRebootstrapInput {
2756                        rebootstrap_id: "restart-receipt".to_owned(),
2757                    })
2758                    .expect("first rebootstrap"),
2759                LocalDataRebootstrapResult {
2760                    already_applied: false,
2761                    retained_commits: 1,
2762                    reset_subscriptions: 1,
2763                }
2764            );
2765        }
2766
2767        let mut reopened = SyncClient::open_path(
2768            "repair-restart-client".to_owned(),
2769            &schema,
2770            ClientLimits::default(),
2771            path_string,
2772        )
2773        .expect("reopen");
2774        reopened.drain_change_batches();
2775        reopened.drain_sync_intents();
2776        assert_eq!(
2777            reopened
2778                .rebootstrap_local_data(&LocalDataRebootstrapInput {
2779                    rebootstrap_id: "restart-receipt".to_owned(),
2780                })
2781                .expect("receipt replay"),
2782            LocalDataRebootstrapResult {
2783                already_applied: true,
2784                retained_commits: 1,
2785                reset_subscriptions: 1,
2786            }
2787        );
2788        assert!(reopened.drain_change_batches().is_empty());
2789        assert!(reopened.drain_sync_intents().is_empty());
2790        drop(reopened);
2791        std::fs::remove_file(path).expect("remove temp database");
2792    }
2793
2794    #[test]
2795    fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
2796        let mut malformed = client();
2797        malformed
2798            .subscribe(
2799                "repair-tasks".to_owned(),
2800                "tasks".to_owned(),
2801                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2802                None,
2803            )
2804            .expect("subscribe");
2805        malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
2806        malformed.drain_change_batches();
2807        malformed.drain_sync_intents();
2808        let malformed_error = malformed
2809            .rebootstrap_local_data(&LocalDataRebootstrapInput {
2810                rebootstrap_id: "malformed".to_owned(),
2811            })
2812            .expect_err("malformed receipt must fail closed");
2813        assert_eq!(
2814            malformed_error,
2815            "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
2816        );
2817        assert!(!malformed.upgrading());
2818        assert_eq!(
2819            malformed
2820                .subscription_state("repair-tasks")
2821                .expect("unchanged subscription")
2822                .cursor,
2823            -1
2824        );
2825        assert!(malformed.drain_change_batches().is_empty());
2826        assert!(malformed.drain_sync_intents().is_empty());
2827
2828        let mut unreadable = client();
2829        unreadable
2830            .conn
2831            .execute(
2832                "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
2833                rusqlite::params!["server-row", "p1"],
2834            )
2835            .expect("seed visible row");
2836        unreadable
2837            .conn
2838            .execute("DROP TABLE _syncular_meta", [])
2839            .expect("break marker storage");
2840        let unreadable_error = unreadable
2841            .rebootstrap_local_data(&LocalDataRebootstrapInput {
2842                rebootstrap_id: "unreadable".to_owned(),
2843            })
2844            .expect_err("unreadable marker storage must fail closed");
2845        assert_eq!(
2846            unreadable_error,
2847            "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
2848        );
2849        let visible_rows = unreadable
2850            .conn
2851            .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
2852            .expect("visible projection remains");
2853        assert_eq!(visible_rows, 1);
2854        assert!(!unreadable.upgrading());
2855        assert!(unreadable.drain_change_batches().is_empty());
2856        assert!(unreadable.drain_sync_intents().is_empty());
2857    }
2858
2859    #[test]
2860    fn local_rebootstrap_cannot_bypass_schema_floor() {
2861        let mut client = client();
2862        client.set_schema_floor(Some(SchemaFloor {
2863            required_schema_version: Some(2),
2864            latest_schema_version: Some(2),
2865        }));
2866        let error = client
2867            .rebootstrap_local_data(&LocalDataRebootstrapInput {
2868                rebootstrap_id: "blocked-floor".to_owned(),
2869            })
2870            .expect_err("schema floor must block repair");
2871        assert!(error.contains("cannot bypass an active schema-floor stop"));
2872    }
2873
2874    #[test]
2875    fn diego_applied_sparse_moves_stay_visible_until_pull_image_arrives() {
2876        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2877            "columns":[{"name":"id","type":"string","nullable":false},
2878                {"name":"project_id","type":"string","nullable":false},
2879                {"name":"starts_at_ms","type":"integer","nullable":false},
2880                {"name":"version","type":"integer","nullable":false}],
2881            "scopes":[{"pattern":"project:{project_id}"}]}]});
2882        let mut client =
2883            SyncClient::new("diego-ack-gap".into(), &schema, ClientLimits::default()).unwrap();
2884        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2885        client
2886            .write_base_row(
2887                "tasks",
2888                &vec![
2889                    Some(ColumnValue::String("t1".into())),
2890                    Some(ColumnValue::String("p1".into())),
2891                    Some(ColumnValue::Integer(100)),
2892                    Some(ColumnValue::Integer(1)),
2893                ],
2894                1,
2895            )
2896            .unwrap();
2897        client.rebuild_overlay().expect("rebuild overlay");
2898        let first = client
2899            .patch(
2900                "tasks",
2901                "t1",
2902                Map::from_iter([
2903                    ("starts_at_ms".into(), json!(200)),
2904                    ("version".into(), json!(2)),
2905                ]),
2906                None,
2907            )
2908            .unwrap();
2909        let (_, first_request) = client.build_request(false).unwrap();
2910        // This second edit happens while the first request is in flight.
2911        let second = client
2912            .patch(
2913                "tasks",
2914                "t1",
2915                Map::from_iter([
2916                    ("starts_at_ms".into(), json!(300)),
2917                    ("version".into(), json!(3)),
2918                ]),
2919                None,
2920            )
2921            .unwrap();
2922        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2923        for (id, seq, meta) in [
2924            (first, 2, first_request),
2925            (second, 3, client.build_request(false).unwrap().1),
2926        ] {
2927            let response = Message {
2928                wire_version: WIRE_VERSION,
2929                msg_kind: MsgKind::Response,
2930                frames: vec![
2931                    Frame::RespHeader {
2932                        required_schema_version: None,
2933                        latest_schema_version: None,
2934                        log_epoch: Some("epoch-1".into()),
2935                        reset_required: Some(false),
2936                    },
2937                    Frame::PushResult {
2938                        client_commit_id: id,
2939                        status: PushStatus::Applied,
2940                        commit_seq: Some(seq),
2941                        results: vec![OpResult::Applied { op_index: 0 }],
2942                    },
2943                ],
2944            };
2945            assert!(matches!(
2946                client.process_response(&mut transport, response, &meta),
2947                SyncOutcome::Ok(_)
2948            ));
2949            let row = &client
2950                .query("SELECT starts_at_ms, version FROM tasks WHERE id='t1'", &[])
2951                .unwrap()[0];
2952            assert_eq!(
2953                row["starts_at_ms"],
2954                json!(300),
2955                "an applied ACK without a pull image must not expose the old base"
2956            );
2957            assert_eq!(
2958                row["version"],
2959                json!(3),
2960                "the queued domain version must remain monotonic"
2961            );
2962        }
2963    }
2964
2965    #[test]
2966    fn first_epoch_acquisition_preserves_tables_subscriptions_and_local_intent() {
2967        for stored in [None, Some("epoch-old")] {
2968            let mut client = client();
2969            client
2970                .subscribe(
2971                    "tasks".into(),
2972                    "tasks".into(),
2973                    vec![("project_id".into(), vec!["p1".into()])],
2974                    None,
2975                )
2976                .unwrap();
2977            client
2978                .conn
2979                .execute_batch(
2980                    "CREATE TRIGGER epoch_table_identity AFTER INSERT ON tasks BEGIN SELECT 1; END",
2981                )
2982                .unwrap();
2983            let id = client
2984                .mutate(vec![Mutation::Upsert {
2985                    table: "tasks".into(),
2986                    values: Map::from_iter([
2987                        ("id".into(), json!("offline")),
2988                        ("project_id".into(), json!("p1")),
2989                    ]),
2990                    base_version: None,
2991                }])
2992                .unwrap();
2993            if let Some(epoch) = stored {
2994                client.set_meta(LOG_EPOCH_KEY, epoch);
2995            }
2996            let before = serde_json::to_value(client.subscription_state("tasks")).unwrap();
2997            let (_, meta) = client.build_request(false).unwrap();
2998            client.drain_change_batches();
2999            client.drain_sync_intents();
3000            let response = Message {
3001                wire_version: WIRE_VERSION,
3002                msg_kind: MsgKind::Response,
3003                frames: vec![Frame::RespHeader {
3004                    required_schema_version: None,
3005                    latest_schema_version: None,
3006                    log_epoch: Some("epoch-new".into()),
3007                    reset_required: Some(true),
3008                }],
3009            };
3010            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3011            let outcome = client.process_response(&mut transport, response, &meta);
3012            let SyncOutcome::Ok(report) = outcome else {
3013                panic!("epoch round failed: {outcome:?}");
3014            };
3015            assert_eq!(
3016                report.resets,
3017                if stored.is_none() {
3018                    Vec::<String>::new()
3019                } else {
3020                    vec!["tasks".into()]
3021                }
3022            );
3023            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3024            assert_eq!(client.pending_commit_ids(), vec![id]);
3025            assert_eq!(
3026                client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3027                json!("offline")
3028            );
3029            let trigger: i64 = client
3030                .conn
3031                .query_row(
3032                    "SELECT count(*) FROM sqlite_master WHERE name='epoch_table_identity'",
3033                    [],
3034                    |row| row.get(0),
3035                )
3036                .unwrap();
3037            if stored.is_none() {
3038                assert!(!client.upgrading);
3039                assert!(client
3040                    .drain_change_batches()
3041                    .iter()
3042                    .all(|batch| batch.status.as_ref().is_none_or(|status| !status.upgrading)));
3043                assert_eq!(
3044                    serde_json::to_value(client.subscription_state("tasks")).unwrap(),
3045                    before
3046                );
3047                assert_eq!(trigger, 1, "acquisition must not drop the synced table");
3048            } else {
3049                assert!(client.upgrading);
3050                assert_eq!(trigger, 0, "an epoch change must drop the old table");
3051            }
3052            assert!(matches!(
3053                client.drain_sync_intents().as_slice(),
3054                [SyncIntent::Interactive]
3055            ));
3056        }
3057    }
3058
3059    #[test]
3060    fn epoch_acquisition_storage_failures_preserve_ready_state_and_intent() {
3061        for key in ["logEpoch", "localRevision"] {
3062            let mut client = client();
3063            let id = client
3064                .mutate(vec![Mutation::Upsert {
3065                    table: "tasks".into(),
3066                    values: Map::from_iter([
3067                        ("id".into(), json!("offline")),
3068                        ("project_id".into(), json!("p1")),
3069                    ]),
3070                    base_version: None,
3071                }])
3072                .unwrap();
3073            let revision = client.local_revision();
3074            let needed = client.sync_needed;
3075            client.drain_change_batches();
3076            client.drain_sync_intents();
3077            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();
3078            assert!(client.run_log_epoch_reset("epoch-new").is_err());
3079            assert!(client.get_meta(LOG_EPOCH_KEY).is_none());
3080            assert!(!client.upgrading);
3081            assert_eq!(client.sync_needed, needed);
3082            assert_eq!(client.local_revision(), revision);
3083            assert_eq!(client.pending_commit_ids(), [id]);
3084            assert!(client.drain_change_batches().is_empty());
3085            assert!(client.drain_sync_intents().is_empty());
3086        }
3087    }
3088
3089    #[test]
3090    fn failed_log_epoch_resets_restore_memory_and_durable_state() {
3091        for key in [
3092            "localSchemaVersion",
3093            "localSchemaDescriptor",
3094            "logEpoch",
3095            "localRevision",
3096        ] {
3097            let mut client = client();
3098            client.set_meta(LOG_EPOCH_KEY, "epoch-old");
3099            let commit = client
3100                .mutate(vec![Mutation::Upsert {
3101                    table: "tasks".into(),
3102                    values: Map::from_iter([
3103                        ("id".into(), json!("offline")),
3104                        ("project_id".into(), json!("p1")),
3105                    ]),
3106                    base_version: None,
3107                }])
3108                .unwrap();
3109            client.active_round = Some(uuid::Uuid::new_v4());
3110            client.overlay_dirty.set(false);
3111            let round = client.active_round;
3112            let needed = client.sync_needed;
3113            let revision = client.local_revision();
3114            let rows = serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap();
3115            client.drain_change_batches();
3116            client.drain_sync_intents();
3117            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();
3118            assert!(client.run_log_epoch_reset("epoch-new").is_err(), "{key}");
3119            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-old"));
3120            assert!(!client.upgrading);
3121            assert_eq!(client.active_round, round);
3122            assert!(!client.overlay_dirty.get());
3123            assert_eq!(client.sync_needed, needed);
3124            assert_eq!(client.local_revision(), revision);
3125            assert_eq!(
3126                client.pending_commit_ids().as_slice(),
3127                std::slice::from_ref(&commit)
3128            );
3129            assert_eq!(
3130                serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap(),
3131                rows
3132            );
3133            assert!(client.drain_change_batches().is_empty());
3134            assert!(client.drain_sync_intents().is_empty());
3135            assert!(client.conn.is_autocommit());
3136            client
3137                .conn
3138                .execute_batch("DROP TRIGGER fail_reset")
3139                .unwrap();
3140            client.run_log_epoch_reset("epoch-new").unwrap();
3141            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3142            assert_eq!(client.pending_commit_ids(), [commit]);
3143        }
3144    }
3145
3146    #[test]
3147    fn acknowledged_restore_and_replay_ignore_unrelated_subscription_count() {
3148        for subscriptions in [0, 2, 6, 12] {
3149            let mut client = SyncClient::new("ack-scope".into(), &json!({ "version": 1, "tables": [
3150                { "name": "tasks", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] },
3151                { "name": "docs", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] }
3152            ] }), ClientLimits::default()).unwrap();
3153            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3154            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();
3155            for index in 0..32 {
3156                client
3157                    .mutate(vec![Mutation::Upsert {
3158                        table: "tasks".into(),
3159                        values: Map::from_iter([
3160                            ("id".into(), json!(format!("task-{index}"))),
3161                            ("project_id".into(), json!("p1")),
3162                        ]),
3163                        base_version: None,
3164                    }])
3165                    .unwrap();
3166            }
3167            let (_, meta) = client.build_request(false).unwrap();
3168            let mut frames = vec![Frame::RespHeader {
3169                required_schema_version: None,
3170                latest_schema_version: None,
3171                log_epoch: Some("epoch-1".into()),
3172                reset_required: Some(false),
3173            }];
3174            frames.extend(meta.pushed_ids.iter().enumerate().map(|(index, id)| {
3175                Frame::PushResult {
3176                    client_commit_id: id.clone(),
3177                    status: PushStatus::Applied,
3178                    commit_seq: Some(index as i64 + 1),
3179                    results: vec![OpResult::Applied { op_index: 0 }],
3180                }
3181            }));
3182            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3183            client.acknowledged_replay_count.set(0);
3184            client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3185            assert!(matches!(
3186                client.process_response(
3187                    &mut transport,
3188                    Message {
3189                        wire_version: WIRE_VERSION,
3190                        msg_kind: MsgKind::Response,
3191                        frames
3192                    },
3193                    &meta
3194                ),
3195                SyncOutcome::Ok(_)
3196            ));
3197            assert_eq!(client.acknowledged_replay_count.get(), 32);
3198            assert_eq!(
3199                client
3200                    .query(
3201                        "SELECT count(*) AS n FROM ack_writes WHERE kind='restore'",
3202                        &[]
3203                    )
3204                    .unwrap()[0]["n"],
3205                json!(32)
3206            );
3207            assert_eq!(
3208                client
3209                    .query(
3210                        "SELECT count(*) AS n FROM ack_writes WHERE kind='replay'",
3211                        &[]
3212                    )
3213                    .unwrap()[0]["n"],
3214                json!(32)
3215            );
3216            client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3217            client.acknowledged_replay_count.set(0);
3218            for index in 0..subscriptions {
3219                client
3220                    .subscribe(
3221                        format!("docs-{index}"),
3222                        "docs".into(),
3223                        vec![("project_id".into(), vec![format!("scope-{index}")])],
3224                        None,
3225                    )
3226                    .unwrap();
3227                let sub = client.subs.len() - 1;
3228                client.subs[sub].effective =
3229                    Some(vec![("project_id".into(), vec![format!("scope-{index}")])]);
3230                let segment = RowsSegment {
3231                    table: "docs".into(),
3232                    schema_version: 1,
3233                    columns: client.schema.table("docs").unwrap().wire_columns.clone(),
3234                    blocks: if index % 2 == 0 {
3235                        vec![vec![ssp2::segment::SegmentRow {
3236                            values: vec![
3237                                Some(ColumnValue::String(format!("doc-{index}"))),
3238                                Some(ColumnValue::String(format!("scope-{index}"))),
3239                            ],
3240                            server_version: 1,
3241                        }]]
3242                    } else {
3243                        vec![]
3244                    },
3245                };
3246                assert!(client.apply_segment(sub, &segment, true).is_ok());
3247            }
3248            assert_eq!(
3249                client
3250                    .query("SELECT count(*) AS n FROM ack_writes", &[])
3251                    .unwrap()[0]["n"],
3252                json!(0),
3253                "unrelated bootstrap must not restore/replay tasks"
3254            );
3255            assert_eq!(
3256                client.acknowledged_replay_count.get(),
3257                0,
3258                "unrelated bootstrap must not decode task ACKs"
3259            );
3260            assert_eq!(
3261                client
3262                    .query("SELECT count(*) AS n FROM tasks", &[])
3263                    .unwrap()[0]["n"],
3264                json!(32)
3265            );
3266            assert_eq!(
3267                client
3268                    .query("SELECT count(*) AS n FROM _syncular_acked_rows", &[])
3269                    .unwrap()[0]["n"],
3270                json!(32)
3271            );
3272        }
3273    }
3274
3275    #[test]
3276    fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
3277        let mut client = client();
3278        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3279        const COMMIT_COUNT: usize = 32;
3280
3281        for index in 0..COMMIT_COUNT {
3282            client
3283                .mutate(vec![Mutation::Upsert {
3284                    table: "tasks".to_owned(),
3285                    values: Map::from_iter([
3286                        ("id".to_owned(), Value::from(format!("task-{index}"))),
3287                        ("project_id".to_owned(), Value::from("project-1")),
3288                    ]),
3289                    base_version: None,
3290                }])
3291                .expect("queue commit");
3292        }
3293
3294        let (_, request_meta) = client.build_request(false).unwrap();
3295        assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
3296        let mut frames = vec![Frame::RespHeader {
3297            required_schema_version: None,
3298            latest_schema_version: None,
3299            log_epoch: Some("epoch-1".to_owned()),
3300            reset_required: Some(false),
3301        }];
3302        frames.extend(request_meta.pushed_ids.iter().enumerate().map(
3303            |(index, client_commit_id)| Frame::PushResult {
3304                client_commit_id: client_commit_id.clone(),
3305                status: PushStatus::Applied,
3306                commit_seq: Some(index as i64 + 1),
3307                results: vec![OpResult::Applied { op_index: 0 }],
3308            },
3309        ));
3310        let response = Message {
3311            wire_version: WIRE_VERSION,
3312            msg_kind: MsgKind::Response,
3313            frames,
3314        };
3315        let mut transport =
3316            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
3317
3318        client.overlay_rebuild_count.set(0);
3319        client.outcome_prune_count.set(0);
3320        let outcome = client.process_response(&mut transport, response, &request_meta);
3321        assert!(matches!(outcome, SyncOutcome::Ok(_)));
3322        assert!(client.pending_commit_ids().is_empty());
3323        assert_eq!(
3324            client.overlay_rebuild_count.get(),
3325            1,
3326            "one response must reconcile its acknowledged commits with one overlay rebuild"
3327        );
3328        assert_eq!(
3329            client.outcome_prune_count.get(),
3330            1,
3331            "one response must enforce outcome retention once"
3332        );
3333    }
3334
3335    #[test]
3336    fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
3337        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
3338            "columns": [
3339                {"name":"id", "type":"string", "nullable":false},
3340                {"name":"project_id", "type":"string", "nullable":false},
3341                {"name":"title", "type":"string", "nullable":false}
3342            ], "scopes": [{"pattern":"project:{project_id}"}],
3343            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
3344            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
3345        }] });
3346        let mut incremental =
3347            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
3348        let mut reference =
3349            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
3350        let tables = vec!["tasks".to_owned()];
3351        for index in 0..32 {
3352            let id = format!("task-{}", index % 8);
3353            let values = Map::from_iter([
3354                ("id".to_owned(), Value::from(id.clone())),
3355                (
3356                    "project_id".to_owned(),
3357                    Value::from(format!("p{}", index % 2)),
3358                ),
3359                ("title".to_owned(), Value::from(format!("needle{index}"))),
3360            ]);
3361            let payload = encode_row_json(
3362                incremental.schema.table("tasks").unwrap(),
3363                &id,
3364                &values,
3365                &incremental.encryption,
3366            )
3367            .unwrap();
3368            let change = ssp2::model::Change {
3369                table_index: 0,
3370                row_id: id,
3371                op: if index % 5 == 0 {
3372                    Op::Delete
3373                } else {
3374                    Op::Upsert
3375                },
3376                row_version: Some(index + 1),
3377                scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
3378                row: Some(payload),
3379            };
3380            reference.overlay_dirty.set(true);
3381            for instance in [&mut incremental, &mut reference] {
3382                instance.begin_observation("mirror_test").unwrap();
3383                let mut batch = ChangeAccumulator::default();
3384                instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
3385                instance
3386                    .apply_commit_changes(&tables, std::slice::from_ref(&change))
3387                    .unwrap();
3388                instance
3389                    .rebuild_overlay_if_dirty()
3390                    .expect("rebuild overlay");
3391                instance.finish_observation("mirror_test", batch).unwrap();
3392            }
3393            for sql in [
3394                "SELECT * FROM tasks ORDER BY id",
3395                "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
3396            ] {
3397                assert_eq!(
3398                    incremental.query(sql, &[]).unwrap(),
3399                    reference.query(sql, &[]).unwrap()
3400                );
3401            }
3402            assert_eq!(incremental.local_revision(), reference.local_revision());
3403            assert_eq!(
3404                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
3405                serde_json::to_value(reference.drain_change_batches()).unwrap()
3406            );
3407        }
3408        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
3409        assert_eq!(reference.overlay_rebuild_count.get(), 32);
3410    }
3411
3412    #[test]
3413    fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
3414        let cases = [
3415            (
3416                "string",
3417                vec![
3418                    json!("1"),
3419                    json!("01"),
3420                    json!(""),
3421                    json!("nul\u{0}id"),
3422                    json!("é"),
3423                ],
3424            ),
3425            (
3426                "integer",
3427                vec![
3428                    json!(i64::MIN),
3429                    json!(-1),
3430                    json!(0),
3431                    json!(1),
3432                    json!(i64::MAX),
3433                ],
3434            ),
3435            ("boolean", vec![json!(false), json!(true)]),
3436            ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
3437        ];
3438        for (kind, ids) in cases {
3439            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3440                "columns":[{"name":"id","type":kind,"nullable":false},
3441                    {"name":"project_id","type":"string","nullable":false}],
3442                "scopes":[{"pattern":"project:{project_id}"}]}]});
3443            let mut client =
3444                SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
3445            for (index, id) in ids.iter().enumerate() {
3446                let table = client.schema.table("tasks").unwrap();
3447                client
3448                    .write_base_row(
3449                        "tasks",
3450                        &vec![
3451                            if kind == "integer" {
3452                                // Exercise local SQLite key lookup across its
3453                                // full i64 range independently of authoring.
3454                                Some(ColumnValue::Integer(id.as_i64().unwrap()))
3455                            } else {
3456                                json_to_column_value(&table.columns[0], Some(id)).unwrap()
3457                            },
3458                            Some(ColumnValue::String(format!("p{index}"))),
3459                        ],
3460                        1,
3461                    )
3462                    .unwrap();
3463            }
3464            client.rebuild_overlay_if_dirty().expect("rebuild overlay");
3465            for base in [true, false] {
3466                let full = if base {
3467                    base_table("tasks")
3468                } else {
3469                    visible_table("tasks")
3470                };
3471                let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
3472                for row_id in [
3473                    "1",
3474                    "01",
3475                    "1.0",
3476                    "1.0000000000000002",
3477                    "+1",
3478                    "1e0",
3479                    " 1",
3480                    "-1",
3481                    "0",
3482                    "9223372036854775807",
3483                    "-9223372036854775808",
3484                    "true",
3485                    "false",
3486                    "",
3487                    "nul\u{0}id",
3488                    "é",
3489                    "null",
3490                    "{\"a\":1}",
3491                ] {
3492                    let old_sql =
3493                        format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
3494                    let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
3495                    let old = client
3496                        .conn
3497                        .prepare(&old_sql)
3498                        .unwrap()
3499                        .query_map([row_id], |row| row.get::<_, String>(0))
3500                        .unwrap()
3501                        .collect::<Result<Vec<_>, _>>()
3502                        .unwrap();
3503                    let new = client
3504                        .conn
3505                        .prepare_cached(&new_sql)
3506                        .unwrap()
3507                        .query_map([row_id], |row| row.get::<_, String>(0))
3508                        .unwrap()
3509                        .collect::<Result<Vec<_>, _>>()
3510                        .unwrap();
3511                    assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
3512                    let mut batch = ChangeAccumulator::default();
3513                    assert_eq!(
3514                        client.record_row_scopes(&mut batch, "tasks", row_id, base),
3515                        !old.is_empty()
3516                    );
3517                    let expected = old.first().map(|scope| {
3518                        BTreeMap::from([(
3519                            "tasks".to_owned(),
3520                            Some(BTreeSet::from([format!("project:{scope}")])),
3521                        )])
3522                    });
3523                    assert_eq!(batch.tables, expected.unwrap_or_default());
3524                    client
3525                        .conn
3526                        .execute_batch("SAVEPOINT lookup_delete")
3527                        .unwrap();
3528                    if base {
3529                        client.delete_base_row("tasks", row_id).unwrap();
3530                    } else {
3531                        client
3532                            .apply_outbox_ops(&[OutboxOp {
3533                                upsert: false,
3534                                table: "tasks".into(),
3535                                row_id: row_id.into(),
3536                                base_version: None,
3537                                values: None,
3538                            }])
3539                            .expect("replay delete");
3540                    }
3541                    let remaining: i64 = client
3542                        .conn
3543                        .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
3544                            row.get(0)
3545                        })
3546                        .unwrap();
3547                    assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
3548                    client
3549                        .conn
3550                        .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
3551                        .unwrap();
3552                    client.overlay_dirty.set(false);
3553                }
3554                for (predicate, expected) in [
3555                    ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
3556                    (predicate, "SEARCH"),
3557                ] {
3558                    let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
3559                    let details = client
3560                        .conn
3561                        .prepare(&sql)
3562                        .unwrap()
3563                        .query_map(["1"], |row| row.get::<_, String>(3))
3564                        .unwrap()
3565                        .collect::<Result<Vec<_>, _>>()
3566                        .unwrap();
3567                    assert!(
3568                        details.iter().any(|detail| detail.contains(expected)),
3569                        "{kind}: {details:?}"
3570                    );
3571                }
3572            }
3573        }
3574    }
3575
3576    #[test]
3577    fn primary_keys_reject_every_ineligible_column_type() {
3578        // §2.4: a `rowId` is a string, so a primary key type whose string form
3579        // differs per renderer, or cannot be reproduced by local storage
3580        // comparison, is a schema error rather than a silent lookup miss.
3581        for ty in ["float", "bytes", "crdt", "blob_ref"] {
3582            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3583                "columns":[{"name":"id","type":ty,"nullable":false},
3584                    {"name":"project_id","type":"string","nullable":false}],
3585                "scopes":[{"pattern":"project:{project_id}"}]}]});
3586            let error =
3587                match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
3588                    Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
3589                    Err(error) => error,
3590                };
3591            assert!(
3592                error.contains("has an ineligible column type (§2.4)"),
3593                "{ty}: {error}"
3594            );
3595        }
3596        for ty in ["string", "integer", "boolean", "json"] {
3597            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3598                "columns":[{"name":"id","type":ty,"nullable":false},
3599                    {"name":"project_id","type":"string","nullable":false}],
3600                "scopes":[{"pattern":"project:{project_id}"}]}]});
3601            SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
3602                .unwrap_or_else(|error| panic!("{ty}: {error}"));
3603        }
3604    }
3605
3606    #[test]
3607    fn pending_row_reconciliation_matches_full_fifo_replay() {
3608        for unique in [false, true] {
3609            let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
3610                "name": name, "primaryKey": "id", "columns": [
3611                    {"name":"id", "type":"string", "nullable":false},
3612                    {"name":"project_id", "type":"string", "nullable":false},
3613                    {"name":"title", "type":"string", "nullable":false}],
3614                "scopes": [{"pattern":"project:{project_id}"}],
3615                "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
3616                "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
3617            })))});
3618            let mut incremental =
3619                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
3620            let mut reference =
3621                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
3622            let tables = vec!["tasks".to_owned(), "notes".to_owned()];
3623            for instance in [&mut incremental, &mut reference] {
3624                for table in &tables {
3625                    for index in 0..12 {
3626                        instance
3627                            .write_base_row(
3628                                table,
3629                                &vec![
3630                                    Some(ColumnValue::String(format!("row{index}"))),
3631                                    Some(ColumnValue::String("p0".into())),
3632                                    Some(ColumnValue::String(format!("base{index}"))),
3633                                ],
3634                                1,
3635                            )
3636                            .unwrap();
3637                    }
3638                }
3639                instance
3640                    .rebuild_overlay_if_dirty()
3641                    .expect("rebuild overlay");
3642            }
3643            for index in 0..80 {
3644                let mutations = (0..3)
3645                    .map(|offset| {
3646                        let table = tables[(index + offset) % 2].clone();
3647                        let id = format!("row{}", (index + offset) % 16);
3648                        if (index + offset) % 7 == 0 {
3649                            Mutation::Delete {
3650                                table,
3651                                row_id: id,
3652                                base_version: None,
3653                            }
3654                        } else {
3655                            Mutation::Upsert {
3656                                table,
3657                                values: Map::from_iter([
3658                                    ("id".into(), Value::from(id)),
3659                                    ("project_id".into(), Value::from(format!("p{}", index % 3))),
3660                                    (
3661                                        "title".into(),
3662                                        Value::from(format!("needle{}", (index + offset) % 16)),
3663                                    ),
3664                                ]),
3665                                base_version: None,
3666                            }
3667                        }
3668                    })
3669                    .collect::<Vec<_>>();
3670                for instance in [&mut incremental, &mut reference] {
3671                    instance.mutate(mutations.clone()).unwrap();
3672                }
3673                // Most frames touch only the table without unique constraints.
3674                // Mixed frames must retain full replay for both tables.
3675                let changes = (0..3)
3676                    .map(|offset| {
3677                        let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
3678                        let id = format!("row{}", (index + offset) % 16);
3679                        let values = Map::from_iter([
3680                            ("id".into(), Value::from(id.clone())),
3681                            (
3682                                "project_id".into(),
3683                                Value::from(format!("p{}", (index + 1) % 3)),
3684                            ),
3685                            (
3686                                "title".into(),
3687                                Value::from(format!("remote{index}_{offset}")),
3688                            ),
3689                        ]);
3690                        ssp2::model::Change {
3691                            table_index: table_index as u16,
3692                            row_id: id.clone(),
3693                            op: if (index + offset) % 5 == 0 {
3694                                Op::Delete
3695                            } else {
3696                                Op::Upsert
3697                            },
3698                            row_version: Some(index as i64 + 2),
3699                            scopes: vec![],
3700                            row: Some(
3701                                encode_row_json(
3702                                    incremental.schema.table(&tables[table_index]).unwrap(),
3703                                    &id,
3704                                    &values,
3705                                    &incremental.encryption,
3706                                )
3707                                .unwrap(),
3708                            ),
3709                        }
3710                    })
3711                    .collect::<Vec<_>>();
3712                reference.overlay_dirty.set(true);
3713                incremental
3714                    .apply_commit_frame(&tables, &changes, None)
3715                    .unwrap();
3716                reference
3717                    .apply_commit_frame(&tables, &changes, None)
3718                    .unwrap();
3719                for table in &tables {
3720                    for sql in [format!("SELECT * FROM {table} ORDER BY id"),
3721                        format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
3722                        format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
3723                        format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
3724                        assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
3725                    }
3726                }
3727                assert_eq!(incremental.local_revision(), reference.local_revision());
3728                assert_eq!(
3729                    serde_json::to_value(incremental.drain_change_batches()).unwrap(),
3730                    serde_json::to_value(reference.drain_change_batches()).unwrap()
3731                );
3732            }
3733            assert_eq!(
3734                incremental.overlay_rebuild_count.get(),
3735                if unique { 81 } else { 21 }
3736            );
3737            assert_eq!(reference.overlay_rebuild_count.get(), 81);
3738        }
3739    }
3740
3741    #[test]
3742    fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
3743        let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
3744            "columns":[{"name":"id", "type":"string", "nullable":false},
3745                {"name":"title", "type":"string", "nullable":false}],
3746            "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
3747        let mut client =
3748            SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
3749        for (id, title) in [("a", "original"), ("b", "occupied")] {
3750            client
3751                .write_base_row(
3752                    "tasks",
3753                    &vec![
3754                        Some(ColumnValue::String(id.into())),
3755                        Some(ColumnValue::String(title.into())),
3756                    ],
3757                    1,
3758                )
3759                .unwrap();
3760        }
3761        client.rebuild_overlay_if_dirty().expect("rebuild overlay");
3762        let pending = client
3763            .mutate(vec![Mutation::Upsert {
3764                table: "tasks".into(),
3765                values: Map::from_iter([
3766                    ("id".into(), Value::from("a")),
3767                    ("title".into(), Value::from("available")),
3768                ]),
3769                base_version: None,
3770            }])
3771            .unwrap();
3772        assert_eq!(
3773            client
3774                .conn
3775                .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
3776                    .get::<_, String>(
3777                    0
3778                ))
3779                .unwrap(),
3780            "available"
3781        );
3782        for (title, expected) in [("available", "original"), ("occupied", "available")] {
3783            let change = ssp2::model::Change {
3784                table_index: 0,
3785                row_id: "b".into(),
3786                op: Op::Upsert,
3787                row_version: Some(2),
3788                scopes: vec![],
3789                row: Some(
3790                    encode_row_json(
3791                        client.schema.table("tasks").unwrap(),
3792                        "b",
3793                        &Map::from_iter([
3794                            ("id".into(), Value::from("b")),
3795                            ("title".into(), Value::from(title)),
3796                        ]),
3797                        &client.encryption,
3798                    )
3799                    .unwrap(),
3800                ),
3801            };
3802            client
3803                .apply_commit_frame(&["tasks".into()], &[change], None)
3804                .unwrap();
3805            assert_eq!(
3806                client
3807                    .conn
3808                    .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
3809                        .get::<_, String>(
3810                        0
3811                    ))
3812                    .unwrap(),
3813                expected
3814            );
3815            assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
3816        }
3817        assert_eq!(client.overlay_rebuild_count.get(), 3);
3818    }
3819
3820    fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
3821        let schema = json!({ "version": 1, "tables": [
3822            { "name":"tasks", "primaryKey":"id", "columns":[
3823                {"name":"id","type":"string","nullable":false},
3824                {"name":"project_id","type":"string","nullable":false}],
3825                "scopes":[{"pattern":"project:{project_id}"}] },
3826            { "name":"catalogue", "primaryKey":"id", "columns":[
3827                {"name":"id","type":"string","nullable":false},
3828                {"name":"project_id","type":"string","nullable":false},
3829                {"name":"title","type":"string","nullable":false}],
3830                "scopes":[{"pattern":"project:{project_id}"}],
3831                "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
3832        ] });
3833        let mut client =
3834            SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
3835        if unchanged_rows > 0 {
3836            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();
3837        }
3838        client.rebuild_overlay().expect("rebuild overlay");
3839        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();
3840        let mut elapsed = Vec::new();
3841        for round in 1..=rounds {
3842            let pending = client
3843                .mutate(vec![Mutation::Upsert {
3844                    table: "tasks".into(),
3845                    values: Map::from_iter([
3846                        ("id".into(), json!("t1")),
3847                        ("project_id".into(), json!("p1")),
3848                    ]),
3849                    base_version: None,
3850                }])
3851                .unwrap();
3852            let started = std::time::Instant::now();
3853            client
3854                .write_base_row(
3855                    "tasks",
3856                    &vec![
3857                        Some(ColumnValue::String("t1".into())),
3858                        Some(ColumnValue::String("p1".into())),
3859                    ],
3860                    round,
3861                )
3862                .unwrap();
3863            let mut report = SyncReport::default();
3864            client
3865                .handle_push_results(
3866                    &[Frame::PushResult {
3867                        client_commit_id: pending.clone(),
3868                        status: PushStatus::Applied,
3869                        commit_seq: Some(round),
3870                        results: vec![OpResult::Applied { op_index: 0 }],
3871                    }],
3872                    &HashSet::from([pending.as_str()]),
3873                    &HashMap::new(),
3874                    &mut report,
3875                    Some(&pending),
3876                )
3877                .unwrap();
3878            client.rebuild_overlay_if_dirty().expect("rebuild overlay");
3879            elapsed.push(started.elapsed().as_nanos());
3880        }
3881        assert_eq!(
3882            client
3883                .conn
3884                .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
3885                .unwrap(),
3886            0,
3887            "unchanged catalogue must never be copied"
3888        );
3889        assert_eq!(
3890            client
3891                .conn
3892                .query_row(
3893                    "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
3894                    [],
3895                    |r| r.get::<_, i64>(0)
3896                )
3897                .unwrap(),
3898            unchanged_rows
3899        );
3900        assert_eq!(
3901            client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
3902            json!(rounds)
3903        );
3904        elapsed
3905    }
3906
3907    #[test]
3908    fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
3909        let elapsed = measure_scoped_overlay(100_000, 1);
3910        eprintln!(
3911            "table-scoped overlay with 100k unchanged rows: {} ns",
3912            elapsed[0]
3913        );
3914    }
3915
3916    #[test]
3917    #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
3918    fn benchmark_scoped_overlay() {
3919        for rows in [0, 100_000, 200_000] {
3920            let mut elapsed = measure_scoped_overlay(rows, 30);
3921            elapsed.sort_unstable();
3922            eprintln!(
3923                "{}",
3924                json!({"unchangedRows": rows, "rounds": elapsed.len(),
3925                "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
3926                "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
3927            );
3928        }
3929    }
3930
3931    #[test]
3932    fn released_context_discards_in_flight_ack_and_server_rows() {
3933        for barrier in [
3934            "preflight",
3935            "purge",
3936            "rebootstrap",
3937            "subscription",
3938            "failed-purge-replay",
3939            "failed-purge-publish",
3940            "failed-rebootstrap",
3941        ] {
3942            let mut client = client();
3943            client.set_meta(LOG_EPOCH_KEY, "epoch");
3944            client
3945                .subscribe(
3946                    "tasks".into(),
3947                    "tasks".into(),
3948                    vec![("project_id".into(), vec!["p1".into()])],
3949                    None,
3950                )
3951                .unwrap();
3952            let commit = client
3953                .mutate(vec![Mutation::Upsert {
3954                    table: "tasks".into(),
3955                    values: Map::from_iter([
3956                        ("id".into(), json!("local")),
3957                        ("project_id".into(), json!("p1")),
3958                    ]),
3959                    base_version: None,
3960                }])
3961                .unwrap();
3962            let prepared = client.prepare_sync_round(false).unwrap();
3963            client.set_transport_enabled(&mut CountingRealtimeTransport::default(), false);
3964            let payload = encode_row_json(
3965                &client.schema.tables[0],
3966                "server",
3967                &Map::from_iter([
3968                    ("id".into(), json!("server")),
3969                    ("project_id".into(), json!("p1")),
3970                ]),
3971                &client.encryption,
3972            )
3973            .unwrap();
3974            let completed = crate::CompletedSyncRound {
3975                prepared,
3976                exchange: crate::round::ExchangeResult::Reply {
3977                    transport_failed: false,
3978                    downloads: Default::default(),
3979                    response: Ok(Message {
3980                        wire_version: WIRE_VERSION,
3981                        msg_kind: MsgKind::Response,
3982                        frames: vec![
3983                            Frame::RespHeader {
3984                                required_schema_version: None,
3985                                latest_schema_version: None,
3986                                log_epoch: Some("epoch".into()),
3987                                reset_required: Some(false),
3988                            },
3989                            Frame::PushResult {
3990                                client_commit_id: commit.clone(),
3991                                status: PushStatus::Applied,
3992                                commit_seq: Some(1),
3993                                results: vec![OpResult::Applied { op_index: 0 }],
3994                            },
3995                            Frame::SubStart {
3996                                id: "tasks".into(),
3997                                status: SubStatus::Active,
3998                                reason_code: String::new(),
3999                                effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
4000                                bootstrap: true,
4001                            },
4002                            Frame::Commit {
4003                                commit_seq: 1,
4004                                created_at_ms: 0,
4005                                actor_id: "actor".into(),
4006                                tables: vec!["tasks".into()],
4007                                changes: vec![ssp2::model::Change {
4008                                    table_index: 0,
4009                                    row_id: "server".into(),
4010                                    op: Op::Upsert,
4011                                    row_version: Some(1),
4012                                    scopes: vec![("project_id".into(), "p1".into())],
4013                                    row: Some(payload),
4014                                }],
4015                            },
4016                            Frame::SubEnd {
4017                                next_cursor: 1,
4018                                bootstrap_state: None,
4019                            },
4020                        ],
4021                    }),
4022                },
4023            };
4024            match barrier {
4025                "preflight" => client.begin_security_preflight(),
4026                "purge" => {
4027                    client
4028                        .purge_local_data(&LocalDataPurgeInput {
4029                            purge_id: "pending-round".into(),
4030                            targets: vec![LocalDataPurgeTarget {
4031                                table: "tasks".into(),
4032                                selectors: BTreeMap::from([(
4033                                    "project_id".into(),
4034                                    vec!["p1".into()],
4035                                )]),
4036                            }],
4037                        })
4038                        .unwrap();
4039                }
4040                "rebootstrap" => {
4041                    client
4042                        .rebootstrap_local_data(&LocalDataRebootstrapInput {
4043                            rebootstrap_id: "pending-round".into(),
4044                        })
4045                        .unwrap();
4046                }
4047                "failed-purge-replay" | "failed-purge-publish" | "failed-rebootstrap" => {
4048                    let trigger = if barrier == "failed-purge-replay" {
4049                        "CREATE TRIGGER fail_local BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4050                    } else {
4051                        "CREATE TRIGGER fail_local BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4052                    };
4053                    client.conn.execute_batch(trigger).unwrap();
4054                    let revision = client.local_revision();
4055                    if barrier == "failed-rebootstrap" {
4056                        assert!(client
4057                            .rebootstrap_local_data(&LocalDataRebootstrapInput {
4058                                rebootstrap_id: "failed-round".into(),
4059                            })
4060                            .is_err());
4061                    } else {
4062                        assert!(client
4063                            .purge_local_data(&LocalDataPurgeInput {
4064                                purge_id: "failed-round".into(),
4065                                targets: vec![LocalDataPurgeTarget {
4066                                    table: "tasks".into(),
4067                                    selectors: BTreeMap::from([(
4068                                        "project_id".into(),
4069                                        vec!["p1".into()]
4070                                    )]),
4071                                }],
4072                            })
4073                            .is_err());
4074                    }
4075                    assert_eq!(client.local_revision(), revision);
4076                    assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
4077                    client
4078                        .conn
4079                        .execute_batch("DROP TRIGGER fail_local")
4080                        .unwrap();
4081                    let applied = client.apply_sync_round(completed);
4082                    assert!(
4083                        matches!(
4084                            applied,
4085                            crate::AppliedSyncRound::Complete {
4086                                outcome: SyncOutcome::Ok(_),
4087                                ..
4088                            }
4089                        ),
4090                        "{barrier}"
4091                    );
4092                    assert!(client.pending_commit_ids().is_empty());
4093                    assert_eq!(
4094                        client
4095                            .query("SELECT id FROM tasks WHERE id = 'server'", &[])
4096                            .unwrap()
4097                            .len(),
4098                        1
4099                    );
4100                    continue;
4101                }
4102                "subscription" => {
4103                    client.unsubscribe("tasks");
4104                    client
4105                        .subscribe(
4106                            "tasks".into(),
4107                            "tasks".into(),
4108                            vec![("project_id".into(), vec!["p2".into()])],
4109                            None,
4110                        )
4111                        .unwrap();
4112                }
4113                _ => unreachable!(),
4114            }
4115            let pending_before_reply = client.pending_commit_ids();
4116            let revision = client.local_revision();
4117            let applied = client.apply_sync_round(completed);
4118            assert!(
4119                matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
4120            );
4121            assert_eq!(client.local_revision(), revision);
4122            assert!(client
4123                .conn
4124                .query_row(
4125                    "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
4126                    [],
4127                    |r| r.get::<_, bool>(0)
4128                )
4129                .unwrap());
4130            assert_eq!(client.pending_commit_ids(), pending_before_reply);
4131        }
4132    }
4133
4134    #[test]
4135    fn sync_round_deadline_is_anchored_once_per_round_and_carried_across_continues() {
4136        struct DeadlineRecording {
4137            budget: std::time::Duration,
4138            seen: Vec<Option<std::time::Instant>>,
4139        }
4140        impl Transport for DeadlineRecording {
4141            fn round_deadline(&self) -> Option<std::time::Duration> {
4142                Some(self.budget)
4143            }
4144            fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
4145                self.seen.push(deadline);
4146            }
4147            fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4148                Err(TransportError::new("transport.failed", "injected"))
4149            }
4150            fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4151                self.sync(request)
4152            }
4153            fn download_segment(
4154                &mut self,
4155                _: &SegmentRequest,
4156                _: &mut dyn FnMut(u64),
4157            ) -> Result<Vec<u8>, TransportError> {
4158                Err(TransportError::new("transport.failed", "injected"))
4159            }
4160            fn blob_upload_grant(
4161                &mut self,
4162                _: &str,
4163                _: u64,
4164                _: Option<&str>,
4165            ) -> Result<crate::BlobUploadGrant, TransportError> {
4166                Ok(crate::BlobUploadGrant::Present)
4167            }
4168            fn realtime_connect(&mut self) -> Result<(), TransportError> {
4169                Ok(())
4170            }
4171            fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4172                Ok(())
4173            }
4174            fn realtime_close(&mut self) -> Result<(), TransportError> {
4175                Ok(())
4176            }
4177        }
4178
4179        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4180            {"name":"id","type":"string","nullable":false},
4181            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4182        let mut client =
4183            SyncClient::new("deadline-round".into(), &schema, Default::default()).unwrap();
4184        client.set_meta(LOG_EPOCH_KEY, "epoch");
4185        client
4186            .upload_blob(b"pending upload", Some("text/plain".into()), None)
4187            .unwrap();
4188
4189        let mut transport = DeadlineRecording {
4190            budget: std::time::Duration::from_secs(30),
4191            seen: Vec::new(),
4192        };
4193        let prepared = client.prepare_sync_round(false).unwrap();
4194        assert_eq!(prepared.uploads.len(), 1, "one staged upload");
4195        // Upload exchange -> Continue (the pin removal is persisted by apply).
4196        let prepared = match client.apply_sync_round(prepared.exchange(&mut transport)) {
4197            crate::AppliedSyncRound::Continue(prepared) => prepared,
4198            crate::AppliedSyncRound::Complete { .. } => panic!("the upload must continue"),
4199        };
4200        // Main request exchange -> Complete.
4201        let completed = prepared.exchange(&mut transport);
4202        let anchor = completed
4203            .prepared
4204            .round_deadline_at
4205            .expect("the upload round anchors a deadline");
4206        assert!(matches!(
4207            client.apply_sync_round(completed),
4208            crate::AppliedSyncRound::Complete { .. }
4209        ));
4210        assert_eq!(transport.seen.len(), 4, "one scope per exchange");
4211        assert_eq!(
4212            transport.seen[0],
4213            Some(anchor),
4214            "the upload anchors the round"
4215        );
4216        assert_eq!(
4217            transport.seen[1], None,
4218            "the upload exchange clears its scope"
4219        );
4220        assert_eq!(
4221            transport.seen[2],
4222            Some(anchor),
4223            "a continuation reuses the carried anchor instead of refreshing it"
4224        );
4225        assert_eq!(transport.seen[3], None, "the continuation clears its scope");
4226
4227        // A fresh round anchors a new deadline from the transport's (now larger)
4228        // budget and never inherits the old one; the clock granule is irrelevant.
4229        transport.budget = std::time::Duration::from_secs(60);
4230        let prepared = client.prepare_sync_round(false).unwrap();
4231        let completed = prepared.exchange(&mut transport);
4232        let fresh = completed
4233            .prepared
4234            .round_deadline_at
4235            .expect("the next round anchors a deadline");
4236        client.apply_sync_round(completed);
4237        assert!(
4238            fresh >= anchor + std::time::Duration::from_secs(30),
4239            "the next round anchors a fresh deadline from the new budget"
4240        );
4241        assert_eq!(transport.seen[4], Some(fresh));
4242        assert_eq!(
4243            transport.seen[5], None,
4244            "the new round clears its scope too"
4245        );
4246    }
4247
4248    /// Records the round-deadline traffic so a test can prove whether a round
4249    /// asked for a budget and whether it ever scoped an anchor.
4250    #[derive(Default)]
4251    struct RoundDeadlineProbe {
4252        budget: Option<std::time::Duration>,
4253        budget_queries: std::cell::Cell<usize>,
4254        scoped: Vec<Option<std::time::Instant>>,
4255    }
4256
4257    impl Transport for RoundDeadlineProbe {
4258        fn round_deadline(&self) -> Option<std::time::Duration> {
4259            self.budget_queries.set(self.budget_queries.get() + 1);
4260            self.budget
4261        }
4262        fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
4263            self.scoped.push(deadline);
4264        }
4265        fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4266            Err(TransportError::new("transport.failed", "injected"))
4267        }
4268        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4269            self.sync(request)
4270        }
4271        fn download_segment(
4272            &mut self,
4273            _: &SegmentRequest,
4274            _: &mut dyn FnMut(u64),
4275        ) -> Result<Vec<u8>, TransportError> {
4276            Err(TransportError::new("transport.failed", "injected"))
4277        }
4278        fn blob_upload_grant(
4279            &mut self,
4280            _: &str,
4281            _: u64,
4282            _: Option<&str>,
4283        ) -> Result<crate::BlobUploadGrant, TransportError> {
4284            Ok(crate::BlobUploadGrant::Present)
4285        }
4286        fn realtime_connect(&mut self) -> Result<(), TransportError> {
4287            Ok(())
4288        }
4289        fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4290            Ok(())
4291        }
4292        fn realtime_close(&mut self) -> Result<(), TransportError> {
4293            Ok(())
4294        }
4295    }
4296
4297    #[test]
4298    fn an_unusable_upload_body_does_not_anchor_the_round_deadline() {
4299        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4300            {"name":"id","type":"string","nullable":false},
4301            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4302        let mut client =
4303            SyncClient::new("deadline-unusable".into(), &schema, ClientLimits::default()).unwrap();
4304        client.set_meta(LOG_EPOCH_KEY, "epoch");
4305        client
4306            .upload_blob(b"pending upload", Some("text/plain".into()), None)
4307            .unwrap();
4308        client
4309            .conn
4310            .execute_batch("DELETE FROM _syncular_blobs")
4311            .unwrap();
4312        let prepared = client.prepare_sync_round(false).unwrap();
4313        assert!(
4314            prepared.uploads.front().is_some_and(|u| u.bytes.is_err()),
4315            "the staged body must fail locally"
4316        );
4317        let mut transport = RoundDeadlineProbe {
4318            budget: Some(std::time::Duration::from_secs(30)),
4319            ..Default::default()
4320        };
4321        let completed = prepared.exchange(&mut transport);
4322        assert!(matches!(
4323            completed.exchange,
4324            crate::round::ExchangeResult::Upload { result: Err(_), .. }
4325        ));
4326        assert_eq!(
4327            transport.budget_queries.get(),
4328            0,
4329            "a round with no network work never asks for a budget"
4330        );
4331        assert_eq!(transport.scoped, vec![None]);
4332    }
4333
4334    #[test]
4335    fn a_transport_budget_the_clock_cannot_represent_fails_before_network() {
4336        struct HugeBudget;
4337        impl Transport for HugeBudget {
4338            fn round_deadline(&self) -> Option<std::time::Duration> {
4339                Some(std::time::Duration::MAX)
4340            }
4341            fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4342                panic!("no network for an invalid budget")
4343            }
4344            fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4345                self.sync(request)
4346            }
4347            fn download_segment(
4348                &mut self,
4349                _: &SegmentRequest,
4350                _: &mut dyn FnMut(u64),
4351            ) -> Result<Vec<u8>, TransportError> {
4352                panic!("no network for an invalid budget")
4353            }
4354            fn realtime_connect(&mut self) -> Result<(), TransportError> {
4355                Ok(())
4356            }
4357            fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4358                Ok(())
4359            }
4360            fn realtime_close(&mut self) -> Result<(), TransportError> {
4361                Ok(())
4362            }
4363        }
4364
4365        let mut client = client();
4366        client.set_meta(LOG_EPOCH_KEY, "epoch");
4367        let prepared = client.prepare_sync_round(false).unwrap();
4368        let outcome = match client.apply_sync_round(prepared.exchange(&mut HugeBudget)) {
4369            crate::AppliedSyncRound::Complete { outcome, .. } => outcome,
4370            crate::AppliedSyncRound::Continue(_) => {
4371                panic!("an unrepresentable budget must not continue")
4372            }
4373        };
4374        match outcome {
4375            SyncOutcome::Failed {
4376                error_code,
4377                message,
4378                ..
4379            } => {
4380                assert_eq!(error_code, "sync.invalid_request");
4381                assert_eq!(message, "transport round deadline is out of range");
4382            }
4383            _ => panic!("an unrepresentable budget must fail synchronously"),
4384        }
4385    }
4386
4387    #[test]
4388    fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
4389        for failure in ["visible", "revision", "commit"] {
4390            let mut client = client();
4391            let id = client
4392                .mutate(vec![Mutation::Upsert {
4393                    table: "tasks".into(),
4394                    values: Map::from_iter([
4395                        ("id".into(), Value::from("t1")),
4396                        ("project_id".into(), Value::from("pending")),
4397                    ]),
4398                    base_version: None,
4399                }])
4400                .unwrap();
4401            client.drain_change_batches();
4402            let revision = client.local_revision();
4403            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
4404            let trigger = match failure {
4405                "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
4406                "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"),
4407                _ => "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(),
4408            };
4409            client.conn.execute_batch(&trigger).unwrap();
4410            let change = ssp2::model::Change {
4411                table_index: 0,
4412                row_id: "t1".into(),
4413                op: Op::Upsert,
4414                row_version: Some(2),
4415                scopes: vec![],
4416                row: Some(
4417                    encode_row_json(
4418                        client.schema.table("tasks").unwrap(),
4419                        "t1",
4420                        &Map::from_iter([
4421                            ("id".into(), Value::from("t1")),
4422                            ("project_id".into(), Value::from("remote")),
4423                        ]),
4424                        &client.encryption,
4425                    )
4426                    .unwrap(),
4427                ),
4428            };
4429            assert!(client
4430                .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), None)
4431                .is_err());
4432            assert!(client.conn.is_autocommit());
4433            assert!(!client.overlay_dirty.get());
4434            assert_eq!(client.local_revision(), revision);
4435            assert!(client.drain_change_batches().is_empty());
4436            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
4437            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
4438            assert!(client
4439                .query("SELECT * FROM _syncular_base_tasks", &[])
4440                .unwrap()
4441                .is_empty());
4442            client
4443                .conn
4444                .execute_batch("DROP TRIGGER fail_pending")
4445                .unwrap();
4446            client
4447                .apply_commit_frame(&["tasks".into()], &[change], None)
4448                .unwrap();
4449            assert_eq!(client.local_revision(), revision + 1);
4450            assert_eq!(client.pending_commit_ids(), vec![id]);
4451            // The overlay keeps the pending write visible; the row now carries
4452            // the server row's version it is layered on (§7.1).
4453            let mut expected = before[0].clone();
4454            expected.insert("_syncular_version".to_owned(), Value::from(2));
4455            assert_eq!(
4456                client.query("SELECT * FROM tasks", &[]).unwrap(),
4457                vec![expected]
4458            );
4459            assert_eq!(
4460                client
4461                    .conn
4462                    .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
4463                        .get::<_, String>(0))
4464                    .unwrap(),
4465                "remote"
4466            );
4467        }
4468    }
4469
4470    #[test]
4471    fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
4472        let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
4473            "columns": [
4474                {"name":"id", "type":"string", "nullable":false},
4475                {"name":"project_id", "type":"string", "nullable":false},
4476                {"name":"title", "type":"string", "nullable":false}],
4477            "scopes": [{"pattern":"project:{project_id}"}],
4478            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
4479            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
4480        let mut incremental =
4481            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
4482        let mut reference =
4483            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
4484        for instance in [&mut incremental, &mut reference] {
4485            for index in 0..16 {
4486                instance
4487                    .write_base_row(
4488                        "tasks",
4489                        &vec![
4490                            Some(ColumnValue::String(format!("task{index}"))),
4491                            Some(ColumnValue::String("p0".to_owned())),
4492                            Some(ColumnValue::String(format!("base{index}"))),
4493                        ],
4494                        1,
4495                    )
4496                    .unwrap();
4497            }
4498            instance
4499                .rebuild_overlay_if_dirty()
4500                .expect("rebuild overlay");
4501            instance.overlay_rebuild_count.set(0);
4502        }
4503        for index in 0..100 {
4504            let mutations = (0..1 + index % 4)
4505                .map(|offset| {
4506                    let id = format!("task{}", (index + offset) % 24);
4507                    if (index + offset) % 7 == 0 {
4508                        Mutation::Delete {
4509                            table: "tasks".to_owned(),
4510                            row_id: id,
4511                            base_version: None,
4512                        }
4513                    } else {
4514                        Mutation::Upsert {
4515                            table: "tasks".to_owned(),
4516                            values: Map::from_iter([
4517                                ("id".to_owned(), Value::from(id)),
4518                                (
4519                                    "project_id".to_owned(),
4520                                    Value::from(format!("p{}", index % 2)),
4521                                ),
4522                                (
4523                                    "title".to_owned(),
4524                                    Value::from(format!("needle{}", (index + offset) % 24)),
4525                                ),
4526                            ]),
4527                            base_version: None,
4528                        }
4529                    }
4530                })
4531                .collect::<Vec<_>>();
4532            reference.overlay_dirty.set(true);
4533            incremental.mutate(mutations.clone()).unwrap();
4534            reference.mutate(mutations).unwrap();
4535            for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
4536                "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
4537                assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
4538            }
4539            assert_eq!(incremental.local_revision(), reference.local_revision());
4540            assert_eq!(
4541                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4542                serde_json::to_value(reference.drain_change_batches()).unwrap()
4543            );
4544        }
4545        assert_eq!(incremental.outbox.len(), 100);
4546        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
4547        assert_eq!(reference.overlay_rebuild_count.get(), 100);
4548    }
4549
4550    #[test]
4551    fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
4552        for fail_at in ["outbox", "revision"] {
4553            let mut client = client();
4554            let values = Map::from_iter([
4555                ("id".to_owned(), Value::from("t1")),
4556                ("project_id".to_owned(), Value::from("p1")),
4557            ]);
4558            let mutation = Mutation::Upsert {
4559                table: "tasks".to_owned(),
4560                values,
4561                base_version: None,
4562            };
4563            let trigger = if fail_at == "outbox" {
4564                "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
4565            } else {
4566                format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
4567            };
4568            client.conn.execute_batch(&trigger).unwrap();
4569            let revision = client.local_revision();
4570            assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
4571            assert_eq!(client.local_revision(), revision);
4572            assert!(client.outbox.is_empty());
4573            assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
4574            assert_eq!(
4575                client
4576                    .conn
4577                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
4578                        .get::<_, i64>(0))
4579                    .unwrap(),
4580                0
4581            );
4582            assert!(client.drain_change_batches().is_empty());
4583            assert!(!client.overlay_dirty.get());
4584            client
4585                .conn
4586                .execute_batch("DROP TRIGGER fail_append")
4587                .unwrap();
4588            client.mutate(vec![mutation]).unwrap();
4589            assert_eq!(client.outbox.len(), 1);
4590            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
4591        }
4592    }
4593
4594    #[test]
4595    fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
4596        let mut client = client();
4597        let tables = vec!["tasks".to_owned(), "missing".to_owned()];
4598        let values = Map::from_iter([
4599            ("id".to_owned(), Value::from("t1")),
4600            ("project_id".to_owned(), Value::from("p1")),
4601        ]);
4602        let payload = encode_row_json(
4603            client.schema.table("tasks").unwrap(),
4604            "t1",
4605            &values,
4606            &client.encryption,
4607        )
4608        .unwrap();
4609        let first = ssp2::model::Change {
4610            table_index: 0,
4611            row_id: "t1".to_owned(),
4612            op: Op::Upsert,
4613            row_version: Some(1),
4614            scopes: vec![("project_id".to_owned(), "p1".to_owned())],
4615            row: Some(payload),
4616        };
4617        let mut invalid = first.clone();
4618        invalid.table_index = 1;
4619        let revision = client.local_revision();
4620        client.begin_observation("failed_mirror").unwrap();
4621        assert!(client
4622            .apply_commit_changes(&tables, &[first, invalid])
4623            .is_err());
4624        client.rollback_observation("failed_mirror");
4625        client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4626        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
4627        assert_eq!(
4628            client
4629                .conn
4630                .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
4631                    .get::<_, i64>(
4632                    0
4633                ))
4634                .unwrap(),
4635            0
4636        );
4637        assert_eq!(client.local_revision(), revision);
4638        assert!(client.drain_change_batches().is_empty());
4639    }
4640
4641    #[test]
4642    fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
4643        for failure in ["journal", "revision", "commit", "later-frame"] {
4644            let mut client = client();
4645            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4646            for id in ["first", "second"] {
4647                client
4648                    .mutate(vec![Mutation::Upsert {
4649                        table: "tasks".to_owned(),
4650                        values: Map::from_iter([
4651                            ("id".to_owned(), Value::from(id)),
4652                            ("project_id".to_owned(), Value::from("p1")),
4653                        ]),
4654                        base_version: None,
4655                    }])
4656                    .unwrap();
4657            }
4658            let (_, meta) = client.build_request(false).unwrap();
4659            // A malicious or stale response must not drain an unsent local commit.
4660            client
4661                .mutate(vec![Mutation::Upsert {
4662                    table: "tasks".to_owned(),
4663                    values: Map::from_iter([
4664                        ("id".to_owned(), Value::from("later")),
4665                        ("project_id".to_owned(), Value::from("p1")),
4666                    ]),
4667                    base_version: None,
4668                }])
4669                .unwrap();
4670            let ids = client.pending_commit_ids();
4671            let rows = client
4672                .query("SELECT * FROM tasks ORDER BY id", &[])
4673                .unwrap();
4674            let revision = client.local_revision();
4675            client.drain_change_batches();
4676            if failure != "later-frame" {
4677                client.conn.execute_batch(match failure {
4678                    "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",
4679                    "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",
4680                    _ => "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",
4681                }).unwrap();
4682            }
4683            let mut response = Message {
4684                wire_version: WIRE_VERSION,
4685                msg_kind: MsgKind::Response,
4686                frames: vec![Frame::RespHeader {
4687                    required_schema_version: None,
4688                    latest_schema_version: None,
4689                    log_epoch: Some("epoch-1".to_owned()),
4690                    reset_required: Some(false),
4691                }],
4692            };
4693            for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
4694                response.frames.push(Frame::PushResult {
4695                    client_commit_id: id.to_owned(),
4696                    status: PushStatus::Applied,
4697                    commit_seq: Some(1),
4698                    results: vec![OpResult::Applied { op_index: 0 }],
4699                });
4700            }
4701            if failure == "later-frame" {
4702                response.frames.push(Frame::Error {
4703                    code: "sync.invalid_request".to_owned(),
4704                    message: "later error".to_owned(),
4705                    category: "protocol".to_owned(),
4706                    retryable: false,
4707                    recommended_action: "retry".to_owned(),
4708                    details: None,
4709                });
4710            }
4711            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
4712            let outcome = client.process_response(&mut transport, response.clone(), &meta);
4713            // §1.4 rule 4: the deferred foreign key of "commit" fails the
4714            // response's coalesced COMMIT instead of the run's RELEASE.
4715            let expected = match failure {
4716                "later-frame" => "sync.invalid_request",
4717                "commit" => "storage.failed",
4718                _ => "client.outcome_persistence_failed",
4719            };
4720            assert!(
4721                matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
4722                "{failure}: {outcome:?}"
4723            );
4724            if failure == "later-frame" {
4725                assert_eq!(client.pending_commit_ids(), ids[2..]);
4726                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
4727                assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
4728                let batches = client.drain_change_batches();
4729                assert_eq!(batches.len(), 1);
4730                assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
4731                assert_eq!(client.local_revision(), revision + 1);
4732                response.frames.pop();
4733            } else {
4734                assert_eq!(client.pending_commit_ids(), ids);
4735                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
4736                assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
4737                assert_eq!(client.local_revision(), revision);
4738                assert!(client.drain_change_batches().is_empty());
4739                assert_eq!(
4740                    client
4741                        .query("SELECT * FROM tasks ORDER BY id", &[])
4742                        .unwrap(),
4743                    rows
4744                );
4745                client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
4746            }
4747            for frame in &mut response.frames {
4748                if let Frame::PushResult { status, .. } = frame {
4749                    *status = PushStatus::Cached;
4750                }
4751            }
4752            let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
4753            else {
4754                panic!("retry failed");
4755            };
4756            assert_eq!(
4757                report.applied,
4758                if failure == "later-frame" {
4759                    vec![]
4760                } else {
4761                    ids[..2].to_vec()
4762                }
4763            );
4764            assert_eq!(client.pending_commit_ids(), ids[2..]);
4765            let batches = client.drain_change_batches();
4766            assert_eq!(batches.len(), usize::from(failure != "later-frame"));
4767            if let Some(batch) = batches.first() {
4768                assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
4769            }
4770            assert_eq!(client.local_revision(), revision + 1);
4771        }
4772    }
4773
4774    #[test]
4775    fn failed_push_result_preserves_memory_disk_and_retry_identity() {
4776        for failure in ["revision", "journal", "commit"] {
4777            for status in ["applied", "cached", "rejected", "conflict"] {
4778                let mut client = client();
4779                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4780                for id in ["first", "later"] {
4781                    client
4782                        .mutate(vec![Mutation::Upsert {
4783                            table: "tasks".to_owned(),
4784                            values: Map::from_iter([
4785                                ("id".to_owned(), Value::from(id)),
4786                                ("project_id".to_owned(), Value::from("p1")),
4787                            ]),
4788                            base_version: None,
4789                        }])
4790                        .unwrap();
4791                }
4792                let (_, meta) = client.build_request(false).unwrap();
4793                let ids = client.pending_commit_ids();
4794                let rows = client
4795                    .query("SELECT * FROM tasks ORDER BY id", &[])
4796                    .unwrap();
4797                let revision = client.local_revision();
4798                client.drain_change_batches();
4799                let (push_status, results) = match status {
4800                    "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
4801                    "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
4802                    "conflict" => (
4803                        PushStatus::Rejected,
4804                        vec![OpResult::Conflict {
4805                            op_index: 0,
4806                            code: "sync.version_conflict".to_owned(),
4807                            message: "conflict".to_owned(),
4808                            server_version: 1,
4809                            server_row: encode_row_json(
4810                                client.schema.table("tasks").unwrap(),
4811                                "first",
4812                                &Map::from_iter([
4813                                    ("id".to_owned(), Value::from("first")),
4814                                    ("project_id".to_owned(), Value::from("p1")),
4815                                ]),
4816                                &client.encryption,
4817                            )
4818                            .unwrap(),
4819                            conflict_columns: Vec::new(),
4820                        }],
4821                    ),
4822                    _ => (
4823                        PushStatus::Rejected,
4824                        vec![OpResult::Error {
4825                            op_index: 0,
4826                            code: "sync.validation_failed".to_owned(),
4827                            message: "rejected".to_owned(),
4828                            retryable: false,
4829                        }],
4830                    ),
4831                };
4832                let response = Message {
4833                    wire_version: WIRE_VERSION,
4834                    msg_kind: MsgKind::Response,
4835                    frames: vec![
4836                        Frame::RespHeader {
4837                            required_schema_version: None,
4838                            latest_schema_version: None,
4839                            log_epoch: Some("epoch-1".to_owned()),
4840                            reset_required: Some(false),
4841                        },
4842                        Frame::PushResult {
4843                            client_commit_id: ids[0].clone(),
4844                            status: push_status,
4845                            commit_seq: Some(1),
4846                            results,
4847                        },
4848                    ],
4849                };
4850                client.conn.execute_batch(match failure {
4851                    "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
4852                    "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
4853                    _ => "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",
4854                }).unwrap();
4855                let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
4856                let outcome = client.process_response(&mut transport, response.clone(), &meta);
4857                assert_eq!(
4858                    client.pending_commit_ids(),
4859                    ids,
4860                    "{failure}/{status}: pending identity"
4861                );
4862                assert_eq!(
4863                    client.local_revision(),
4864                    revision,
4865                    "{failure}/{status}: revision"
4866                );
4867                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
4868                assert!(client.conflicts().is_empty());
4869                assert!(client.rejections().is_empty());
4870                assert!(client.drain_change_batches().is_empty());
4871                assert_eq!(
4872                    client
4873                        .query("SELECT * FROM tasks ORDER BY id", &[])
4874                        .unwrap(),
4875                    rows
4876                );
4877                // §1.4 rule 4: the deferred foreign key of "commit" fails the
4878                // response's coalesced COMMIT instead of the result's RELEASE.
4879                let expected = if failure == "commit" {
4880                    "storage.failed"
4881                } else {
4882                    "client.outcome_persistence_failed"
4883                };
4884                assert!(
4885                    matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
4886                    "{failure}/{status}: failed round"
4887                );
4888                client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
4889                assert!(matches!(
4890                    client.process_response(&mut transport, response, &meta),
4891                    SyncOutcome::Ok(_)
4892                ));
4893                assert_eq!(client.pending_commit_ids(), ids[1..]);
4894                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
4895                assert_eq!(client.drain_change_batches().len(), 1);
4896            }
4897        }
4898    }
4899
4900    #[test]
4901    fn mixed_push_results_reconcile_each_boundary_and_prune_once_per_response() {
4902        let mut client = client();
4903        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4904        for index in 0..4 {
4905            client
4906                .mutate(vec![Mutation::Upsert {
4907                    table: "tasks".to_owned(),
4908                    values: Map::from_iter([
4909                        ("id".to_owned(), Value::from(format!("task-{index}"))),
4910                        ("project_id".to_owned(), Value::from("project-1")),
4911                    ]),
4912                    base_version: None,
4913                }])
4914                .expect("queue commit");
4915        }
4916
4917        let (_, request_meta) = client.build_request(false).unwrap();
4918        let ids = &request_meta.pushed_ids;
4919        assert_eq!(ids.len(), 4);
4920        let response = Message {
4921            wire_version: WIRE_VERSION,
4922            msg_kind: MsgKind::Response,
4923            frames: vec![
4924                Frame::RespHeader {
4925                    required_schema_version: None,
4926                    latest_schema_version: None,
4927                    log_epoch: Some("epoch-1".to_owned()),
4928                    reset_required: Some(false),
4929                },
4930                Frame::PushResult {
4931                    client_commit_id: ids[0].clone(),
4932                    status: PushStatus::Applied,
4933                    commit_seq: Some(1),
4934                    results: vec![OpResult::Applied { op_index: 0 }],
4935                },
4936                Frame::PushResult {
4937                    client_commit_id: ids[1].clone(),
4938                    status: PushStatus::Cached,
4939                    commit_seq: Some(2),
4940                    results: vec![OpResult::Applied { op_index: 0 }],
4941                },
4942                Frame::PushResult {
4943                    client_commit_id: ids[2].clone(),
4944                    status: PushStatus::Rejected,
4945                    commit_seq: None,
4946                    results: vec![OpResult::Error {
4947                        op_index: 0,
4948                        code: "sync.validation_failed".to_owned(),
4949                        message: "rejected".to_owned(),
4950                        retryable: false,
4951                    }],
4952                },
4953                Frame::PushResult {
4954                    client_commit_id: ids[3].clone(),
4955                    status: PushStatus::Rejected,
4956                    commit_seq: None,
4957                    results: vec![OpResult::Error {
4958                        op_index: 0,
4959                        code: "sync.idempotency_cache_miss".to_owned(),
4960                        message: "retry".to_owned(),
4961                        retryable: true,
4962                    }],
4963                },
4964            ],
4965        };
4966        let mut transport =
4967            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
4968
4969        client.overlay_rebuild_count.set(0);
4970        client.outcome_prune_count.set(0);
4971        let outcome = client.process_response(&mut transport, response, &request_meta);
4972        let SyncOutcome::Ok(report) = outcome else {
4973            panic!("mixed push-result response failed");
4974        };
4975
4976        assert_eq!(report.applied, ids[..2]);
4977        assert_eq!(report.rejected, ids[2..3]);
4978        assert_eq!(report.retryable, ids[3..4]);
4979        assert_eq!(client.pending_commit_ids(), ids[3..4]);
4980        assert_eq!(
4981            client
4982                .query("SELECT id FROM tasks ORDER BY id", &[])
4983                .expect("query visible overlay"),
4984            ["task-0", "task-1", "task-3"]
4985                .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
4986                .to_vec()
4987        );
4988        // The successful ACK run and final rejection have distinct durable boundaries.
4989        assert_eq!(client.overlay_rebuild_count.get(), 2);
4990        assert_eq!(client.outcome_prune_count.get(), 1);
4991    }
4992
4993    fn replay_schema(fts: bool) -> Value {
4994        json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
4995            {"name":"id","type":"string","nullable":false},
4996            {"name":"project_id","type":"string","nullable":false},
4997            {"name":"title","type":"string","nullable":false},
4998            {"name":"done","type":"boolean","nullable":false}
4999        ],"scopes":[{"pattern":"project:{project_id}"}], "ftsIndexes": if fts { json!([{"name":"tasks_fts","columns":["title"],"tokenize":"unicode61"}]) } else { json!([]) }}]})
5000    }
5001
5002    #[test]
5003    fn aborted_schema_and_epoch_resets_restore_memory_and_durable_intent() {
5004        for schema_reset in [false, true] {
5005            let mut schema = replay_schema(false);
5006            let mut client =
5007                SyncClient::new("reset-failure".into(), &schema, ClientLimits::default()).unwrap();
5008            let id = client
5009                .mutate(vec![Mutation::Upsert {
5010                    table: "tasks".into(),
5011                    values: Map::from_iter([
5012                        ("id".into(), json!("queued")),
5013                        ("project_id".into(), json!("p1")),
5014                        ("title".into(), json!("old")),
5015                        ("done".into(), json!(false)),
5016                    ]),
5017                    base_version: None,
5018                }])
5019                .unwrap();
5020            client.set_meta(LOG_EPOCH_KEY, "old");
5021            let revision = client.local_revision();
5022            let needed = client.sync_needed;
5023            client.drain_change_batches();
5024            client.drain_sync_intents();
5025            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();
5026            if schema_reset {
5027                schema["version"] = json!(2);
5028                schema["tables"][0]["columns"]
5029                    .as_array_mut()
5030                    .unwrap()
5031                    .retain(|column| column["name"] != "title");
5032                client.schema = parse_schema_json(&schema).unwrap();
5033                assert!(client.run_schema_reset().is_err());
5034            } else {
5035                assert!(client.run_log_epoch_reset("new").is_err());
5036            }
5037            assert!(!client.upgrading);
5038            assert_eq!(client.sync_needed, needed);
5039            assert_eq!(client.local_revision(), revision);
5040            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5041            assert!(client.rejections.is_empty());
5042            assert!(client.commit_outcome(&id).unwrap().is_none());
5043            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("old"));
5044            assert_eq!(
5045                client
5046                    .query("SELECT title FROM tasks WHERE id='queued'", &[])
5047                    .unwrap()[0]["title"],
5048                "old"
5049            );
5050            assert_eq!(
5051                client
5052                    .conn
5053                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5054                        .get::<_, i64>(0))
5055                    .unwrap(),
5056                1
5057            );
5058            assert!(client.drain_change_batches().is_empty());
5059            assert!(client.drain_sync_intents().is_empty());
5060            assert!(client.conn.is_autocommit());
5061        }
5062    }
5063
5064    #[test]
5065    fn cached_blob_cleanup_propagates_delete_failures() {
5066        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
5067            {"name":"id","type":"string","nullable":false},
5068            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
5069        let mut client =
5070            SyncClient::new("blob-cleanup".into(), &schema, ClientLimits::default()).unwrap();
5071        client.conn.execute_batch("CREATE TRIGGER refuse_blob_cleanup BEFORE DELETE ON _syncular_blobs BEGIN SELECT RAISE(ABORT,'blob cleanup failed'); END;").unwrap();
5072        client.conn.execute("INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,created_at_ms) VALUES ('test',X'01',1,0)", []).unwrap();
5073        client
5074            .subscribe("attachments".into(), "attachments".into(), vec![], None)
5075            .unwrap();
5076        client.subs[0].effective = Some(vec![]);
5077        client.persist_sub(&client.subs[0]).unwrap();
5078        let revision = client.local_revision();
5079        let (_, meta) = client.build_request(false).unwrap();
5080        let mut transport = CountingRealtimeTransport::default();
5081        let mut report = SyncReport::default();
5082        assert!(client
5083            .process_section(
5084                &mut transport,
5085                "attachments",
5086                SubStatus::Revoked,
5087                "sync.scope_revoked",
5088                vec![],
5089                vec![],
5090                Some((0, None)),
5091                &meta,
5092                &mut report
5093            )
5094            .is_err());
5095        assert_eq!(client.subs[0].state, SubState::Active);
5096        assert_eq!(client.local_revision(), revision);
5097        assert!(report.revoked.is_empty());
5098        assert!(client.conn.is_autocommit());
5099        client
5100            .conn
5101            .execute_batch("DROP TRIGGER refuse_blob_cleanup")
5102            .unwrap();
5103        assert!(client
5104            .process_section(
5105                &mut transport,
5106                "attachments",
5107                SubStatus::Revoked,
5108                "sync.scope_revoked",
5109                vec![],
5110                vec![],
5111                Some((0, None)),
5112                &meta,
5113                &mut report
5114            )
5115            .is_ok());
5116        assert_eq!(client.subs[0].state, SubState::Revoked);
5117        assert_eq!(
5118            client
5119                .conn
5120                .query_row("SELECT count(*) FROM _syncular_blobs", [], |row| row
5121                    .get::<_, i64>(0))
5122                .unwrap(),
5123            0
5124        );
5125    }
5126
5127    #[test]
5128    fn overlay_replay_trigger_refuses_reopen_and_preserves_durable_intent() {
5129        let path =
5130            std::env::temp_dir().join(format!("syncular-replay-{}.db", uuid::Uuid::new_v4()));
5131        let schema = replay_schema(false);
5132        let mut client = SyncClient::open_path(
5133            "replay".into(),
5134            &schema,
5135            ClientLimits::default(),
5136            path.to_str().unwrap(),
5137        )
5138        .unwrap();
5139        let ids = ["early", "bad"].map(|id| {
5140            client
5141                .mutate(vec![Mutation::Upsert {
5142                    table: "tasks".into(),
5143                    values: Map::from_iter([
5144                        ("id".into(), json!(id)),
5145                        ("project_id".into(), json!("p1")),
5146                        ("title".into(), json!(id)),
5147                        ("done".into(), json!(false)),
5148                    ]),
5149                    base_version: None,
5150                }])
5151                .unwrap()
5152        });
5153        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();
5154        let revision = client.local_revision();
5155        drop(client);
5156        let error = SyncClient::open_path(
5157            "replay".into(),
5158            &schema,
5159            ClientLimits::default(),
5160            path.to_str().unwrap(),
5161        )
5162        .err()
5163        .expect("reopen must fail");
5164        assert!(error.contains("replay failed"));
5165        let conn = Connection::open(&path).unwrap();
5166        assert_eq!(
5167            conn.query_row("SELECT count(*) FROM tasks", [], |row| row.get::<_, i64>(0))
5168                .unwrap(),
5169            0
5170        );
5171        assert_eq!(
5172            conn.query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5173                .get::<_, i64>(0))
5174                .unwrap(),
5175            2
5176        );
5177        assert_eq!(
5178            meta_get(&conn, LOCAL_REVISION_KEY),
5179            Some(revision.to_string())
5180        );
5181        conn.execute_batch("DROP TRIGGER fail_replay").unwrap();
5182        drop(conn);
5183        let reopened = SyncClient::open_path(
5184            "replay".into(),
5185            &schema,
5186            ClientLimits::default(),
5187            path.to_str().unwrap(),
5188        )
5189        .unwrap();
5190        assert_eq!(reopened.pending_commit_ids(), ids);
5191        assert_eq!(
5192            reopened
5193                .query("SELECT id FROM tasks ORDER BY id", &[])
5194                .unwrap()
5195                .len(),
5196            2
5197        );
5198        drop(reopened);
5199        std::fs::remove_file(path).unwrap();
5200    }
5201
5202    #[test]
5203    fn overlay_replay_failure_rolls_back_push_and_pull_apply_boundaries() {
5204        let schema = replay_schema(false);
5205        let mut client =
5206            SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
5207        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5208        client
5209            .write_base_row(
5210                "tasks",
5211                &vec![
5212                    Some(ColumnValue::String("queued".into())),
5213                    Some(ColumnValue::String("p1".into())),
5214                    Some(ColumnValue::String("server".into())),
5215                    Some(ColumnValue::Boolean(false)),
5216                ],
5217                1,
5218            )
5219            .unwrap();
5220        client.rebuild_overlay().unwrap();
5221        let id = client
5222            .patch(
5223                "tasks",
5224                "queued",
5225                Map::from_iter([("title".into(), json!("local"))]),
5226                Some(1),
5227            )
5228            .unwrap();
5229        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
5230        let base = client
5231            .query("SELECT * FROM _syncular_base_tasks", &[])
5232            .unwrap();
5233        let revision = client.local_revision();
5234        client.drain_change_batches();
5235        client.conn.execute_batch("CREATE TRIGGER fail_replay BEFORE INSERT ON tasks WHEN new.title='local' BEGIN SELECT RAISE(ABORT,'replay failed'); END").unwrap();
5236        let remote = vec![
5237            Some(ColumnValue::String("queued".into())),
5238            Some(ColumnValue::String("p1".into())),
5239            Some(ColumnValue::String("remote".into())),
5240            Some(ColumnValue::Boolean(false)),
5241        ];
5242        let bytes = crate::values::encode_row_json(
5243            client.schema.table("tasks").unwrap(),
5244            "queued",
5245            &Map::from_iter(
5246                client
5247                    .schema
5248                    .table("tasks")
5249                    .unwrap()
5250                    .columns
5251                    .iter()
5252                    .zip(&remote)
5253                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value))),
5254            ),
5255            &client.encryption,
5256        )
5257        .unwrap();
5258        let change = ssp2::model::Change {
5259            table_index: 0,
5260            op: Op::Upsert,
5261            row_id: "queued".into(),
5262            row_version: Some(2),
5263            scopes: vec![("project_id".into(), "p1".into())],
5264            row: Some(bytes),
5265        };
5266        assert!(client
5267            .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), Some(2))
5268            .is_err());
5269        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
5270        assert_eq!(
5271            client
5272                .query("SELECT * FROM _syncular_base_tasks", &[])
5273                .unwrap(),
5274            base
5275        );
5276        assert_eq!(client.local_revision(), revision);
5277        assert!(client.drain_change_batches().is_empty());
5278        let response = Message {
5279            wire_version: 2,
5280            msg_kind: MsgKind::Response,
5281            frames: vec![
5282                Frame::RespHeader {
5283                    required_schema_version: None,
5284                    latest_schema_version: None,
5285                    log_epoch: Some("epoch-1".into()),
5286                    reset_required: Some(false),
5287                },
5288                Frame::PushResult {
5289                    client_commit_id: id.clone(),
5290                    status: PushStatus::Applied,
5291                    commit_seq: Some(2),
5292                    results: vec![OpResult::Applied { op_index: 0 }],
5293                },
5294            ],
5295        };
5296        let (_, meta) = client.build_request(false).unwrap();
5297        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5298        assert!(
5299            matches!(client.process_response(&mut transport,response.clone(),&meta),SyncOutcome::Failed { error_code,.. } if error_code=="client.outcome_persistence_failed")
5300        );
5301        assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5302        assert!(client.commit_outcome(&id).unwrap().is_none());
5303        assert_eq!(
5304            client
5305                .conn
5306                .query_row("SELECT count(*) FROM _syncular_acked_rows", [], |row| row
5307                    .get::<_, i64>(
5308                    0
5309                ))
5310                .unwrap(),
5311            0
5312        );
5313        assert_eq!(
5314            client
5315                .conn
5316                .query_row("SELECT count(*) FROM _syncular_row_deliveries", [], |row| {
5317                    row.get::<_, i64>(0)
5318                })
5319                .unwrap(),
5320            0
5321        );
5322        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
5323        assert_eq!(client.local_revision(), revision);
5324        assert!(client.drain_change_batches().is_empty());
5325        client
5326            .conn
5327            .execute_batch("DROP TRIGGER fail_replay")
5328            .unwrap();
5329        assert!(matches!(
5330            client.process_response(&mut transport, response, &meta),
5331            SyncOutcome::Ok(_)
5332        ));
5333        client
5334            .apply_commit_frame(&["tasks".into()], &[change], Some(2))
5335            .unwrap();
5336        assert!(client.pending_commit_ids().is_empty());
5337        assert_eq!(
5338            client.query("SELECT title FROM tasks", &[]).unwrap()[0]["title"],
5339            json!("remote")
5340        );
5341    }
5342
5343    #[test]
5344    fn overlay_replay_distinguishes_absence_from_sql_and_value_decode_errors() {
5345        let client = SyncClient::new(
5346            "replay".into(),
5347            &replay_schema(false),
5348            ClientLimits::default(),
5349        )
5350        .unwrap();
5351        let table = client.schema.table("tasks").unwrap();
5352        let patch = OutboxOp {
5353            table: "tasks".into(),
5354            row_id: "absent".into(),
5355            upsert: true,
5356            base_version: Some(1),
5357            values: Some(Map::from_iter([
5358                ("id".into(), json!("absent")),
5359                ("title".into(), json!("patch")),
5360            ])),
5361        };
5362        assert!(client.visible_row(table, "absent").unwrap().is_none());
5363        client
5364            .apply_outbox_ops([&patch])
5365            .expect("sparse replay over truly absent row");
5366        client
5367            .conn
5368            .execute_batch("INSERT INTO tasks VALUES ('bad','p1','title','invalid',1)")
5369            .unwrap();
5370        assert!(client
5371            .visible_row(table, "bad")
5372            .unwrap_err()
5373            .starts_with("sync.local_corrupt:"));
5374        let mut invalid = patch.clone();
5375        invalid.values = Some(Map::from_iter([
5376            ("id".into(), json!("bad-value")),
5377            ("project_id".into(), json!("p1")),
5378            ("title".into(), json!("title")),
5379            ("done".into(), json!({"bad":true})),
5380        ]));
5381        assert!(client
5382            .apply_outbox_ops([&invalid])
5383            .unwrap_err()
5384            .starts_with("sync.local_corrupt:"));
5385        client.conn.execute_batch("DROP TABLE tasks").unwrap();
5386        assert!(
5387            client.apply_outbox_ops([&patch]).is_err(),
5388            "SQL failure must not count as absence"
5389        );
5390    }
5391
5392    #[test]
5393    fn overlay_replay_keeps_secondary_unique_deferral_and_lazy_base_precedence() {
5394        let mut schema = replay_schema(false);
5395        schema["tables"][0]["indexes"] =
5396            json!([{"name":"tasks_project_title", "columns":["project_id","title"],"unique":true}]);
5397        let mut client =
5398            SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
5399        let id = client
5400            .mutate(vec![Mutation::Upsert {
5401                table: "tasks".into(),
5402                values: Map::from_iter([
5403                    ("id".into(), json!("queued")),
5404                    ("project_id".into(), json!("p1")),
5405                    ("title".into(), json!("occupied")),
5406                    ("done".into(), json!(false)),
5407                ]),
5408                base_version: None,
5409            }])
5410            .unwrap();
5411        client
5412            .write_base_row(
5413                "tasks",
5414                &vec![
5415                    Some(ColumnValue::String("server".into())),
5416                    Some(ColumnValue::String("p1".into())),
5417                    Some(ColumnValue::String("occupied".into())),
5418                    Some(ColumnValue::Boolean(false)),
5419                ],
5420                1,
5421            )
5422            .unwrap();
5423        client.rebuild_overlay_if_dirty().unwrap();
5424        assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5425        assert!(client
5426            .visible_row(client.schema.table("tasks").unwrap(), "queued")
5427            .unwrap()
5428            .is_none());
5429        assert!(client.rejections().is_empty());
5430        client.delete_base_row("tasks", "server").unwrap();
5431        client.rebuild_overlay_if_dirty().unwrap();
5432        assert!(client
5433            .visible_row(client.schema.table("tasks").unwrap(), "queued")
5434            .unwrap()
5435            .is_some());
5436        client
5437            .write_base_row(
5438                "tasks",
5439                &vec![
5440                    Some(ColumnValue::String("base".into())),
5441                    Some(ColumnValue::String("p1".into())),
5442                    Some(ColumnValue::String("base-title".into())),
5443                    Some(ColumnValue::Boolean(false)),
5444                ],
5445                1,
5446            )
5447            .unwrap();
5448        client.conn.execute_batch("DROP TABLE tasks").unwrap();
5449        client
5450            .retain_failed_commit(
5451                "retained",
5452                &[OutboxOp {
5453                    table: "tasks".into(),
5454                    row_id: "base".into(),
5455                    upsert: true,
5456                    base_version: Some(1),
5457                    values: Some(Map::from_iter([
5458                        ("id".into(), json!("base")),
5459                        ("title".into(), json!("patch")),
5460                    ])),
5461                }],
5462            )
5463            .expect("an existing base must not read the unavailable visible table");
5464    }
5465
5466    #[test]
5467    fn overlay_replay_fts_and_savepoint_release_failures_roll_back_all_visible_changes() {
5468        for failure in ["fts", "release"] {
5469            let mut client = SyncClient::new(
5470                "replay".into(),
5471                &replay_schema(true),
5472                ClientLimits::default(),
5473            )
5474            .unwrap();
5475            client
5476                .mutate(vec![Mutation::Upsert {
5477                    table: "tasks".into(),
5478                    values: Map::from_iter([
5479                        ("id".into(), json!("queued")),
5480                        ("project_id".into(), json!("p1")),
5481                        ("title".into(), json!("needle")),
5482                        ("done".into(), json!(false)),
5483                    ]),
5484                    base_version: None,
5485                }])
5486                .unwrap();
5487            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
5488            let fts_before = client
5489                .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
5490                .unwrap();
5491            let triggers_before: Vec<String> = client
5492                .conn
5493                .prepare("SELECT sql FROM sqlite_master WHERE type='trigger' ORDER BY name")
5494                .unwrap()
5495                .query_map([], |row| row.get(0))
5496                .unwrap()
5497                .collect::<Result<_, _>>()
5498                .unwrap();
5499            if failure == "fts" {
5500                client.conn.execute_batch("CREATE TRIGGER fail_fts BEFORE INSERT ON _syncular_fts_tasks_fts BEGIN SELECT RAISE(ABORT,'fts replay failed'); END;").unwrap();
5501            } else {
5502                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();
5503            }
5504            client.overlay_dirty.set(true);
5505            assert!(client.rebuild_overlay().is_err());
5506            assert!(client.overlay_dirty.get());
5507            assert!(client.conn.is_autocommit());
5508            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
5509            assert_eq!(
5510                client
5511                    .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
5512                    .unwrap(),
5513                fts_before
5514            );
5515            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();
5516            assert_eq!(original_triggers, triggers_before);
5517            assert_eq!(client.pending_commit_ids().len(), 1);
5518            client
5519                .conn
5520                .execute_batch(if failure == "fts" {
5521                    "DROP TRIGGER fail_fts"
5522                } else {
5523                    "DROP TRIGGER fail_release"
5524                })
5525                .unwrap();
5526            client
5527                .rebuild_overlay()
5528                .expect("retry after removing the fault");
5529        }
5530    }
5531
5532    #[test]
5533    fn secondary_unique_collision_preserves_existing_synced_row() {
5534        let client = SyncClient::new(
5535            "unique-upsert-test".to_owned(),
5536            &json!({
5537                "version": 1,
5538                "tables": [{
5539                    "name": "tasks",
5540                    "primaryKey": "id",
5541                    "columns": [
5542                        { "name": "id", "type": "string", "nullable": false },
5543                        { "name": "project_id", "type": "string", "nullable": false },
5544                        { "name": "title", "type": "string", "nullable": false }
5545                    ],
5546                    "scopes": [{ "pattern": "project:{project_id}" }],
5547                    "indexes": [{
5548                        "name": "tasks_by_project_title",
5549                        "columns": ["project_id", "title"],
5550                        "unique": true
5551                    }]
5552                }]
5553            }),
5554            ClientLimits::default(),
5555        )
5556        .expect("test client");
5557        let table = client.schema.table("tasks").expect("tasks table");
5558        let sql = client.insert_row_sql(&base_table("tasks"), table);
5559
5560        client
5561            .conn
5562            .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
5563            .expect("insert first row");
5564        client
5565            .conn
5566            .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
5567            .expect("update same primary key");
5568        client
5569            .conn
5570            .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
5571            .expect("insert second row");
5572        assert!(client
5573            .conn
5574            .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
5575            .is_err());
5576
5577        let rows = client
5578            .conn
5579            .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
5580            .expect("prepare rows")
5581            .query_map([], |row| {
5582                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5583            })
5584            .expect("query rows")
5585            .collect::<Result<Vec<_>, _>>()
5586            .expect("collect rows");
5587        assert_eq!(
5588            rows,
5589            vec![
5590                ("t1".to_owned(), "updated".to_owned()),
5591                ("t2".to_owned(), "original".to_owned())
5592            ]
5593        );
5594    }
5595
5596    #[test]
5597    fn reopening_active_subscriptions_emits_a_catch_up_intent() {
5598        let path = std::env::temp_dir().join(format!(
5599            "syncular-startup-intent-{}.db",
5600            uuid::Uuid::new_v4()
5601        ));
5602        let schema = json!({
5603            "version": 1,
5604            "tables": [{
5605                "name": "tasks",
5606                "primaryKey": "id",
5607                "columns": [
5608                    { "name": "id", "type": "string", "nullable": false },
5609                    { "name": "project_id", "type": "string", "nullable": false }
5610                ],
5611                "scopes": [{ "pattern": "project:{project_id}" }]
5612            }]
5613        });
5614
5615        {
5616            let mut first = SyncClient::open_path_with_identity(
5617                None,
5618                &schema,
5619                ClientLimits::default(),
5620                path.to_str().expect("UTF-8 temp path"),
5621            )
5622            .expect("first open");
5623            first
5624                .set_window(
5625                    &WindowBase {
5626                        table: "tasks".to_owned(),
5627                        variable: "project_id".to_owned(),
5628                        fixed_scopes: Vec::new(),
5629                        params: None,
5630                    },
5631                    &["persisted".to_owned()],
5632                )
5633                .expect("persist window");
5634        }
5635
5636        let mut reopened = SyncClient::open_path_with_identity(
5637            None,
5638            &schema,
5639            ClientLimits::default(),
5640            path.to_str().expect("UTF-8 temp path"),
5641        )
5642        .expect("reopen");
5643        assert!(reopened.sync_needed());
5644        assert!(matches!(
5645            reopened.drain_sync_intents().as_slice(),
5646            [SyncIntent::Interactive]
5647        ));
5648        drop(reopened);
5649        std::fs::remove_file(path).expect("remove temp database");
5650    }
5651
5652    #[test]
5653    fn reopening_preserves_immutable_subscription_identity_and_progress() {
5654        let path = std::env::temp_dir().join(format!(
5655            "syncular-subscription-identity-{}.db",
5656            uuid::Uuid::new_v4()
5657        ));
5658        let schema = json!({
5659            "version": 1,
5660            "tables": [
5661                {
5662                    "name": "tasks",
5663                    "primaryKey": "id",
5664                    "columns": [
5665                        { "name": "id", "type": "string", "nullable": false },
5666                        { "name": "project_id", "type": "string", "nullable": false }
5667                    ],
5668                    "scopes": [{ "pattern": "project:{project_id}" }]
5669                },
5670                {
5671                    "name": "docs",
5672                    "primaryKey": "id",
5673                    "columns": [
5674                        { "name": "id", "type": "string", "nullable": false },
5675                        { "name": "org_id", "type": "string", "nullable": false },
5676                        { "name": "project_id", "type": "string", "nullable": false }
5677                    ],
5678                    "scopes": [
5679                        { "pattern": "org:{org_id}" },
5680                        { "pattern": "project:{projectId}", "column": "project_id" }
5681                    ]
5682                }
5683            ]
5684        });
5685
5686        {
5687            let mut first = SyncClient::open_path_with_identity(
5688                None,
5689                &schema,
5690                ClientLimits::default(),
5691                path.to_str().expect("UTF-8 temp path"),
5692            )
5693            .expect("first open");
5694            first
5695                .subscribe(
5696                    "stable-subscription".to_owned(),
5697                    "tasks".to_owned(),
5698                    vec![(
5699                        "project_id".to_owned(),
5700                        vec!["p2".to_owned(), "p1".to_owned()],
5701                    )],
5702                    Some(r#"{"view":"v1"}"#.to_owned()),
5703                )
5704                .expect("persist subscription");
5705            let persisted = {
5706                let subscription = first
5707                    .subs
5708                    .iter_mut()
5709                    .find(|subscription| subscription.id == "stable-subscription")
5710                    .expect("subscription");
5711                subscription.cursor = 41;
5712                subscription.bootstrap_state = Some("resume-token".to_owned());
5713                subscription.effective = Some(vec![(
5714                    "project_id".to_owned(),
5715                    vec!["p1".to_owned(), "p2".to_owned()],
5716                )]);
5717                subscription.synced_once = true;
5718                subscription.clone()
5719            };
5720            first.persist_sub(&persisted).unwrap();
5721        }
5722
5723        let mut reopened = SyncClient::open_path_with_identity(
5724            None,
5725            &schema,
5726            ClientLimits::default(),
5727            path.to_str().expect("UTF-8 temp path"),
5728        )
5729        .expect("reopen");
5730        let progress = reopened
5731            .subscription_state("stable-subscription")
5732            .expect("persisted state");
5733        assert_eq!(progress.cursor, 41);
5734        assert!(progress.has_resume_token);
5735
5736        reopened
5737            .subscribe(
5738                "stable-subscription".to_owned(),
5739                "tasks".to_owned(),
5740                vec![(
5741                    "project_id".to_owned(),
5742                    vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
5743                )],
5744                Some(r#"{"view":"v1"}"#.to_owned()),
5745            )
5746            .expect("canonical intent is idempotent");
5747        assert_eq!(
5748            reopened
5749                .subscription_state("stable-subscription")
5750                .expect("unchanged state")
5751                .cursor,
5752            progress.cursor
5753        );
5754
5755        for (table, scopes, params) in [
5756            (
5757                "tasks",
5758                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
5759                Some(r#"{"view":"v1"}"#.to_owned()),
5760            ),
5761            (
5762                "tasks",
5763                vec![(
5764                    "project_id".to_owned(),
5765                    vec!["p2".to_owned(), "p1".to_owned()],
5766                )],
5767                Some(r#"{"view":"v2"}"#.to_owned()),
5768            ),
5769            (
5770                "docs",
5771                vec![
5772                    ("org_id".to_owned(), vec!["o1".to_owned()]),
5773                    ("projectId".to_owned(), vec!["p1".to_owned()]),
5774                ],
5775                Some(r#"{"view":"v1"}"#.to_owned()),
5776            ),
5777        ] {
5778            let error = reopened
5779                .subscribe(
5780                    "stable-subscription".to_owned(),
5781                    table.to_owned(),
5782                    scopes,
5783                    params,
5784                )
5785                .expect_err("identity rebind must fail");
5786            assert!(error.starts_with("client.subscription_intent_mismatch:"));
5787            assert_eq!(
5788                reopened
5789                    .subscription_state("stable-subscription")
5790                    .expect("unchanged state")
5791                    .cursor,
5792                progress.cursor
5793            );
5794        }
5795
5796        drop(reopened);
5797        std::fs::remove_file(path).expect("remove temp database");
5798    }
5799
5800    #[test]
5801    fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
5802        let path = std::env::temp_dir().join(format!(
5803            "syncular-satisfied-schema-floor-{}.db",
5804            uuid::Uuid::new_v4()
5805        ));
5806        let schema = json!({
5807            "version": 23,
5808            "tables": [{
5809                "name": "tasks",
5810                "primaryKey": "id",
5811                "columns": [
5812                    { "name": "id", "type": "string", "nullable": false },
5813                    { "name": "project_id", "type": "string", "nullable": false }
5814                ],
5815                "scopes": [{ "pattern": "project:{project_id}" }]
5816            }]
5817        });
5818
5819        {
5820            let mut first = SyncClient::open_path_with_identity(
5821                None,
5822                &schema,
5823                ClientLimits::default(),
5824                path.to_str().expect("UTF-8 temp path"),
5825            )
5826            .expect("first open");
5827            first
5828                .subscribe(
5829                    "tasks".to_owned(),
5830                    "tasks".to_owned(),
5831                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
5832                    None,
5833                )
5834                .expect("persist subscription");
5835            first.set_schema_floor(Some(SchemaFloor {
5836                required_schema_version: Some(22),
5837                latest_schema_version: Some(22),
5838            }));
5839        }
5840
5841        let mut reopened = SyncClient::open_path_with_identity(
5842            None,
5843            &schema,
5844            ClientLimits::default(),
5845            path.to_str().expect("UTF-8 temp path"),
5846        )
5847        .expect("reopen");
5848        assert!(reopened.schema_floor().is_none());
5849        assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
5850        assert!(reopened.sync_needed());
5851        assert!(matches!(
5852            reopened.drain_sync_intents().as_slice(),
5853            [SyncIntent::Interactive]
5854        ));
5855        drop(reopened);
5856        std::fs::remove_file(path).expect("remove temp database");
5857    }
5858
5859    #[test]
5860    fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
5861        let path = std::env::temp_dir().join(format!(
5862            "syncular-unsatisfied-schema-floor-{}.db",
5863            uuid::Uuid::new_v4()
5864        ));
5865        let schema = json!({
5866            "version": 1,
5867            "tables": [{
5868                "name": "tasks",
5869                "primaryKey": "id",
5870                "columns": [
5871                    { "name": "id", "type": "string", "nullable": false },
5872                    { "name": "project_id", "type": "string", "nullable": false }
5873                ],
5874                "scopes": [{ "pattern": "project:{project_id}" }]
5875            }]
5876        });
5877
5878        {
5879            let mut first = SyncClient::open_path_with_identity(
5880                None,
5881                &schema,
5882                ClientLimits::default(),
5883                path.to_str().expect("UTF-8 temp path"),
5884            )
5885            .expect("first open");
5886            first
5887                .subscribe(
5888                    "tasks".to_owned(),
5889                    "tasks".to_owned(),
5890                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
5891                    None,
5892                )
5893                .expect("persist subscription");
5894            first.set_schema_floor(Some(SchemaFloor {
5895                required_schema_version: Some(2),
5896                latest_schema_version: Some(2),
5897            }));
5898        }
5899
5900        let reopened = SyncClient::open_path_with_identity(
5901            None,
5902            &schema,
5903            ClientLimits::default(),
5904            path.to_str().expect("UTF-8 temp path"),
5905        )
5906        .expect("reopen");
5907        assert_eq!(
5908            reopened.schema_floor(),
5909            Some(&SchemaFloor {
5910                required_schema_version: Some(2),
5911                latest_schema_version: Some(2),
5912            })
5913        );
5914        assert!(!reopened.sync_needed());
5915        drop(reopened);
5916        std::fs::remove_file(path).expect("remove temp database");
5917    }
5918
5919    #[test]
5920    fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
5921        let path = std::env::temp_dir().join(format!(
5922            "syncular-indexed-column-bump-{}.db",
5923            uuid::Uuid::new_v4()
5924        ));
5925        let old_schema = json!({
5926            "version": 1,
5927            "tables": [
5928                {
5929                    "name": "tasks",
5930                    "primaryKey": "id",
5931                    "columns": [
5932                        { "name": "id", "type": "string", "nullable": false },
5933                        { "name": "project_id", "type": "string", "nullable": false }
5934                    ],
5935                    "scopes": [{ "pattern": "project:{project_id}" }]
5936                },
5937                {
5938                    "name": "legacy",
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        });
5948        {
5949            let mut first = SyncClient::open_path_with_identity(
5950                None,
5951                &old_schema,
5952                ClientLimits::default(),
5953                path.to_str().expect("UTF-8 temp path"),
5954            )
5955            .expect("open old schema");
5956            first
5957                .subscribe(
5958                    "legacy-sub".to_owned(),
5959                    "legacy".to_owned(),
5960                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
5961                    None,
5962                )
5963                .expect("persist legacy subscription");
5964        }
5965
5966        let new_schema = json!({
5967            "version": 2,
5968            "tables": [{
5969                "name": "tasks",
5970                "primaryKey": "id",
5971                "columns": [
5972                    { "name": "id", "type": "string", "nullable": false },
5973                    { "name": "project_id", "type": "string", "nullable": false },
5974                    { "name": "facility_membership_id", "type": "string", "nullable": true }
5975                ],
5976                "scopes": [{ "pattern": "project:{project_id}" }],
5977                "indexes": [{
5978                    "name": "tasks_by_membership",
5979                    "columns": ["project_id", "facility_membership_id"],
5980                    "unique": false
5981                }]
5982            }]
5983        });
5984        let upgraded = SyncClient::open_path_with_identity(
5985            None,
5986            &new_schema,
5987            ClientLimits::default(),
5988            path.to_str().expect("UTF-8 temp path"),
5989        )
5990        .expect("open upgraded schema");
5991        let columns = upgraded
5992            .conn
5993            .prepare("PRAGMA table_info(tasks)")
5994            .expect("prepare columns")
5995            .query_map([], |row| row.get::<_, String>(1))
5996            .expect("query columns")
5997            .collect::<Result<Vec<_>, _>>()
5998            .expect("collect columns");
5999        assert!(columns.contains(&"facility_membership_id".to_owned()));
6000        let index_count: i64 = upgraded
6001            .conn
6002            .query_row(
6003                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
6004                [],
6005                |row| row.get(0),
6006            )
6007            .expect("query index");
6008        assert_eq!(index_count, 1);
6009        assert!(upgraded.subscription_state("legacy-sub").is_none());
6010        drop(upgraded);
6011        std::fs::remove_file(path).expect("remove temp database");
6012    }
6013
6014    #[test]
6015    fn migrates_pre_envelope_outcome_journal_additively() {
6016        let path = std::env::temp_dir().join(format!(
6017            "syncular-outcome-migration-{}.db",
6018            uuid::Uuid::new_v4()
6019        ));
6020        let conn = Connection::open(&path).expect("open legacy database");
6021        conn.execute_batch(
6022            "CREATE TABLE _syncular_commit_outcomes (
6023               seq INTEGER PRIMARY KEY AUTOINCREMENT,
6024               client_commit_id TEXT NOT NULL UNIQUE,
6025               status TEXT NOT NULL,
6026               recorded_at_ms INTEGER NOT NULL,
6027               results_json TEXT NOT NULL,
6028               resolution TEXT NOT NULL DEFAULT 'active',
6029               resolved_at_ms INTEGER,
6030               replacement_client_commit_id TEXT);",
6031        )
6032        .expect("create legacy outcome journal");
6033        drop(conn);
6034        let schema = json!({
6035            "version": 1,
6036            "tables": [{
6037                "name": "tasks",
6038                "primaryKey": "id",
6039                "columns": [
6040                    { "name": "id", "type": "string", "nullable": false },
6041                    { "name": "project_id", "type": "string", "nullable": false }
6042                ],
6043                "scopes": [{ "pattern": "project:{project_id}" }]
6044            }]
6045        });
6046        let client = SyncClient::open_path(
6047            "migration-native".to_owned(),
6048            &schema,
6049            ClientLimits::default(),
6050            path.to_str().expect("UTF-8 temp path"),
6051        )
6052        .expect("migrate database");
6053        let has_operations = client
6054            .conn
6055            .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
6056            .expect("prepare table info")
6057            .query_map([], |row| row.get::<_, String>(1))
6058            .expect("query table info")
6059            .filter_map(Result::ok)
6060            .any(|column| column == "operations_json");
6061        assert!(has_operations);
6062        drop(client);
6063        std::fs::remove_file(path).expect("remove temp database");
6064    }
6065
6066    #[test]
6067    fn retained_rejection_survives_file_reopen_and_security_purge() {
6068        let path =
6069            std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
6070        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
6071            { "name": "id", "type": "string", "nullable": false },
6072            { "name": "project_id", "type": "string", "nullable": false },
6073            { "name": "title", "type": "string", "nullable": false }
6074        ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
6075        {
6076            let mut first = SyncClient::open_path(
6077                "retained-native".to_owned(),
6078                &schema,
6079                ClientLimits::default(),
6080                path.to_str().unwrap(),
6081            )
6082            .unwrap();
6083            first
6084                .write_base_row(
6085                    "tasks",
6086                    &vec![
6087                        Some(ColumnValue::String("t1".into())),
6088                        Some(ColumnValue::String("p1".into())),
6089                        Some(ColumnValue::String("server".into())),
6090                    ],
6091                    1,
6092                )
6093                .unwrap();
6094            first.rebuild_overlay().expect("rebuild overlay");
6095            let operation = OutboxOp {
6096                table: "tasks".into(),
6097                row_id: "t1".into(),
6098                upsert: true,
6099                base_version: Some(1),
6100                values: Some(Map::from_iter([
6101                    ("id".into(), json!("t1")),
6102                    ("title".into(), json!("mine")),
6103                ])),
6104            };
6105            first
6106                .retain_failed_commit("retained", std::slice::from_ref(&operation))
6107                .unwrap();
6108            first
6109                .persist_commit_outcome(
6110                    "retained",
6111                    CommitOutcomeStatus::Rejected,
6112                    &[CommitOperationOutcome::Error {
6113                        rejection: RejectionRecord {
6114                            client_commit_id: "retained".into(),
6115                            op_index: 0,
6116                            code: "validation.denied".into(),
6117                            message: "denied".into(),
6118                            retryable: false,
6119                            details: None,
6120                            operation: None,
6121                        },
6122                    }],
6123                    Some(&[operation]),
6124                )
6125                .unwrap();
6126            first.rebuild_overlay().expect("rebuild overlay");
6127        }
6128        {
6129            let mut reopened = SyncClient::open_path(
6130                "retained-native".to_owned(),
6131                &schema,
6132                ClientLimits::default(),
6133                path.to_str().unwrap(),
6134            )
6135            .unwrap();
6136            let row = reopened
6137                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6138                .expect("read visible row")
6139                .unwrap();
6140            assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
6141            assert_eq!(
6142                reopened
6143                    .commit_outcome("retained")
6144                    .unwrap()
6145                    .unwrap()
6146                    .retained_rows
6147                    .unwrap()[0]
6148                    .server_row
6149                    .as_ref()
6150                    .unwrap()["title"],
6151                json!("server")
6152            );
6153            reopened
6154                .conn
6155                .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
6156                .unwrap();
6157            reopened.rebuild_overlay().expect("rebuild overlay");
6158            assert!(reopened
6159                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6160                .expect("read visible row")
6161                .is_none());
6162            let retained = reopened.commit_outcome("retained").unwrap().unwrap();
6163            assert!(retained.retained_rows.is_some());
6164            assert_eq!(
6165                retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
6166                json!("mine")
6167            );
6168            reopened
6169                .purge_local_data(&LocalDataPurgeInput {
6170                    purge_id: "protected-purge".into(),
6171                    targets: vec![LocalDataPurgeTarget {
6172                        table: "tasks".into(),
6173                        selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
6174                    }],
6175                })
6176                .unwrap();
6177            assert!(reopened
6178                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6179                .expect("read visible row")
6180                .is_none());
6181            assert!(reopened
6182                .commit_outcome("retained")
6183                .unwrap()
6184                .unwrap()
6185                .retained_rows
6186                .is_none());
6187        }
6188        std::fs::remove_file(path).unwrap();
6189    }
6190
6191    #[test]
6192    fn durable_conflict_outcome_and_resolution_survive_reopen() {
6193        let path = std::env::temp_dir().join(format!(
6194            "syncular-durable-outcome-{}.db",
6195            uuid::Uuid::new_v4()
6196        ));
6197        let schema = json!({
6198            "version": 1,
6199            "tables": [{
6200                "name": "tasks",
6201                "primaryKey": "id",
6202                "columns": [
6203                    { "name": "id", "type": "string", "nullable": false },
6204                    { "name": "project_id", "type": "string", "nullable": false }
6205                ],
6206                "scopes": [{ "pattern": "project:{project_id}" }]
6207            }]
6208        });
6209        let conflict = ConflictRecord {
6210            client_commit_id: "losing-commit".to_owned(),
6211            op_index: 0,
6212            table: "tasks".to_owned(),
6213            row_id: "t1".to_owned(),
6214            code: "sync.version_conflict".to_owned(),
6215            message: "stale base version".to_owned(),
6216            server_version: 2,
6217            server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
6218            conflict_columns: vec!["project_id".to_owned()],
6219            operation: Some(CommitOperation {
6220                table: "tasks".to_owned(),
6221                row_id: "t1".to_owned(),
6222                op: "upsert".to_owned(),
6223                base_version: Some(1),
6224                values: Some(Map::from_iter([
6225                    ("id".into(), json!("t1")),
6226                    ("project_id".into(), json!("p1")),
6227                ])),
6228            }),
6229        };
6230        let failed_operations = vec![
6231            OutboxOp {
6232                upsert: true,
6233                table: "tasks".to_owned(),
6234                row_id: "t1".to_owned(),
6235                base_version: Some(1),
6236                values: Some(Map::from_iter([
6237                    ("id".into(), json!("t1")),
6238                    ("project_id".into(), json!("p1")),
6239                ])),
6240            },
6241            OutboxOp {
6242                upsert: true,
6243                table: "tasks".to_owned(),
6244                row_id: "status-event-1".to_owned(),
6245                base_version: Some(0),
6246                values: Some(Map::from_iter([
6247                    ("id".into(), json!("status-event-1")),
6248                    ("project_id".into(), json!("p1")),
6249                ])),
6250            },
6251        ];
6252
6253        {
6254            let mut first = SyncClient::open_path(
6255                "durable-native".to_owned(),
6256                &schema,
6257                ClientLimits::default(),
6258                path.to_str().expect("UTF-8 temp path"),
6259            )
6260            .expect("first open");
6261            first
6262                .begin_observation("test_outcome")
6263                .expect("begin outcome");
6264            first
6265                .persist_commit_outcome(
6266                    "losing-commit",
6267                    CommitOutcomeStatus::Conflict,
6268                    &[CommitOperationOutcome::Conflict {
6269                        conflict: conflict.clone(),
6270                    }],
6271                    Some(&failed_operations),
6272                )
6273                .expect("persist outcome");
6274            first.conflicts.push(conflict);
6275            first
6276                .finish_observation(
6277                    "test_outcome",
6278                    ChangeAccumulator {
6279                        conflicts: true,
6280                        outcomes: true,
6281                        ..ChangeAccumulator::default()
6282                    },
6283                )
6284                .expect("commit outcome");
6285        }
6286
6287        {
6288            let mut reopened = SyncClient::open_path(
6289                "durable-native".to_owned(),
6290                &schema,
6291                ClientLimits::default(),
6292                path.to_str().expect("UTF-8 temp path"),
6293            )
6294            .expect("reopen");
6295            assert_eq!(reopened.conflicts().len(), 1);
6296            assert_eq!(
6297                reopened.conflicts()[0].conflict_columns,
6298                vec!["project_id".to_owned()],
6299                "conflictColumns survives the journal round trip"
6300            );
6301            let outcome = reopened
6302                .commit_outcome("losing-commit")
6303                .expect("read outcome")
6304                .expect("outcome");
6305            assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
6306            let operations = outcome.operations.expect("aggregate envelope");
6307            assert_eq!(operations.len(), 2);
6308            assert_eq!(operations[1].row_id, "status-event-1");
6309            let resolved = reopened
6310                .resolve_commit_outcome(ResolveCommitOutcomeInput {
6311                    client_commit_id: "losing-commit".to_owned(),
6312                    resolution: CommitOutcomeResolution::ResolvedKeepServer,
6313                    replacement_client_commit_id: None,
6314                })
6315                .expect("resolve");
6316            assert_eq!(
6317                resolved.resolution,
6318                CommitOutcomeResolution::ResolvedKeepServer
6319            );
6320            assert!(reopened.conflicts().is_empty());
6321        }
6322
6323        let reopened = SyncClient::open_path(
6324            "durable-native".to_owned(),
6325            &schema,
6326            ClientLimits::default(),
6327            path.to_str().expect("UTF-8 temp path"),
6328        )
6329        .expect("second reopen");
6330        assert!(reopened.conflicts().is_empty());
6331        assert_eq!(
6332            reopened
6333                .commit_outcome("losing-commit")
6334                .expect("read outcome")
6335                .expect("outcome")
6336                .resolution,
6337            CommitOutcomeResolution::ResolvedKeepServer
6338        );
6339        drop(reopened);
6340        std::fs::remove_file(path).expect("remove temp database");
6341    }
6342
6343    #[test]
6344    fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
6345        let path =
6346            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6347        let schema = json!({
6348            "version": 1,
6349            "tables": [{
6350                "name": "tasks",
6351                "primaryKey": "id",
6352                "columns": [
6353                    { "name": "id", "type": "string", "nullable": false },
6354                    { "name": "project_id", "type": "string", "nullable": false }
6355                ],
6356                "scopes": [{ "pattern": "project:{project_id}" }]
6357            }]
6358        });
6359        let mut client = SyncClient::open_path_with_identity(
6360            Some("sidecar-client".to_owned()),
6361            &schema,
6362            ClientLimits::default(),
6363            path.to_str().expect("UTF-8 temp path"),
6364        )
6365        .expect("open owner");
6366        let base = WindowBase {
6367            table: "tasks".to_owned(),
6368            variable: "project_id".to_owned(),
6369            fixed_scopes: Vec::new(),
6370            params: None,
6371        };
6372        client
6373            .set_window(&base, &["one".to_owned()])
6374            .expect("set window");
6375        client
6376            .mutate(vec![Mutation::Upsert {
6377                table: "tasks".to_owned(),
6378                values: Map::from_iter([
6379                    ("id".to_owned(), Value::from("t1")),
6380                    ("project_id".to_owned(), Value::from("one")),
6381                ]),
6382                base_version: None,
6383            }])
6384            .expect("local mutate");
6385
6386        let coverage = [WindowCoverage {
6387            base,
6388            units: vec!["one".to_owned(), "missing".to_owned()],
6389        }];
6390        let owner = client
6391            .query_snapshot(
6392                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
6393                &[],
6394                &coverage,
6395                None,
6396            )
6397            .expect("owner snapshot");
6398        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6399        let sidecar = reader
6400            .query_snapshot(
6401                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
6402                &[],
6403                &coverage,
6404            )
6405            .expect("sidecar snapshot");
6406
6407        assert_eq!(sidecar.revision, owner.revision);
6408        assert_eq!(sidecar.rows, owner.rows);
6409        assert_eq!(
6410            serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
6411            serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
6412        );
6413        assert_eq!(sidecar.revision, "2");
6414        assert_eq!(sidecar.rows[0]["id"], "t1");
6415        assert_eq!(sidecar.rows[0]["server_version"], -1);
6416        assert!(!sidecar.coverage.complete);
6417        assert_eq!(sidecar.coverage.pending.len(), 1);
6418        assert_eq!(sidecar.coverage.missing.len(), 1);
6419
6420        drop(reader);
6421        drop(client);
6422        std::fs::remove_file(path).expect("remove temp database");
6423    }
6424
6425    #[test]
6426    fn file_snapshot_reader_reports_catchup_delivery_and_multi_statement() {
6427        let path =
6428            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6429        let schema = json!({
6430            "version": 1,
6431            "tables": [{
6432                "name": "tasks",
6433                "primaryKey": "id",
6434                "columns": [
6435                    { "name": "id", "type": "string", "nullable": false },
6436                    { "name": "project_id", "type": "string", "nullable": false }
6437                ],
6438                "scopes": [{ "pattern": "project:{project_id}" }]
6439            }]
6440        });
6441        let mut client = SyncClient::open_path_with_identity(
6442            Some("sidecar-catchup".to_owned()),
6443            &schema,
6444            ClientLimits::default(),
6445            path.to_str().expect("UTF-8 temp path"),
6446        )
6447        .expect("open owner");
6448        client
6449            .subscribe(
6450                "s1".into(),
6451                "tasks".into(),
6452                vec![("project_id".into(), vec!["one".into()])],
6453                None,
6454            )
6455            .expect("subscribe");
6456        let commit_id = client
6457            .mutate(vec![Mutation::Upsert {
6458                table: "tasks".to_owned(),
6459                values: Map::from_iter([
6460                    ("id".to_owned(), Value::from("t1")),
6461                    ("project_id".to_owned(), Value::from("one")),
6462                ]),
6463                base_version: None,
6464            }])
6465            .expect("mutate");
6466        let revision = client.local_revision().to_string();
6467
6468        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6469        assert_eq!(reader.local_revision().expect("revision"), revision);
6470
6471        let read = reader
6472            .snapshot_read(&SnapshotReadRequest {
6473                statements: vec![
6474                    SnapshotStatement {
6475                        sql: "SELECT 1 AS n",
6476                        params: &[],
6477                    },
6478                    SnapshotStatement {
6479                        sql: "SELECT id FROM tasks ORDER BY id",
6480                        params: &[],
6481                    },
6482                ],
6483                coverage: &[],
6484                subscriptions: vec!["s1".to_owned(), "absent".to_owned()],
6485                commit_ids: vec![commit_id.clone(), "never-known".to_owned()],
6486            })
6487            .expect("snapshot read");
6488        assert_eq!(read.revision, revision);
6489        assert_eq!(read.queries.len(), 2);
6490        assert_eq!(read.queries[0][0]["n"], 1);
6491        assert_eq!(read.queries[1][0]["id"], "t1");
6492        match &read.subscriptions[0] {
6493            SubscriptionCatchup::Known {
6494                status,
6495                cursor,
6496                bootstrap_complete,
6497                known_pending_pages,
6498                ..
6499            } => {
6500                assert_eq!(status, "active");
6501                assert_eq!(*cursor, -1);
6502                assert!(!bootstrap_complete);
6503                assert!(known_pending_pages);
6504            }
6505            other => panic!("expected a known subscription: {other:?}"),
6506        }
6507        assert!(matches!(
6508            &read.subscriptions[1],
6509            SubscriptionCatchup::Unknown { id } if id.as_str() == "absent"
6510        ));
6511        assert!(matches!(
6512            &read.deliveries[0],
6513            CommitDelivery::Pending { client_commit_id } if client_commit_id == &commit_id
6514        ));
6515        assert!(matches!(
6516            &read.deliveries[1],
6517            CommitDelivery::Unknown { client_commit_id } if client_commit_id.as_str() == "never-known"
6518        ));
6519
6520        // A bad statement rolls the whole read back and releases the connection.
6521        assert!(reader
6522            .query_snapshot("SELECT * FROM does_not_exist", &[], &[])
6523            .is_err());
6524        assert_eq!(
6525            reader.local_revision().expect("revision after rollback"),
6526            revision
6527        );
6528        // The read-only guard refuses a write on any statement.
6529        assert!(reader
6530            .query_snapshot("DELETE FROM tasks", &[], &[])
6531            .is_err());
6532
6533        // A corrupt revision marker fails typed, not as an invented zero.
6534        client
6535            .conn
6536            .execute(
6537                "UPDATE _syncular_meta SET value = '+1' WHERE key = 'localRevision'",
6538                [],
6539            )
6540            .expect("corrupt revision");
6541        let failure = reader.local_revision().expect_err("corrupt revision fails");
6542        assert_eq!(failure.code, Some("sync.local_corrupt"));
6543
6544        drop(reader);
6545        drop(client);
6546        std::fs::remove_file(path).expect("remove temp database");
6547    }
6548
6549    #[test]
6550    fn snapshot_delivery_is_the_persisted_journal_view_without_retained_rows() {
6551        let path =
6552            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6553        let schema = json!({
6554            "version": 1,
6555            "tables": [{
6556                "name": "tasks",
6557                "primaryKey": "id",
6558                "columns": [
6559                    { "name": "id", "type": "string", "nullable": false },
6560                    { "name": "project_id", "type": "string", "nullable": false }
6561                ],
6562                "scopes": [{ "pattern": "project:{project_id}" }]
6563            }]
6564        });
6565        let mut client = SyncClient::open_path_with_identity(
6566            Some("sidecar-delivery".to_owned()),
6567            &schema,
6568            ClientLimits::default(),
6569            path.to_str().expect("UTF-8 temp path"),
6570        )
6571        .expect("open owner");
6572        client.begin_observation("test_delivery").expect("observe");
6573        let operations = [OutboxOp {
6574            upsert: true,
6575            table: "tasks".to_owned(),
6576            row_id: "t1".to_owned(),
6577            base_version: Some(1),
6578            values: Some(Map::from_iter([
6579                ("id".into(), json!("t1")),
6580                ("project_id".into(), json!("p1")),
6581            ])),
6582        }];
6583        client
6584            .persist_commit_outcome(
6585                "conflict-commit",
6586                CommitOutcomeStatus::Conflict,
6587                &[CommitOperationOutcome::Conflict {
6588                    conflict: ConflictRecord {
6589                        client_commit_id: "conflict-commit".to_owned(),
6590                        op_index: 0,
6591                        table: "tasks".to_owned(),
6592                        row_id: "t1".to_owned(),
6593                        code: "sync.version_conflict".to_owned(),
6594                        message: "stale base version".to_owned(),
6595                        server_version: 2,
6596                        server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
6597                        conflict_columns: vec!["project_id".to_owned()],
6598                        operation: None,
6599                    },
6600                }],
6601                Some(&operations),
6602            )
6603            .expect("persist conflict");
6604        client
6605            .persist_commit_outcome(
6606                "rejected-commit",
6607                CommitOutcomeStatus::Rejected,
6608                &[CommitOperationOutcome::Error {
6609                    rejection: RejectionRecord {
6610                        client_commit_id: "rejected-commit".to_owned(),
6611                        op_index: 0,
6612                        code: "sync.unknown_table".to_owned(),
6613                        message: "unknown table".to_owned(),
6614                        retryable: false,
6615                        details: None,
6616                        operation: None,
6617                    },
6618                }],
6619                Some(&operations),
6620            )
6621            .expect("persist rejection");
6622        client
6623            .finish_observation(
6624                "test_delivery",
6625                ChangeAccumulator {
6626                    conflicts: true,
6627                    outcomes: true,
6628                    ..ChangeAccumulator::default()
6629                },
6630            )
6631            .expect("commit outcomes");
6632
6633        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6634        match reader.commit_delivery("conflict-commit").expect("conflict") {
6635            CommitDelivery::Known { outcome, .. } => {
6636                assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
6637                assert!(matches!(
6638                    outcome.results.as_slice(),
6639                    [CommitOperationOutcome::Conflict { conflict }]
6640                        if conflict.code == "sync.version_conflict"
6641                            && conflict.server_version == 2
6642                ));
6643                assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
6644                assert!(
6645                    outcome.retained_rows.is_none(),
6646                    "the sidecar reports the persisted view, not owner-derived retained rows"
6647                );
6648            }
6649            other => panic!("expected a known conflict: {other:?}"),
6650        }
6651        match reader
6652            .commit_delivery("rejected-commit")
6653            .expect("rejection")
6654        {
6655            CommitDelivery::Known { outcome, .. } => {
6656                assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
6657                assert!(matches!(
6658                    outcome.results.as_slice(),
6659                    [CommitOperationOutcome::Error { rejection }]
6660                        if rejection.code == "sync.unknown_table"
6661                ));
6662                assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
6663                assert!(outcome.retained_rows.is_none());
6664            }
6665            other => panic!("expected a known rejection: {other:?}"),
6666        }
6667        drop(reader);
6668        drop(client);
6669        std::fs::remove_file(path).expect("remove temp database");
6670    }
6671
6672    #[test]
6673    fn snapshot_read_isolates_a_concurrent_writer_commit() {
6674        use std::sync::mpsc::channel;
6675        let path = std::env::temp_dir().join(format!(
6676            "syncular-read-concurrent-{}.db",
6677            uuid::Uuid::new_v4()
6678        ));
6679        let schema = json!({
6680            "version": 1,
6681            "tables": [{
6682                "name": "tasks",
6683                "primaryKey": "id",
6684                "columns": [
6685                    { "name": "id", "type": "string", "nullable": false },
6686                    { "name": "project_id", "type": "string", "nullable": false }
6687                ],
6688                "scopes": [{ "pattern": "project:{project_id}" }]
6689            }]
6690        });
6691        let mut client = SyncClient::open_path_with_identity(
6692            Some("sidecar-concurrent".to_owned()),
6693            &schema,
6694            ClientLimits::default(),
6695            path.to_str().expect("UTF-8 temp path"),
6696        )
6697        .expect("open owner");
6698        client
6699            .mutate(vec![Mutation::Upsert {
6700                table: "tasks".to_owned(),
6701                values: Map::from_iter([
6702                    ("id".to_owned(), Value::from("t1")),
6703                    ("project_id".to_owned(), Value::from("one")),
6704                ]),
6705                base_version: None,
6706            }])
6707            .expect("seed row");
6708        let revision_before = client.local_revision().to_string();
6709
6710        // A writer on a second connection commits while the reader is between
6711        // statements; the hook rendezvous is a barrier, not a sleep.
6712        let (ready_tx, ready_rx) = channel::<()>();
6713        let (done_tx, done_rx) = channel::<()>();
6714        let writer_path = path.to_string_lossy().into_owned();
6715        let writer_schema = schema.clone();
6716        let writer = std::thread::spawn(move || {
6717            let mut writer = SyncClient::open_path_with_identity(
6718                Some("sidecar-concurrent".to_owned()),
6719                &writer_schema,
6720                ClientLimits::default(),
6721                &writer_path,
6722            )
6723            .expect("open writer");
6724            ready_rx.recv().expect("reader signalled");
6725            writer
6726                .mutate(vec![Mutation::Upsert {
6727                    table: "tasks".to_owned(),
6728                    values: Map::from_iter([
6729                        ("id".to_owned(), Value::from("t2")),
6730                        ("project_id".to_owned(), Value::from("one")),
6731                    ]),
6732                    base_version: None,
6733                }])
6734                .expect("writer commit");
6735            let _ = done_tx.send(());
6736        });
6737        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6738        SNAPSHOT_READ_HOOK.with(|slot| {
6739            *slot.borrow_mut() = Some(Box::new(move || {
6740                ready_tx.send(()).expect("signal writer");
6741                done_rx.recv().expect("writer finished");
6742            }));
6743        });
6744        let read = reader
6745            .snapshot_read(&SnapshotReadRequest {
6746                statements: vec![
6747                    SnapshotStatement {
6748                        sql: "SELECT count(*) AS n FROM tasks",
6749                        params: &[],
6750                    },
6751                    SnapshotStatement {
6752                        sql: "SELECT count(*) AS n FROM tasks",
6753                        params: &[],
6754                    },
6755                ],
6756                coverage: &[],
6757                subscriptions: Vec::new(),
6758                commit_ids: Vec::new(),
6759            })
6760            .expect("snapshot read");
6761        assert_eq!(
6762            read.queries[0][0]["n"], 1,
6763            "first statement sees the pre-commit row"
6764        );
6765        assert_eq!(
6766            read.queries[1][0]["n"], 1,
6767            "a later statement keeps the one read snapshot"
6768        );
6769        assert_eq!(
6770            read.revision, revision_before,
6771            "the read reports the snapshot revision, not the writer's"
6772        );
6773        writer.join().expect("join writer");
6774
6775        assert_ne!(
6776            reader.local_revision().expect("revision after commit"),
6777            revision_before,
6778            "the next read observes the committed revision"
6779        );
6780        let after = reader
6781            .query_snapshot("SELECT count(*) AS n FROM tasks", &[], &[])
6782            .expect("count after commit");
6783        assert_eq!(
6784            after.rows[0]["n"], 2,
6785            "the committed row is visible afterwards"
6786        );
6787
6788        drop(reader);
6789        drop(client);
6790        std::fs::remove_file(path).expect("remove temp database");
6791    }
6792
6793    #[test]
6794    fn snapshot_read_rejects_every_non_canonical_revision() {
6795        let path = std::env::temp_dir().join(format!(
6796            "syncular-read-revision-{}.db",
6797            uuid::Uuid::new_v4()
6798        ));
6799        let schema = json!({
6800            "version": 1,
6801            "tables": [{
6802                "name": "tasks",
6803                "primaryKey": "id",
6804                "columns": [
6805                    { "name": "id", "type": "string", "nullable": false },
6806                    { "name": "project_id", "type": "string", "nullable": false }
6807                ],
6808                "scopes": [{ "pattern": "project:{project_id}" }]
6809            }]
6810        });
6811        let client = SyncClient::open_path_with_identity(
6812            Some("sidecar-revision".to_owned()),
6813            &schema,
6814            ClientLimits::default(),
6815            path.to_str().expect("UTF-8 temp path"),
6816        )
6817        .expect("open owner");
6818        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6819        for bad in [
6820            "+1",
6821            "01",
6822            "-1",
6823            " 1",
6824            "1 ",
6825            "18446744073709551616",
6826            "1.0",
6827            "abc",
6828            "",
6829        ] {
6830            client
6831                .conn
6832                .execute(
6833                    "UPDATE _syncular_meta SET value = ?1 WHERE key = 'localRevision'",
6834                    rusqlite::params![bad],
6835                )
6836                .expect("corrupt revision");
6837            let failure = reader.local_revision().expect_err("corrupt marker fails");
6838            assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad:?}");
6839        }
6840        for value in ["X'31'", "CAST(X'FF' AS TEXT)"] {
6841            client
6842                .conn
6843                .execute_batch(&format!(
6844                    "UPDATE _syncular_meta SET value={value} WHERE key='localRevision'"
6845                ))
6846                .expect("corrupt stored revision type");
6847            assert_eq!(
6848                reader.local_revision().expect_err("invalid SQL field").code,
6849                Some("sync.local_corrupt")
6850            );
6851        }
6852        // The absent marker is the legacy zero, not corruption.
6853        client
6854            .conn
6855            .execute("DELETE FROM _syncular_meta WHERE key = 'localRevision'", [])
6856            .expect("delete marker");
6857        assert_eq!(reader.local_revision().expect("legacy zero"), "0");
6858
6859        drop(reader);
6860        drop(client);
6861        std::fs::remove_file(path).expect("remove temp database");
6862    }
6863
6864    #[test]
6865    fn snapshot_read_rejects_corrupt_subscription_state() {
6866        let path = std::env::temp_dir().join(format!(
6867            "syncular-read-substate-{}.db",
6868            uuid::Uuid::new_v4()
6869        ));
6870        let schema = json!({
6871            "version": 1,
6872            "tables": [{
6873                "name": "tasks",
6874                "primaryKey": "id",
6875                "columns": [
6876                    { "name": "id", "type": "string", "nullable": false },
6877                    { "name": "project_id", "type": "string", "nullable": false }
6878                ],
6879                "scopes": [{ "pattern": "project:{project_id}" }]
6880            }]
6881        });
6882        let mut client = SyncClient::open_path_with_identity(
6883            Some("sidecar-substate".to_owned()),
6884            &schema,
6885            ClientLimits::default(),
6886            path.to_str().expect("UTF-8 temp path"),
6887        )
6888        .expect("open owner");
6889        client
6890            .subscribe(
6891                "s1".into(),
6892                "tasks".into(),
6893                vec![("project_id".into(), vec!["one".into()])],
6894                None,
6895            )
6896            .expect("subscribe");
6897        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6898        for bad in [
6899            "not json".to_owned(),
6900            "{}".to_owned(),
6901            json!({ "status": "bogus", "cursor": 0 }).to_string(),
6902            json!({ "status": "active", "cursor": "x" }).to_string(),
6903            json!({ "status": "active", "cursor": 0, "bootstrapState": 7 }).to_string(),
6904            json!({ "status": "active", "cursor": 0, "reasonCode": 7 }).to_string(),
6905        ] {
6906            client
6907                .conn
6908                .execute(
6909                    "UPDATE _syncular_subscriptions SET state_json = ?1 WHERE id = 's1'",
6910                    rusqlite::params![bad],
6911                )
6912                .expect("corrupt subscription state");
6913            let failure = reader
6914                .subscription_catchup("s1")
6915                .expect_err("corrupt state fails");
6916            assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad}");
6917        }
6918
6919        for assignment in [
6920            "tbl=X'7461736b73'",
6921            "state_json=X'7b7d'",
6922            "state_json=CAST(X'FF' AS TEXT)",
6923        ] {
6924            client.conn.execute_batch(&format!(
6925                "UPDATE _syncular_subscriptions SET tbl='tasks',state_json='{{\"status\":\"active\",\"cursor\":0}}'; UPDATE _syncular_subscriptions SET {assignment} WHERE id='s1'"
6926            )).expect("corrupt stored subscription type");
6927            assert_eq!(
6928                reader
6929                    .subscription_catchup("s1")
6930                    .expect_err("invalid SQL field")
6931                    .code,
6932                Some("sync.local_corrupt")
6933            );
6934        }
6935
6936        drop(reader);
6937        drop(client);
6938        std::fs::remove_file(path).expect("remove temp database");
6939    }
6940
6941    #[test]
6942    fn snapshot_read_rolls_back_a_later_invalid_statement_and_guards_every_write() {
6943        let path = std::env::temp_dir().join(format!(
6944            "syncular-read-rollback-{}.db",
6945            uuid::Uuid::new_v4()
6946        ));
6947        let schema = json!({
6948            "version": 1,
6949            "tables": [{
6950                "name": "tasks",
6951                "primaryKey": "id",
6952                "columns": [
6953                    { "name": "id", "type": "string", "nullable": false },
6954                    { "name": "project_id", "type": "string", "nullable": false }
6955                ],
6956                "scopes": [{ "pattern": "project:{project_id}" }]
6957            }]
6958        });
6959        let mut client = SyncClient::open_path_with_identity(
6960            Some("sidecar-rollback".to_owned()),
6961            &schema,
6962            ClientLimits::default(),
6963            path.to_str().expect("UTF-8 temp path"),
6964        )
6965        .expect("open owner");
6966        let commit_id = client
6967            .mutate(vec![Mutation::Upsert {
6968                table: "tasks".to_owned(),
6969                values: Map::from_iter([
6970                    ("id".to_owned(), Value::from("t1")),
6971                    ("project_id".to_owned(), Value::from("one")),
6972                ]),
6973                base_version: None,
6974            }])
6975            .expect("seed row");
6976        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6977        let revision = reader.local_revision().expect("revision");
6978
6979        // The second statement fails: the whole read rolls back and the
6980        // connection releases, so the same reader still works afterwards.
6981        let failure = reader
6982            .snapshot_read(&SnapshotReadRequest {
6983                statements: vec![
6984                    SnapshotStatement {
6985                        sql: "SELECT id FROM tasks",
6986                        params: &[],
6987                    },
6988                    SnapshotStatement {
6989                        sql: "SELECT * FROM does_not_exist",
6990                        params: &[],
6991                    },
6992                ],
6993                coverage: &[],
6994                subscriptions: Vec::new(),
6995                commit_ids: vec![commit_id.clone()],
6996            })
6997            .expect_err("second statement fails");
6998        assert_eq!(failure.code, None);
6999        assert_eq!(
7000            reader.local_revision().expect("revision after rollback"),
7001            revision,
7002            "a rolled-back read consumes no revision"
7003        );
7004        assert!(
7005            !matches!(
7006                reader
7007                    .commit_delivery(&commit_id)
7008                    .expect("delivery after rollback"),
7009                CommitDelivery::Unknown { .. }
7010            ),
7011            "the same reader serves the next read after releasing the connection"
7012        );
7013
7014        // The read-only guard rejects a write in the first position too.
7015        let guard = reader
7016            .snapshot_read(&SnapshotReadRequest {
7017                statements: vec![SnapshotStatement {
7018                    sql: "DELETE FROM tasks",
7019                    params: &[],
7020                }],
7021                coverage: &[],
7022                subscriptions: Vec::new(),
7023                commit_ids: Vec::new(),
7024            })
7025            .expect_err("write guard");
7026        assert!(guard.message.contains("read-only"), "{}", guard.message);
7027
7028        drop(reader);
7029        drop(client);
7030        std::fs::remove_file(path).expect("remove temp database");
7031    }
7032
7033    #[test]
7034    fn owned_query_failures_classify_order_bound_and_clear() {
7035        let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7036            rusqlite::ffi::Error::new(266),
7037            Some("private SQLite prose".to_owned()),
7038        ));
7039        assert_eq!(io.code, Some("client.storage_io"));
7040        assert_eq!(io.sqlite_code, Some(266));
7041        assert_eq!(io.message, "local SQLite storage I/O failed");
7042        let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7043            rusqlite::ffi::Error::new(26),
7044            None,
7045        ));
7046        assert_eq!(corrupt.code, Some("client.storage_corrupt"));
7047        assert!(!io.retryable());
7048        assert!(!corrupt.retryable());
7049
7050        // SQLITE_BUSY (5) and SQLITE_LOCKED (6), including extended codes, are
7051        // transient lock contention: classified busy and retryable.
7052        for (raw, extended) in [(5, 5), (5 | (1 << 8), 261), (6, 6), (6 | (1 << 8), 262)] {
7053            let busy = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7054                rusqlite::ffi::Error::new(raw),
7055                Some("database is locked".to_owned()),
7056            ));
7057            assert_eq!(busy.code, Some("client.storage_busy"));
7058            assert_eq!(busy.sqlite_code, Some(extended));
7059            assert_eq!(busy.message, "local SQLite storage is busy");
7060            assert!(busy.retryable());
7061        }
7062        let unrelated = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7063            rusqlite::ffi::Error::new(1),
7064            Some("generic SQLite prose".to_owned()),
7065        ));
7066        assert_eq!(unrelated.code, None);
7067        assert!(!unrelated.retryable());
7068
7069        let mut client = client();
7070        let generic = QueryReadFailure::from("private query prose".to_owned());
7071        client.record_query_read(
7072            QueryOwner {
7073                id: "first",
7074                tables: &["tasks", "tasks"],
7075            },
7076            Some(&generic),
7077        );
7078        client.record_query_read(
7079            QueryOwner {
7080                id: "second",
7081                tables: &["tasks"],
7082            },
7083            Some(&generic),
7084        );
7085        client.record_query_read(
7086            QueryOwner {
7087                id: "first",
7088                tables: &["docs"],
7089            },
7090            Some(&io),
7091        );
7092        let failures = &client
7093            .diagnostics_snapshot(&Default::default())
7094            .expect("diagnostics")
7095            .query_failures;
7096        assert_eq!(
7097            failures
7098                .iter()
7099                .map(|entry| entry.id.as_str())
7100                .collect::<Vec<_>>(),
7101            vec!["second", "first"]
7102        );
7103        assert_eq!(failures[1].tables, vec!["docs"]);
7104        assert_eq!(failures[1].code, "client.storage_io");
7105
7106        for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
7107            client.record_query_read(
7108                QueryOwner {
7109                    id: &format!("query-{index}"),
7110                    tables: &["tasks"],
7111                },
7112                Some(&generic),
7113            );
7114        }
7115        let failures = &client
7116            .diagnostics_snapshot(&Default::default())
7117            .expect("bounded diagnostics")
7118            .query_failures;
7119        assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
7120        assert_eq!(failures.last().expect("last failure").id, "query-256");
7121
7122        client
7123            .query_snapshot(
7124                "SELECT id FROM tasks",
7125                &[],
7126                &[],
7127                Some(QueryOwner {
7128                    id: "query-256",
7129                    tables: &["tasks"],
7130                }),
7131            )
7132            .expect("successful owned read");
7133        assert!(client
7134            .diagnostics_snapshot(&Default::default())
7135            .expect("cleared diagnostics")
7136            .query_failures
7137            .iter()
7138            .all(|entry| entry.id != "query-256"));
7139    }
7140
7141    #[test]
7142    fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
7143        let mut client = client();
7144        let image = Connection::open_in_memory().unwrap();
7145        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
7146            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7147            INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
7148            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7149            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
7150        let table = client.schema.table("tasks").unwrap().clone();
7151        let scopes = vec![("project_id".into(), vec!["p1".into()])];
7152        assert!(client
7153            .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
7154            .is_err());
7155        assert!(client.conn.is_autocommit());
7156        assert_eq!(
7157            client
7158                .query("SELECT count(*) AS n FROM tasks", &[])
7159                .unwrap()[0]["n"],
7160            1024
7161        );
7162        assert_eq!(client.drain_change_batches().len(), 1);
7163        image
7164            .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
7165            .unwrap();
7166        assert!(matches!(
7167            client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
7168            Ok(2050)
7169        ));
7170        assert_eq!(
7171            client
7172                .query("SELECT count(*) AS n FROM tasks", &[])
7173                .unwrap()[0]["n"],
7174            2050
7175        );
7176        assert_eq!(client.drain_change_batches().len(), 3);
7177    }
7178
7179    #[test]
7180    fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
7181        let mut client = client();
7182        let base = WindowBase {
7183            table: "tasks".into(),
7184            variable: "project_id".into(),
7185            fixed_scopes: Vec::new(),
7186            params: None,
7187        };
7188        client
7189            .set_window(&base, &["p1".into(), "p2".into()])
7190            .unwrap();
7191        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7192            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7193            INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
7194            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7195            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
7196        client.drain_change_batches();
7197        assert!(client.set_window(&base, &["p2".into()]).is_err());
7198        assert!(client.conn.is_autocommit());
7199        assert_eq!(
7200            client
7201                .query("SELECT count(*) AS n FROM tasks", &[])
7202                .unwrap()[0]["n"],
7203            1027
7204        );
7205        assert_eq!(client.window_state(&base).units, vec!["p2"]);
7206        assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
7207        assert_eq!(client.drain_change_batches().len(), 1);
7208        client
7209            .conn
7210            .execute_batch("DROP TRIGGER interrupt_evict")
7211            .unwrap();
7212        client.drain_pending_evictions().unwrap();
7213        assert!(client.load_pending_evictions().unwrap().is_empty());
7214        assert_eq!(
7215            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
7216            "held"
7217        );
7218        assert_eq!(client.drain_change_batches().len(), 2);
7219    }
7220
7221    #[test]
7222    fn eviction_marker_failures_roll_back_window_changes() {
7223        let mut client = client();
7224        let base = WindowBase {
7225            table: "tasks".into(),
7226            variable: "project_id".into(),
7227            fixed_scopes: Vec::new(),
7228            params: None,
7229        };
7230        client.set_window(&base, &["p1".into()]).unwrap();
7231        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7232            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7233            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7234            CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
7235        let revision = client.local_revision();
7236        assert!(client.set_window(&base, &[]).is_err());
7237        assert!(client.conn.is_autocommit());
7238        assert_eq!(client.local_revision(), revision);
7239        assert_eq!(
7240            client
7241                .query("SELECT count(*) AS n FROM tasks", &[])
7242                .unwrap()[0]["n"],
7243            2050
7244        );
7245        assert_eq!(client.window_state(&base).units, vec!["p1"]);
7246        client.conn.execute_batch("DROP TRIGGER fail_marker;
7247            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
7248        assert!(client.set_window(&base, &[]).is_err());
7249        client.conn.execute_batch("DROP TRIGGER interrupt_evict;
7250            CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
7251        assert!(client.drain_pending_evictions().is_err());
7252        assert!(client.conn.is_autocommit());
7253        assert_eq!(
7254            client
7255                .query("SELECT count(*) AS n FROM tasks", &[])
7256                .unwrap()[0]["n"],
7257            2
7258        );
7259        assert!(client.set_window(&base, &["p1".into()]).is_err());
7260        assert!(client.conn.is_autocommit());
7261        assert!(client.window_state(&base).units.is_empty());
7262        assert!(client.subs.is_empty());
7263        assert_eq!(
7264            client
7265                .query(
7266                    "SELECT count(*) AS n FROM _syncular_window_pending_evict",
7267                    &[]
7268                )
7269                .unwrap()[0]["n"],
7270            1
7271        );
7272        client
7273            .conn
7274            .execute_batch("DROP TRIGGER fail_marker")
7275            .unwrap();
7276        client.set_window(&base, &["p1".into()]).unwrap();
7277        assert_eq!(client.window_state(&base).units, vec!["p1"]);
7278    }
7279
7280    #[test]
7281    fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
7282        let mut client = client();
7283        client
7284            .conn
7285            .execute(
7286                "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
7287                [],
7288            )
7289            .unwrap();
7290        assert_eq!(
7291            client.drain_pending_evictions().unwrap_err(),
7292            "sync.local_corrupt: invalid pending eviction scopes"
7293        );
7294        assert!(client.conn.is_autocommit());
7295        assert!(client.enqueue_startup_sync_if_needed().is_err());
7296    }
7297
7298    #[test]
7299    fn image_chunks_reject_null_primary_keys() {
7300        let mut client = client();
7301        let image = Connection::open_in_memory().unwrap();
7302        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7303            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
7304            INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
7305            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7306            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
7307        let table = client.schema.table("tasks").unwrap().clone();
7308        assert!(client
7309            .apply_sqlite_image(
7310                &image,
7311                &table,
7312                true,
7313                &[("project_id".into(), vec!["p1".into()])],
7314                1025,
7315                7,
7316                "digest"
7317            )
7318            .is_err());
7319        assert!(client.conn.is_autocommit());
7320        assert_eq!(
7321            client
7322                .query("SELECT count(*) AS n FROM tasks", &[])
7323                .unwrap()[0]["n"],
7324            0
7325        );
7326    }
7327
7328    #[test]
7329    fn import_chunks_with_unique_constraints_match_full_pending_replay() {
7330        for images in [false, true] {
7331            let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
7332                "name":name,"primaryKey":"id","columns":[
7333                    {"name":"id","type":"string","nullable":false},
7334                    {"name":"project_id","type":"string","nullable":false},
7335                    {"name":"title","type":"string","nullable":false}],
7336                "scopes":[{"pattern":"project:{project_id}"}],
7337                "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
7338                "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
7339            })))});
7340            let mut actual =
7341                SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
7342            let mut reference =
7343                SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
7344            let scopes = vec![("project_id".into(), vec!["p1".into()])];
7345            for client in [&mut actual, &mut reference] {
7346                client
7347                    .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
7348                    .unwrap();
7349                client.subs[0].effective = Some(scopes.clone());
7350                for (table, id, title) in [
7351                    ("tasks", "a", "original"),
7352                    ("tasks", "b", "occupied"),
7353                    ("tasks", "c", "deleted"),
7354                    ("notes", "n", "untouched"),
7355                ] {
7356                    client
7357                        .write_base_row(
7358                            table,
7359                            &vec![
7360                                Some(ColumnValue::String(id.into())),
7361                                Some(ColumnValue::String("p1".into())),
7362                                Some(ColumnValue::String(title.into())),
7363                            ],
7364                            1,
7365                        )
7366                        .unwrap();
7367                }
7368                client.rebuild_overlay_if_dirty().expect("rebuild overlay");
7369                client
7370                    .mutate(vec![
7371                        Mutation::Upsert {
7372                            table: "tasks".into(),
7373                            values: Map::from_iter([
7374                                ("id".into(), Value::from("a")),
7375                                ("project_id".into(), Value::from("p1")),
7376                                ("title".into(), Value::from("available")),
7377                            ]),
7378                            base_version: None,
7379                        },
7380                        Mutation::Delete {
7381                            table: "tasks".into(),
7382                            row_id: "c".into(),
7383                            base_version: None,
7384                        },
7385                        Mutation::Upsert {
7386                            table: "tasks".into(),
7387                            values: Map::from_iter([
7388                                ("id".into(), Value::from("local")),
7389                                ("project_id".into(), Value::from("p1")),
7390                                ("title".into(), Value::from("local value")),
7391                            ]),
7392                            base_version: None,
7393                        },
7394                        Mutation::Upsert {
7395                            table: "notes".into(),
7396                            values: Map::from_iter([
7397                                ("id".into(), Value::from("n")),
7398                                ("project_id".into(), Value::from("p1")),
7399                                ("title".into(), Value::from("pending note")),
7400                            ]),
7401                            base_version: None,
7402                        },
7403                    ])
7404                    .unwrap();
7405            }
7406            let pending = actual.pending_commit_ids();
7407            actual.overlay_rebuild_count.set(0);
7408            for (id, title) in [
7409                ("b", "available"),
7410                ("b", "occupied"),
7411                ("a", "server original"),
7412                ("d", "local value"),
7413                ("d", "released"),
7414                ("c", "redelivered"),
7415            ] {
7416                let table = actual.schema.table("tasks").unwrap().clone();
7417                let values = vec![
7418                    Some(ColumnValue::String(id.into())),
7419                    Some(ColumnValue::String("p1".into())),
7420                    Some(ColumnValue::String(title.into())),
7421                ];
7422                if images {
7423                    let image = Connection::open_in_memory().unwrap();
7424                    image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7425                        CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7426                        INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
7427                    image
7428                        .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
7429                        .unwrap();
7430                    assert!(matches!(
7431                        actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
7432                        Ok(1)
7433                    ));
7434                } else {
7435                    let segment = RowsSegment {
7436                        table: "tasks".into(),
7437                        schema_version: 1,
7438                        columns: table.wire_columns.clone(),
7439                        blocks: vec![vec![ssp2::segment::SegmentRow {
7440                            server_version: 2,
7441                            values: values.clone(),
7442                        }]],
7443                    };
7444                    assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
7445                }
7446                reference.write_base_row("tasks", &values, 2).unwrap();
7447                reference.rebuild_overlay().expect("rebuild overlay");
7448                for table in ["tasks", "notes"] {
7449                    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")] {
7450                        assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
7451                    }
7452                }
7453                assert_eq!(actual.pending_commit_ids(), pending);
7454            }
7455            assert_eq!(
7456                actual.overlay_rebuild_count.get(),
7457                0,
7458                "each chunk must leave unrelated tables alone"
7459            );
7460            let before = actual
7461                .query("SELECT * FROM tasks ORDER BY id", &[])
7462                .unwrap();
7463            let fts_before = actual
7464                .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
7465                .unwrap();
7466            let revision = actual.local_revision();
7467            actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
7468            let table = actual.schema.table("tasks").unwrap().clone();
7469            let segment = RowsSegment {
7470                table: "tasks".into(),
7471                schema_version: 1,
7472                columns: table.wire_columns.clone(),
7473                blocks: vec![vec![ssp2::segment::SegmentRow {
7474                    server_version: 3,
7475                    values: vec![
7476                        Some(ColumnValue::String("b".into())),
7477                        Some(ColumnValue::String("p1".into())),
7478                        Some(ColumnValue::String("fresh".into())),
7479                    ],
7480                }]],
7481            };
7482            assert!(actual.apply_segment(0, &segment, false).is_err());
7483            assert!(actual.conn.is_autocommit());
7484            assert_eq!(
7485                actual
7486                    .query("SELECT * FROM tasks ORDER BY id", &[])
7487                    .unwrap(),
7488                before
7489            );
7490            assert_eq!(
7491                actual
7492                    .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
7493                    .unwrap(),
7494                fts_before
7495            );
7496            assert_eq!(actual.local_revision(), revision);
7497            assert_eq!(actual.pending_commit_ids(), pending);
7498        }
7499    }
7500
7501    #[test]
7502    fn import_reconciliation_preserves_typed_pending_primary_keys() {
7503        for (kind, a, b) in [
7504            ("string", json!("a"), json!("b")),
7505            ("integer", json!(1), json!(2)),
7506            ("boolean", json!(true), json!(false)),
7507            ("json", json!("1"), json!("2")),
7508        ] {
7509            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}]}]});
7510            for images in [false, true] {
7511                let mut client =
7512                    SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
7513                let table = client.schema.table("tasks").unwrap().clone();
7514                client
7515                    .subscribe("tasks".into(), "tasks".into(), vec![], None)
7516                    .unwrap();
7517                for (id, title) in [(&a, "original"), (&b, "occupied")] {
7518                    client
7519                        .write_base_row(
7520                            "tasks",
7521                            &vec![
7522                                json_to_column_value(&table.columns[0], Some(id)).unwrap(),
7523                                Some(ColumnValue::String(title.into())),
7524                            ],
7525                            1,
7526                        )
7527                        .unwrap();
7528                }
7529                client.rebuild_overlay_if_dirty().expect("rebuild overlay");
7530                client
7531                    .mutate(vec![Mutation::Upsert {
7532                        table: "tasks".into(),
7533                        values: Map::from_iter([
7534                            ("id".into(), a.clone()),
7535                            ("title".into(), json!("available")),
7536                        ]),
7537                        base_version: None,
7538                    }])
7539                    .unwrap();
7540                client.overlay_rebuild_count.set(0);
7541                for (title, expected) in [("available", "original"), ("occupied", "available")] {
7542                    let values = vec![
7543                        json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
7544                        Some(ColumnValue::String(title.into())),
7545                    ];
7546                    if images {
7547                        let image = Connection::open_in_memory().unwrap();
7548                        image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7549                            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7550                            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
7551                        image
7552                            .execute(
7553                                "INSERT INTO tasks VALUES (?1,?2,2)",
7554                                rusqlite::params![RowParam::Cell(&values[0]), title],
7555                            )
7556                            .unwrap();
7557                        assert!(
7558                            matches!(
7559                                client.apply_sqlite_image(
7560                                    &image,
7561                                    &table,
7562                                    false,
7563                                    &[],
7564                                    1,
7565                                    7,
7566                                    "digest"
7567                                ),
7568                                Ok(1)
7569                            ),
7570                            "{kind}, images={images}"
7571                        );
7572                    } else {
7573                        let segment = RowsSegment {
7574                            table: "tasks".into(),
7575                            schema_version: 1,
7576                            columns: table.wire_columns.clone(),
7577                            blocks: vec![vec![ssp2::segment::SegmentRow {
7578                                server_version: 2,
7579                                values,
7580                            }]],
7581                        };
7582                        assert!(
7583                            matches!(client.apply_segment(0, &segment, false), Ok(1)),
7584                            "{kind}"
7585                        );
7586                    }
7587                    let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
7588                    let actual: String = client
7589                        .conn
7590                        .query_row(
7591                            "SELECT title FROM tasks WHERE id=?1",
7592                            [RowParam::Cell(&key)],
7593                            |row| row.get(0),
7594                        )
7595                        .unwrap();
7596                    assert_eq!(actual, expected, "{kind}, images={images}");
7597                }
7598                assert_eq!(client.overlay_rebuild_count.get(), 0);
7599            }
7600        }
7601    }
7602
7603    fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
7604        let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
7605            "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
7606            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
7607            "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
7608        })))});
7609        let mut client =
7610            SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
7611        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();
7612        client.rebuild_overlay().expect("rebuild overlay");
7613        client
7614            .mutate(vec![Mutation::Upsert {
7615                table: "tasks".into(),
7616                values: Map::from_iter([
7617                    ("id".into(), json!("offline")),
7618                    ("project_id".into(), json!("p1")),
7619                    ("title".into(), json!("pending edit")),
7620                ]),
7621                base_version: None,
7622            }])
7623            .unwrap();
7624        let image = Connection::open_in_memory().unwrap();
7625        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7626            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
7627        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();
7628        image
7629            .execute(
7630                "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
7631                [rows],
7632            )
7633            .unwrap();
7634        client.overlay_rebuild_count.set(0);
7635        let table = client.schema.table("tasks").unwrap().clone();
7636        (client, image, table)
7637    }
7638
7639    #[test]
7640    fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
7641        let (mut client, image, table) = unique_import_fixture(1);
7642        image
7643            .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
7644            .unwrap();
7645        client
7646            .conn
7647            .execute_batch("PRAGMA max_page_count = 1")
7648            .unwrap();
7649        let effective = [("project_id".into(), vec!["p1".into()])];
7650        assert!(client
7651            .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
7652            .is_err());
7653        let failure = client.storage_failure.borrow().clone().unwrap();
7654        assert_eq!(failure.code, Some("client.storage_full"));
7655        assert_eq!(failure.sqlite_code, Some(13));
7656        assert!(!failure.message.contains("rollback"));
7657        assert!(client.conn.is_autocommit());
7658        client
7659            .conn
7660            .execute_batch("PRAGMA max_page_count = 1073741823")
7661            .unwrap();
7662        assert!(matches!(
7663            client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
7664            Ok(1)
7665        ));
7666        assert!(client.conn.is_autocommit());
7667    }
7668
7669    #[test]
7670    fn authoring_storage_failures_are_structured_and_scoped() {
7671        let path = std::env::temp_dir().join(format!(
7672            "syncular-authoring-failure-{}.db",
7673            uuid::Uuid::new_v4()
7674        ));
7675        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
7676            {"name":"id","type":"string","nullable":false},
7677            {"name":"project_id","type":"string","nullable":false},
7678            {"name":"title","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
7679        let mut client = SyncClient::open_path(
7680            "authoring".into(),
7681            &schema,
7682            ClientLimits::default(),
7683            path.to_str().unwrap(),
7684        )
7685        .expect("open client");
7686        client.create_synced_tables().expect("create synced tables");
7687        // Return BUSY immediately rather than waiting out the file timeout.
7688        client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
7689
7690        let upsert = |id: &str| {
7691            vec![Mutation::Upsert {
7692                table: "tasks".into(),
7693                values: Map::from_iter([
7694                    ("id".into(), json!(id)),
7695                    ("project_id".into(), json!("p1")),
7696                    ("title".into(), json!("first")),
7697                ]),
7698                base_version: None,
7699            }]
7700        };
7701
7702        let locker = Connection::open(&path).expect("second connection");
7703        locker.execute_batch("BEGIN IMMEDIATE").unwrap();
7704        let busy = client.mutate(upsert("t1")).expect_err("busy write");
7705        assert_eq!(busy.code, "client.storage_busy");
7706        assert!(busy.retryable);
7707        assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
7708        assert!(client.pending_commit_ids().is_empty());
7709        assert_eq!(client.local_revision(), 0);
7710
7711        // A stale classified failure must not classify a later unrelated one.
7712        let invalid = client.mutate(Vec::new()).expect_err("empty commit");
7713        assert_eq!(invalid.code, "sync.invalid_request");
7714        assert_eq!(invalid.message, "the authoring request is invalid");
7715        assert!(
7716            invalid.details.as_ref().unwrap()["legacyCause"]
7717                .as_str()
7718                .unwrap()
7719                .contains("at least one operation"),
7720            "{invalid:?}"
7721        );
7722
7723        locker.execute_batch("ROLLBACK").unwrap();
7724        let id = client.mutate(upsert("t1")).expect("retry after busy");
7725        assert_eq!(client.pending_commit_ids(), vec![id]);
7726        assert_eq!(client.local_revision(), 1);
7727
7728        // A later storage failure reports its own code and details.
7729        locker.execute_batch("BEGIN IMMEDIATE").unwrap();
7730        let busy_again = client.mutate(upsert("t2")).expect_err("busy write again");
7731        assert_eq!(busy_again.code, "client.storage_busy");
7732        assert!(busy_again.retryable);
7733        locker.execute_batch("ROLLBACK").unwrap();
7734
7735        // Non-retryable classified failures keep their sqlite evidence too.
7736        let full = ClientError::from(QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7737            rusqlite::ffi::Error::new(13),
7738            Some("database or disk is full".to_owned()),
7739        )));
7740        assert_eq!(full.code, "client.storage_full");
7741        assert!(!full.retryable);
7742        assert_eq!(full.details.as_ref().unwrap()["sqliteCode"], 13);
7743
7744        drop(locker);
7745        drop(client);
7746        let _ = std::fs::remove_file(&path);
7747    }
7748
7749    #[test]
7750    fn caller_value_failures_are_invalid_request_and_leave_no_outbox_entry() {
7751        let path = std::env::temp_dir().join(format!(
7752            "syncular-caller-values-{}.db",
7753            uuid::Uuid::new_v4()
7754        ));
7755        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
7756            {"name":"id","type":"string","nullable":false},
7757            {"name":"project_id","type":"string","nullable":false},
7758            {"name":"title","type":"string","nullable":false},
7759            {"name":"priority","type":"integer","nullable":false},
7760            {"name":"payload","type":"bytes","nullable":false},
7761            {"name":"extra","type":"bytes","nullable":true}],"scopes":[{"pattern":"project:{project_id}"}]}]});
7762        let mut client = SyncClient::open_path(
7763            "caller-values".into(),
7764            &schema,
7765            ClientLimits::default(),
7766            path.to_str().unwrap(),
7767        )
7768        .expect("open client");
7769        client.create_synced_tables().expect("create synced tables");
7770
7771        let values = |id: &str| {
7772            Map::from_iter([
7773                ("id".to_owned(), json!(id)),
7774                ("project_id".to_owned(), json!("p1")),
7775                ("title".to_owned(), json!("first")),
7776                ("priority".to_owned(), json!(1)),
7777                ("payload".to_owned(), json!({"$bytes": "0a"})),
7778            ])
7779        };
7780        let upsert = |values: Map<String, Value>| {
7781            vec![Mutation::Upsert {
7782                table: "tasks".into(),
7783                values,
7784                base_version: None,
7785            }]
7786        };
7787        let seed = client.mutate(upsert(values("t1"))).expect("seed row");
7788        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
7789
7790        // §6.1/§7.1: every caller value the authoring seam rejects reports the
7791        // same stable identity instead of the internal-failure default, keeps
7792        // the dynamic cause in `details.legacyCause`, and leaves the durable
7793        // outbox, the local revision and the projection untouched.
7794        for (label, override_value, cause) in [
7795            ("unknown column", ("nope", json!(1)), "unknown column"),
7796            (
7797                "internal sync column",
7798                ("_sync_version", json!(1)),
7799                "internal sync column",
7800            ),
7801            ("wrong value type", ("title", json!(7)), "expected a string"),
7802            (
7803                "malformed bytes envelope",
7804                ("payload", json!({"$bytes": "zz"})),
7805                "expected exactly",
7806            ),
7807            (
7808                "odd-length bytes envelope",
7809                ("payload", json!({"$bytes": "abc"})),
7810                "expected exactly",
7811            ),
7812            (
7813                "plus-sign bytes envelope",
7814                ("payload", json!({"$bytes": "+a"})),
7815                "expected exactly",
7816            ),
7817            (
7818                "bytes envelope with a second key",
7819                ("payload", json!({"$bytes": "0a", "extra": 1})),
7820                "expected exactly",
7821            ),
7822            (
7823                "no bytes envelope",
7824                ("payload", json!({"nested": true})),
7825                "expected exactly",
7826            ),
7827        ] {
7828            let mut full = values("t2");
7829            full.insert(override_value.0.to_owned(), override_value.1);
7830            let error = client.mutate(upsert(full)).expect_err(label);
7831            assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
7832            assert_eq!(error.message, "the authoring request is invalid", "{label}");
7833            assert!(!error.retryable, "{label}");
7834            let legacy = error.details.as_ref().unwrap()["legacyCause"]
7835                .as_str()
7836                .unwrap();
7837            assert!(
7838                legacy.starts_with("sync.invalid_request: ") && legacy.contains(cause),
7839                "{label}: {legacy}"
7840            );
7841            assert_eq!(client.pending_commit_ids(), vec![seed.clone()], "{label}");
7842            assert_eq!(client.local_revision(), 1, "{label}");
7843            assert_eq!(
7844                client.query("SELECT * FROM tasks", &[]).unwrap(),
7845                rows,
7846                "{label}"
7847            );
7848        }
7849
7850        // A full-row upsert presents every column, so a missing required one is
7851        // the caller's failure too.
7852        let mut missing = values("t3");
7853        missing.remove("payload");
7854        let missing_error = client.mutate(upsert(missing)).expect_err("missing column");
7855        assert_eq!(missing_error.code, "sync.invalid_request");
7856        assert!(missing_error.details.as_ref().unwrap()["legacyCause"]
7857            .as_str()
7858            .unwrap()
7859            .contains("is not nullable"));
7860
7861        // A primary key the wire cannot render is rejected before any op is
7862        // recorded.
7863        let mut bad_row_id = values("t4");
7864        bad_row_id.insert("id".to_owned(), json!({"nested": true}));
7865        let row_id_error = client.mutate(upsert(bad_row_id)).expect_err("bad rowId");
7866        assert_eq!(row_id_error.code, "sync.invalid_request");
7867
7868        // The sparse route classifies the same value failures.
7869        for (label, partial, cause) in [
7870            (
7871                "patch wrong value type",
7872                ("priority", json!("high")),
7873                "expected an integer",
7874            ),
7875            ("patch unknown column", ("nope", json!(1)), "unknown column"),
7876        ] {
7877            let error = client
7878                .patch(
7879                    "tasks",
7880                    "t1",
7881                    Map::from_iter([(partial.0.to_owned(), partial.1)]),
7882                    None,
7883                )
7884                .expect_err(label);
7885            assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
7886            assert_eq!(error.message, "the authoring request is invalid", "{label}");
7887            assert!(
7888                error.details.as_ref().unwrap()["legacyCause"]
7889                    .as_str()
7890                    .unwrap()
7891                    .contains(cause),
7892                "{label}"
7893            );
7894        }
7895        assert_eq!(client.pending_commit_ids(), vec![seed.clone()]);
7896        assert_eq!(client.local_revision(), 1);
7897        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
7898
7899        // Nullable explicit null, an empty envelope, and uppercase digits stay
7900        // accepted; the stored payload is the decoded byte sequence.
7901        for (label, payload) in [
7902            ("explicit null", json!(null)),
7903            ("empty envelope", json!({"$bytes": ""})),
7904            ("uppercase envelope", json!({"$bytes": "0AFF"})),
7905        ] {
7906            let mut accepted = values("t-ok");
7907            accepted.insert("extra".to_owned(), payload);
7908            let encoded = client.mutate(upsert(accepted)).unwrap_or_else(|error| {
7909                panic!("{label} must be accepted: {error:?}");
7910            });
7911            assert!(!encoded.is_empty(), "{label}");
7912        }
7913
7914        // Nothing is poisoned: the next valid authoring call succeeds.
7915        let next = client
7916            .mutate(upsert(values("t5")))
7917            .expect("valid after failures");
7918        assert_ne!(next, seed);
7919
7920        drop(client);
7921        let _ = std::fs::remove_file(&path);
7922    }
7923
7924    #[test]
7925    fn a_legacy_outbox_value_the_codec_refuses_is_dropped_instead_of_wedging() {
7926        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
7927            {"name":"id","type":"string","nullable":false},
7928            {"name":"project_id","type":"string","nullable":false},
7929            {"name":"payload","type":"bytes","nullable":true}],
7930            "scopes":[{"pattern":"project:{project_id}"}]}]});
7931        let open = |path: &std::path::Path| {
7932            SyncClient::open_path(
7933                "legacy-values".into(),
7934                &schema,
7935                ClientLimits::default(),
7936                path.to_str().unwrap(),
7937            )
7938            .expect("open client")
7939        };
7940        let row = |id: &str| {
7941            Map::from_iter([
7942                ("id".to_owned(), json!(id)),
7943                ("project_id".to_owned(), json!("p1")),
7944                ("payload".to_owned(), json!({"$bytes": "0a"})),
7945            ])
7946        };
7947        let upsert = |values| {
7948            vec![Mutation::Upsert {
7949                table: "tasks".into(),
7950                values,
7951                base_version: None,
7952            }]
7953        };
7954        for (label, stored_cell, representable) in [
7955            ("plus-sign envelope", json!({"$bytes": "+a"}), false),
7956            ("non-hex envelope", json!({"$bytes": "zz"}), false),
7957            ("odd-length envelope", json!({"$bytes": "abc"}), false),
7958            ("no envelope", json!({"nested": true}), false),
7959            // A wrong declared type and a stored null for a non-nullable column
7960            // are journalable as stored, so their rejection keeps the operation
7961            // envelope.
7962            ("wrong declared type", json!("high"), true),
7963            ("stored null for a non-nullable column", json!(null), true),
7964        ] {
7965            let path = std::env::temp_dir().join(format!(
7966                "syncular-legacy-values-{}-{}.db",
7967                label.replace(' ', "-"),
7968                uuid::Uuid::new_v4()
7969            ));
7970            let mut client = open(&path);
7971            client.create_synced_tables().expect("create synced tables");
7972            let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
7973            let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
7974            drop(client);
7975
7976            // The stored intent an earlier version accepted.
7977            let connection = Connection::open(&path).expect("raw connection");
7978            let (column, cell) = if stored_cell.is_null() {
7979                ("project_id", stored_cell.clone())
7980            } else {
7981                ("payload", stored_cell.clone())
7982            };
7983            let mut planted_values = json!({
7984                "id": "t1", "project_id": "p1", "payload": {"$bytes": "0a"}
7985            });
7986            planted_values[column] = cell;
7987            let planted = json!([{
7988                "op": "upsert",
7989                "table": "tasks",
7990                "rowId": "t1",
7991                "baseVersion": null,
7992                "values": planted_values
7993            }]);
7994            connection
7995                .execute(
7996                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
7997                    rusqlite::params![planted.to_string(), stale],
7998                )
7999                .expect("plant legacy intent");
8000            drop(connection);
8001
8002            // Reopen: the startup reconciliation recovers the commit before any
8003            // replay, so the observable phase matches the browser core.
8004            let reopened = open(&path);
8005            assert_eq!(
8006                reopened.pending_commit_ids(),
8007                vec![successor.clone()],
8008                "{label}"
8009            );
8010            let rejection = reopened
8011                .rejections()
8012                .iter()
8013                .find(|rejection| rejection.client_commit_id == stale)
8014                .unwrap_or_else(|| panic!("{label}: rejection"));
8015            assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8016            assert_eq!(
8017                rejection.message, "the persisted commit carries values the current codec refuses",
8018                "{label}"
8019            );
8020            assert_eq!(rejection.operation.is_some(), representable, "{label}");
8021            assert_eq!(
8022                rejection
8023                    .details
8024                    .as_ref()
8025                    .and_then(|details| details.reason.as_deref()),
8026                Some(INVALID_STORED_VALUES_REASON),
8027                "{label}"
8028            );
8029            // The rejection stays readable after a further reopen.
8030            let journal = reopened
8031                .commit_outcome(&stale)
8032                .expect("read outcome")
8033                .expect("journal entry");
8034            assert!(matches!(journal.status, CommitOutcomeStatus::Rejected));
8035            drop(reopened);
8036            let mut reopened = open(&path);
8037            let reread = reopened
8038                .commit_outcome(&stale)
8039                .expect("read outcome")
8040                .expect("journal entry");
8041            assert!(matches!(reread.status, CommitOutcomeStatus::Rejected));
8042
8043            // The legacy row's optimistic projection is gone, the successor
8044            // survives and still encodes, and the round path keeps the queue.
8045            assert_eq!(
8046                reopened
8047                    .query("SELECT id FROM tasks ORDER BY id", &[])
8048                    .unwrap(),
8049                vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8050                "{label}"
8051            );
8052            assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8053            assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
8054            assert!(!reopened
8055                .drop_unencodable_outbox(false, None)
8056                .expect("idempotent prepass"));
8057            let mut transport = CountingRealtimeTransport::default();
8058            let outcome = reopened.sync(&mut transport);
8059            assert!(matches!(outcome, SyncOutcome::Failed { .. }), "{outcome:?}");
8060            assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8061            let next = reopened.mutate(upsert(row("t3"))).expect("after the drop");
8062            assert_eq!(reopened.pending_commit_ids(), vec![successor, next]);
8063            assert_eq!(reopened.pending_payloads().len(), 2, "{label}");
8064            drop(reopened);
8065            let _ = std::fs::remove_file(&path);
8066        }
8067    }
8068
8069    #[test]
8070    fn two_legacy_values_on_the_same_row_recover_in_fifo_order() {
8071        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8072            {"name":"id","type":"string","nullable":false},
8073            {"name":"project_id","type":"string","nullable":false},
8074            {"name":"payload","type":"bytes","nullable":true}],
8075            "scopes":[{"pattern":"project:{project_id}"}]}]});
8076        for (label, column, planted) in [
8077            ("malformed envelope", "payload", json!({"$bytes": "zz"})),
8078            (
8079                "stored null for a non-nullable column",
8080                "project_id",
8081                json!(null),
8082            ),
8083            ("wrong declared type", "payload", json!(7)),
8084        ] {
8085            let path = std::env::temp_dir().join(format!(
8086                "syncular-shared-row-{}-{}.db",
8087                label.replace(' ', "-"),
8088                uuid::Uuid::new_v4()
8089            ));
8090            let open = |path: &std::path::Path| {
8091                SyncClient::open_path(
8092                    "shared-row".into(),
8093                    &schema,
8094                    ClientLimits::default(),
8095                    path.to_str().unwrap(),
8096                )
8097                .expect("open client")
8098            };
8099            let row = |id: &str| {
8100                Map::from_iter([
8101                    ("id".to_owned(), json!(id)),
8102                    ("project_id".to_owned(), json!("p1")),
8103                    ("payload".to_owned(), json!({"$bytes": "0a"})),
8104                ])
8105            };
8106            let upsert = |values| {
8107                vec![Mutation::Upsert {
8108                    table: "tasks".into(),
8109                    values,
8110                    base_version: None,
8111                }]
8112            };
8113            let mut client = open(&path);
8114            client.create_synced_tables().expect("create synced tables");
8115            let older = client.mutate(upsert(row("t1"))).expect("older commit");
8116            let newer = client.mutate(upsert(row("t1"))).expect("newer commit");
8117            let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8118            drop(client);
8119
8120            let connection = Connection::open(&path).expect("raw connection");
8121            for commit_id in [&older, &newer] {
8122                let mut values = row("t1");
8123                values.insert(column.to_owned(), planted.clone());
8124                let planted_ops = json!([{
8125                    "op": "upsert",
8126                    "table": "tasks",
8127                    "rowId": "t1",
8128                    "baseVersion": null,
8129                    "values": values
8130                }]);
8131                connection
8132                    .execute(
8133                        "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8134                        rusqlite::params![planted_ops.to_string(), commit_id],
8135                    )
8136                    .expect("plant legacy intent");
8137            }
8138            drop(connection);
8139
8140            // Startup recovery drops both, FIFO, and publishes one batch.
8141            let mut reopened = open(&path);
8142            assert_eq!(
8143                reopened.pending_commit_ids(),
8144                vec![successor.clone()],
8145                "{label}"
8146            );
8147            assert_eq!(
8148                reopened
8149                    .rejections()
8150                    .iter()
8151                    .map(|rejection| rejection.client_commit_id.clone())
8152                    .collect::<Vec<_>>(),
8153                vec![older.clone(), newer.clone()],
8154                "{label}"
8155            );
8156            assert!(
8157                reopened
8158                    .rejections()
8159                    .iter()
8160                    .all(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE
8161                        && rejection.details.as_ref().and_then(|d| d.reason.as_deref())
8162                            == Some(INVALID_STORED_VALUES_REASON)),
8163                "{label}"
8164            );
8165            for commit_id in [&older, &newer] {
8166                assert!(
8167                    matches!(
8168                        reopened
8169                            .commit_outcome(commit_id)
8170                            .expect("outcome")
8171                            .map(|o| o.status),
8172                        Some(CommitOutcomeStatus::Rejected)
8173                    ),
8174                    "{label}"
8175                );
8176            }
8177            assert_eq!(
8178                reopened
8179                    .query("SELECT id FROM tasks ORDER BY id", &[])
8180                    .unwrap(),
8181                vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8182                "{label}"
8183            );
8184            let revision = reopened.local_revision();
8185            let batches = reopened.drain_change_batches();
8186            assert!(
8187                batches.iter().any(|batch| batch.rejections_changed
8188                    && batch.outcomes_changed
8189                    && batch.tables.iter().any(|change| change.table == "tasks")
8190                    && batch.revision == revision.to_string()),
8191                "{label}: {batches:?}"
8192            );
8193            drop(reopened);
8194
8195            // An ordinary reopen with nothing to recover publishes nothing and
8196            // leaves the revision alone.
8197            let mut stable = open(&path);
8198            assert_eq!(stable.local_revision(), revision, "{label}");
8199            assert!(stable.drain_change_batches().is_empty(), "{label}");
8200            assert_eq!(stable.pending_commit_ids(), vec![successor], "{label}");
8201            drop(stable);
8202            let _ = std::fs::remove_file(&path);
8203        }
8204    }
8205
8206    #[test]
8207    fn recovery_omits_a_legacy_operation_the_journal_reader_rejects() {
8208        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8209            {"name":"id","type":"string","nullable":false},
8210            {"name":"project_id","type":"string","nullable":false},
8211            {"name":"payload","type":"bytes","nullable":true}],
8212            "scopes":[{"pattern":"project:{project_id}"}]}]});
8213        for (label, second_op) in [
8214            (
8215                "delete carrying values",
8216                json!({"op": "delete", "table": "tasks", "rowId": "t9", "values": {}}),
8217            ),
8218            (
8219                "baseVersion beyond the safe bound",
8220                json!({"op": "upsert", "table": "tasks", "rowId": "t9",
8221                    "baseVersion": MAX_JS_SAFE_INTEGER + 1,
8222                    "values": {"id": "t9", "project_id": "p1", "payload": {"$bytes": "0a"}}}),
8223            ),
8224        ] {
8225            let path = std::env::temp_dir().join(format!(
8226                "syncular-journal-shape-{}-{}.db",
8227                label.replace(' ', "-"),
8228                uuid::Uuid::new_v4()
8229            ));
8230            let open = |path: &std::path::Path| {
8231                SyncClient::open_path(
8232                    "journal-shape".into(),
8233                    &schema,
8234                    ClientLimits::default(),
8235                    path.to_str().unwrap(),
8236                )
8237                .expect("open client")
8238            };
8239            let values = |id: &str| {
8240                Map::from_iter([
8241                    ("id".to_owned(), json!(id)),
8242                    ("project_id".to_owned(), json!("p1")),
8243                    ("payload".to_owned(), json!({"$bytes": "0a"})),
8244                ])
8245            };
8246            let upsert = |values| {
8247                vec![Mutation::Upsert {
8248                    table: "tasks".into(),
8249                    values,
8250                    base_version: None,
8251                }]
8252            };
8253            let mut client = open(&path);
8254            client.create_synced_tables().expect("create synced tables");
8255            let stale = client.mutate(upsert(values("t1"))).expect("stale commit");
8256            drop(client);
8257
8258            let connection = Connection::open(&path).expect("raw connection");
8259            // The first operation triggers the drop with canonical cells (a
8260            // stored null for a non-nullable column); the second one is the
8261            // operation shape the journal reader refuses.
8262            let planted = json!([
8263                {
8264                    "op": "upsert",
8265                    "table": "tasks",
8266                    "rowId": "t1",
8267                    "baseVersion": null,
8268                    "values": {"id": "t1", "project_id": null,
8269                        "payload": {"$bytes": "0a"}}
8270                },
8271                second_op
8272            ]);
8273            connection
8274                .execute(
8275                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8276                    rusqlite::params![planted.to_string(), stale],
8277                )
8278                .expect("plant legacy intent");
8279            drop(connection);
8280
8281            let reopened = open(&path);
8282            let journal = reopened
8283                .commit_outcome(&stale)
8284                .unwrap_or_else(|error| panic!("{label}: journal read: {error}"))
8285                .unwrap_or_else(|| panic!("{label}: journal entry"));
8286            assert!(
8287                matches!(journal.status, CommitOutcomeStatus::Rejected),
8288                "{label}"
8289            );
8290            assert!(journal.operations.is_none(), "{label}");
8291            assert!(
8292                reopened
8293                    .commit_outcomes(CommitOutcomeQuery::default())
8294                    .is_ok(),
8295                "{label}: outcome list query"
8296            );
8297            assert!(reopened.pending_commit_ids().is_empty(), "{label}");
8298            drop(reopened);
8299            let _ = std::fs::remove_file(&path);
8300        }
8301    }
8302
8303    #[test]
8304    fn legacy_values_recover_at_the_recreate_and_bump_reset_boundaries() {
8305        let schema = |version: i32, extra: bool| {
8306            let mut columns = vec![
8307                json!({"name":"id","type":"string","nullable":false}),
8308                json!({"name":"project_id","type":"string","nullable":false}),
8309                json!({"name":"payload","type":"bytes","nullable":true}),
8310            ];
8311            if extra {
8312                columns.push(json!({"name":"later","type":"string","nullable":true}));
8313            }
8314            json!({"version":version,"tables":[{"name":"tasks","primaryKey":"id","columns":columns,
8315                "scopes":[{"pattern":"project:{project_id}"}]}]})
8316        };
8317        for (label, bump) in [
8318            ("same-version recreation", false),
8319            ("version-bump reset", true),
8320            ("version-only bump reset", true),
8321        ] {
8322            let path = std::env::temp_dir().join(format!(
8323                "syncular-legacy-reset-{}-{}.db",
8324                label.replace(' ', "-"),
8325                uuid::Uuid::new_v4()
8326            ));
8327            let v1 = schema(1, false);
8328            let open = |schema: &Value, path: &std::path::Path| {
8329                SyncClient::open_path(
8330                    "legacy-reset".into(),
8331                    schema,
8332                    ClientLimits::default(),
8333                    path.to_str().unwrap(),
8334                )
8335                .expect("open client")
8336            };
8337            let row = |id: &str| {
8338                Map::from_iter([
8339                    ("id".to_owned(), json!(id)),
8340                    ("project_id".to_owned(), json!("p1")),
8341                    ("payload".to_owned(), json!({"$bytes": "0a"})),
8342                ])
8343            };
8344            let upsert = |values| {
8345                vec![Mutation::Upsert {
8346                    table: "tasks".into(),
8347                    values,
8348                    base_version: None,
8349                }]
8350            };
8351            let mut client = open(&v1, &path);
8352            client.create_synced_tables().expect("create synced tables");
8353            let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
8354            let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8355            drop(client);
8356
8357            let connection = Connection::open(&path).expect("raw connection");
8358            let planted = json!([{
8359                "op": "upsert",
8360                "table": "tasks",
8361                "rowId": "t1",
8362                "baseVersion": null,
8363                "values": {"id": "t1", "project_id": 7, "payload": {"$bytes": "0a"}}
8364            }]);
8365            connection
8366                .execute(
8367                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8368                    rusqlite::params![planted.to_string(), stale],
8369                )
8370                .expect("plant legacy intent");
8371            drop(connection);
8372
8373            let target = if bump {
8374                schema(2, label == "version-bump reset")
8375            } else {
8376                v1.clone()
8377            };
8378            let reopened = open(&target, &path);
8379            let rejection = reopened
8380                .rejections()
8381                .iter()
8382                .find(|rejection| rejection.client_commit_id == stale)
8383                .unwrap_or_else(|| panic!("{label}: rejection"));
8384            assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8385            assert_eq!(
8386                rejection
8387                    .details
8388                    .as_ref()
8389                    .and_then(|details| details.reason.as_deref()),
8390                Some(INVALID_STORED_VALUES_REASON),
8391                "{label}"
8392            );
8393            assert!(rejection.operation.is_some(), "{label}");
8394            assert_eq!(
8395                reopened.pending_commit_ids(),
8396                vec![successor.clone()],
8397                "{label}"
8398            );
8399            // The successor survives and still encodes. A bump that adds a
8400            // column leaves the older full-row upsert partial over the empty
8401            // base, so its visible row appears when the server answers it
8402            // (§7.1); the same-version recreation replays it locally.
8403            assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
8404            if label == "version-bump reset" {
8405                assert_eq!(
8406                    reopened
8407                        .query("SELECT id FROM tasks ORDER BY id", &[])
8408                        .unwrap(),
8409                    Vec::<Map<String, Value>>::new(),
8410                    "{label}"
8411                );
8412                // The guarded bump reset owns the publication, so its change
8413                // batch carries the recovery's rejection, outcome, and table
8414                // domains at the revision it advanced to.
8415                let mut next = reopened;
8416                let revision = next.local_revision();
8417                let batches = next.drain_change_batches();
8418                assert!(
8419                    batches.iter().any(|batch| batch.rejections_changed
8420                        && batch.outcomes_changed
8421                        && batch.tables.iter().any(|change| change.table == "tasks")
8422                        && batch.revision == revision.to_string()),
8423                    "{label}: {batches:?}"
8424                );
8425                next.mutate(upsert(Map::from_iter([
8426                    ("id".to_owned(), json!("t3")),
8427                    ("project_id".to_owned(), json!("p1")),
8428                    ("payload".to_owned(), json!({"$bytes": "0b"})),
8429                    ("later".to_owned(), json!("v2")),
8430                ])))
8431                .expect("v2 authoring after the reset");
8432                assert_eq!(next.pending_commit_ids().len(), 2, "{label}");
8433                assert_eq!(next.pending_payloads().len(), 2, "{label}");
8434                drop(next);
8435            } else {
8436                assert_eq!(
8437                    reopened
8438                        .query("SELECT id FROM tasks ORDER BY id", &[])
8439                        .unwrap(),
8440                    vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8441                    "{label}"
8442                );
8443                drop(reopened);
8444            }
8445
8446            // Queue and corrupt a new commit while the client stays open so only
8447            // recreation can recover it.
8448            if !bump {
8449                let mut recreated = open(&v1, &path);
8450                let fresh = recreated
8451                    .mutate(upsert(row("t3")))
8452                    .expect("fresh commit while open");
8453                recreated.drain_change_batches();
8454                let planted_fresh = json!([{
8455                    "op": "upsert",
8456                    "table": "tasks",
8457                    "rowId": "t3",
8458                    "baseVersion": null,
8459                    "values": {"id": "t3", "project_id": "p1",
8460                        "payload": {"$bytes": "zz"}}
8461                }]);
8462                let connection = Connection::open(&path).expect("raw connection");
8463                let affected = connection
8464                    .execute(
8465                        "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8466                        rusqlite::params![planted_fresh.to_string(), fresh],
8467                    )
8468                    .expect("plant fresh legacy intent");
8469                assert_eq!(
8470                    affected, 1,
8471                    "{label}: the in-place plant must hit the queued commit"
8472                );
8473                drop(connection);
8474                let revision_before = recreated.local_revision();
8475                recreated
8476                    .recreate_with_schema(&v1)
8477                    .unwrap_or_else(|error| panic!("{label}: recreation failed: {error}"));
8478                assert_eq!(recreated.pending_commit_ids(), vec![successor], "{label}");
8479                let fresh_rejection = recreated
8480                    .rejections()
8481                    .iter()
8482                    .find(|rejection| rejection.client_commit_id == fresh)
8483                    .expect("the in-place recovery rejection");
8484                assert_eq!(fresh_rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8485                assert_eq!(
8486                    fresh_rejection
8487                        .details
8488                        .as_ref()
8489                        .and_then(|details| details.reason.as_deref()),
8490                    Some(INVALID_STORED_VALUES_REASON),
8491                    "{label}"
8492                );
8493                assert!(
8494                    matches!(
8495                        recreated
8496                            .commit_outcome(&fresh)
8497                            .expect("outcome read")
8498                            .map(|outcome| outcome.status),
8499                        Some(CommitOutcomeStatus::Rejected)
8500                    ),
8501                    "{label}"
8502                );
8503                assert!(recreated.local_revision() > revision_before, "{label}");
8504                let revision = recreated.local_revision();
8505                let batches = recreated.drain_change_batches();
8506                assert_eq!(batches.len(), 1, "{label}: one recovery batch");
8507                assert!(
8508                    batches.iter().any(|batch| batch.rejections_changed
8509                        && batch.outcomes_changed
8510                        && batch.tables.iter().any(|change| change.table == "tasks")
8511                        && batch.revision == revision.to_string()),
8512                    "{label}: {batches:?}"
8513                );
8514                assert_eq!(recreated.pending_payloads().len(), 1, "{label}");
8515                assert_eq!(
8516                    recreated
8517                        .query("SELECT id FROM tasks ORDER BY id", &[])
8518                        .unwrap(),
8519                    vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8520                    "{label}: the recovered commit leaves no projection"
8521                );
8522                drop(recreated);
8523            }
8524            let _ = std::fs::remove_file(&path);
8525        }
8526    }
8527
8528    #[test]
8529    fn legacy_values_recover_over_a_synced_authoritative_row() {
8530        let path = std::env::temp_dir().join(format!(
8531            "syncular-authoritative-row-{}.db",
8532            uuid::Uuid::new_v4()
8533        ));
8534        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8535            {"name":"id","type":"string","nullable":false},
8536            {"name":"project_id","type":"string","nullable":false},
8537            {"name":"title","type":"string","nullable":false},
8538            {"name":"payload","type":"bytes","nullable":true}],
8539            "scopes":[{"pattern":"project:{project_id}"}]}]});
8540        let open = |path: &std::path::Path| {
8541            SyncClient::open_path(
8542                "authoritative-row".into(),
8543                &schema,
8544                ClientLimits::default(),
8545                path.to_str().unwrap(),
8546            )
8547            .expect("open client")
8548        };
8549        let row = |title: &str| {
8550            Map::from_iter([
8551                ("id".to_owned(), json!("t1")),
8552                ("project_id".to_owned(), json!("p1")),
8553                ("title".to_owned(), json!(title)),
8554                ("payload".to_owned(), json!({"$bytes": "0a"})),
8555            ])
8556        };
8557        let upsert = |values| {
8558            vec![Mutation::Upsert {
8559                table: "tasks".into(),
8560                values,
8561                base_version: None,
8562            }]
8563        };
8564        let mut client = open(&path);
8565        client.create_synced_tables().expect("create synced tables");
8566        // A server-confirmed row: base and visible carry the authoritative
8567        // values at version 1.
8568        client
8569            .conn
8570            .execute_batch(
8571                "INSERT INTO _syncular_base_tasks VALUES ('t1','p1','authoritative',x'0a',1);
8572                 INSERT INTO tasks VALUES ('t1','p1','authoritative',x'0a',1);",
8573            )
8574            .expect("seed authoritative row");
8575        let older = client.mutate(upsert(row("older"))).expect("older");
8576        let newer = client.mutate(upsert(row("newer"))).expect("newer");
8577        let successor = client.mutate(upsert(row("successor"))).expect("successor");
8578        drop(client);
8579
8580        let connection = Connection::open(&path).expect("raw connection");
8581        for commit_id in [&older, &newer] {
8582            let planted = json!([{
8583                "op": "upsert",
8584                "table": "tasks",
8585                "rowId": "t1",
8586                "baseVersion": null,
8587                "values": {"id": "t1", "project_id": "p1", "title": "legacy",
8588                    "payload": {"$bytes": "zz"}}
8589            }]);
8590            connection
8591                .execute(
8592                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8593                    rusqlite::params![planted.to_string(), commit_id],
8594                )
8595                .expect("plant legacy intent");
8596        }
8597        drop(connection);
8598
8599        let reopened = open(&path);
8600        assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8601        assert_eq!(
8602            reopened
8603                .rejections()
8604                .iter()
8605                .map(|rejection| rejection.client_commit_id.clone())
8606                .collect::<Vec<_>>(),
8607            vec![older.clone(), newer.clone()]
8608        );
8609        // The pending successor overlays the restored authoritative row, which
8610        // keeps its server version.
8611        assert_eq!(
8612            reopened.query("SELECT id,title,_syncular_version FROM tasks", &[]),
8613            Ok(vec![Map::from_iter([
8614                ("id".to_owned(), json!("t1")),
8615                ("title".to_owned(), json!("successor")),
8616                ("_syncular_version".to_owned(), json!(1)),
8617            ])])
8618        );
8619
8620        // A later rejection of the surviving successor restores the
8621        // authoritative values and version exactly.
8622        drop(reopened);
8623        let connection = Connection::open(&path).expect("raw connection");
8624        connection
8625            .execute(
8626                "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8627                rusqlite::params![
8628                    json!([{
8629                        "op": "upsert",
8630                        "table": "tasks",
8631                        "rowId": "t1",
8632                        "baseVersion": null,
8633                        "values": {"id": "t1", "project_id": "p1", "title": "legacy",
8634                            "payload": {"$bytes": "zz"}}
8635                    }])
8636                    .to_string(),
8637                    successor
8638                ],
8639            )
8640            .expect("plant successor intent");
8641        drop(connection);
8642        let late = open(&path);
8643        assert!(late.pending_commit_ids().is_empty());
8644        assert_eq!(
8645            late.query("SELECT id,title,_syncular_version FROM tasks", &[]),
8646            Ok(vec![Map::from_iter([
8647                ("id".to_owned(), json!("t1")),
8648                ("title".to_owned(), json!("authoritative")),
8649                ("_syncular_version".to_owned(), json!(1)),
8650            ])])
8651        );
8652        drop(late);
8653        let _ = std::fs::remove_file(&path);
8654    }
8655
8656    #[test]
8657    fn a_failed_value_recovery_commit_restores_every_side_effect() {
8658        let path = std::env::temp_dir().join(format!(
8659            "syncular-value-recovery-commit-{}.db",
8660            uuid::Uuid::new_v4()
8661        ));
8662        let path_str = path.to_str().expect("path");
8663        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8664            {"name":"id","type":"string","nullable":false},
8665            {"name":"project_id","type":"string","nullable":false},
8666            {"name":"payload","type":"bytes","nullable":true}],
8667            "scopes":[{"pattern":"project:{project_id}"}]}]});
8668        // A rollback-journal replica, so a second read connection can hold
8669        // SHARED across the recovery transaction's COMMIT.
8670        let conn = Connection::open(path_str).expect("replica connection");
8671        let mut client = SyncClient::with_connection(
8672            "value-recovery-commit".into(),
8673            &schema,
8674            ClientLimits::default(),
8675            conn,
8676        )
8677        .expect("replica");
8678        assert_eq!(
8679            client
8680                .conn
8681                .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
8682                .expect("journal mode"),
8683            "delete"
8684        );
8685        client.create_synced_tables().expect("create synced tables");
8686        let stale = client
8687            .mutate(vec![Mutation::Upsert {
8688                table: "tasks".into(),
8689                values: Map::from_iter([
8690                    ("id".to_owned(), json!("t1")),
8691                    ("project_id".to_owned(), json!("p1")),
8692                    ("payload".to_owned(), json!({"$bytes": "0a"})),
8693                ]),
8694                base_version: None,
8695            }])
8696            .expect("queued write");
8697        client.drain_change_batches();
8698        let affected = client
8699            .conn
8700            .execute(
8701                "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8702                rusqlite::params![
8703                    json!([{
8704                        "op": "upsert",
8705                        "table": "tasks",
8706                        "rowId": "t1",
8707                        "baseVersion": null,
8708                        "values": {"id": "t1", "project_id": "p1",
8709                            "payload": {"$bytes": "zz"}}
8710                    }])
8711                    .to_string(),
8712                    stale
8713                ],
8714            )
8715            .expect("plant legacy intent");
8716        assert_eq!(affected, 1, "the planted row must exist");
8717        let revision = client.local_revision();
8718        let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
8719        let prior_outbox = client.pending_commit_ids();
8720        let prior_rejections = client.rejections().len();
8721        let prior_changes = client.change_queue.len();
8722        let prior_intents = client.sync_intent_queue.len();
8723        let prior_failed = client.failed_commits.len();
8724        // The reader holds SHARED for the whole attempt: the recovery runs to
8725        // its publication and the COMMIT fails.
8726        let reader = Connection::open(path_str).expect("reader");
8727        reader
8728            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
8729            .expect("reader snapshot");
8730        client
8731            .conn
8732            .busy_timeout(std::time::Duration::ZERO)
8733            .expect("busy timeout");
8734        let error = client
8735            .recreate_with_schema(&schema)
8736            .expect_err("a blocked COMMIT must refuse the recovery");
8737        assert!(error.starts_with("client.storage_busy:"), "{error}");
8738        assert!(client.conn.is_autocommit(), "the transaction is released");
8739        assert_eq!(client.local_revision(), revision);
8740        assert_eq!(client.pending_commit_ids(), prior_outbox);
8741        assert_eq!(client.rejections().len(), prior_rejections);
8742        assert_eq!(client.change_queue.len(), prior_changes);
8743        assert_eq!(client.sync_intent_queue.len(), prior_intents);
8744        assert_eq!(client.failed_commits.len(), prior_failed);
8745        assert_eq!(
8746            client.query("SELECT * FROM tasks", &[]).expect("rows"),
8747            rows
8748        );
8749        assert!(
8750            client
8751                .commit_outcome(&stale)
8752                .expect("outcome read")
8753                .is_none(),
8754            "the rolled-back recovery kept no outcome"
8755        );
8756        // The retry runs the same publication branch to completion.
8757        reader.execute_batch("ROLLBACK").expect("release reader");
8758        client
8759            .recreate_with_schema(&schema)
8760            .expect("retry after the blocked commit");
8761        assert!(client.pending_commit_ids().is_empty());
8762        assert_eq!(client.rejections().len(), prior_rejections + 1);
8763        assert!(client.local_revision() > revision);
8764        let batches = client.drain_change_batches();
8765        assert!(
8766            batches.iter().any(|batch| batch.rejections_changed
8767                && batch.outcomes_changed
8768                && batch.tables.iter().any(|change| change.table == "tasks")),
8769            "{batches:?}"
8770        );
8771        drop(client);
8772        let _ = std::fs::remove_file(&path);
8773    }
8774
8775    #[cfg(feature = "e2ee")]
8776    #[test]
8777    fn a_failed_recovery_restores_retained_failed_commits() {
8778        let path = std::env::temp_dir().join(format!(
8779            "syncular-value-recovery-retention-{}.db",
8780            uuid::Uuid::new_v4()
8781        ));
8782        let path_str = path.to_str().expect("path");
8783        let schema = json!({"version":1,"tables":[{"name":"secrets","primaryKey":"id","columns":[
8784            {"name":"id","type":"string","nullable":false},
8785            {"name":"project_id","type":"string","nullable":false},
8786            {"name":"note","type":"bytes","nullable":true,"encrypted":true,"declaredType":"string"}],
8787            "scopes":[{"pattern":"project:{project_id}"}]}]});
8788        let conn = Connection::open(path_str).expect("replica connection");
8789        let mut client = SyncClient::with_connection(
8790            "value-recovery-retention".into(),
8791            &schema,
8792            ClientLimits::default(),
8793            conn,
8794        )
8795        .expect("replica");
8796        client.create_synced_tables().expect("create synced tables");
8797        client.set_retain_failed_commits(true).expect("retention");
8798        // No keys configured: the §5.11 seal fails, so retention is exercised.
8799        client.set_encryption(crate::values::EncryptionConfig::default());
8800        client
8801            .mutate(vec![Mutation::Upsert {
8802                table: "secrets".into(),
8803                values: Map::from_iter([
8804                    ("id".to_owned(), json!("s1")),
8805                    ("project_id".to_owned(), json!("p1")),
8806                    ("note".to_owned(), json!("secret")),
8807                ]),
8808                base_version: None,
8809            }])
8810            .expect("queued write");
8811        client.drain_change_batches();
8812        let revision = client.local_revision();
8813        let prior_outbox = client.pending_commit_ids();
8814        let prior_rejections = client.rejections().len();
8815        let prior_failed = client.failed_commits.len();
8816        let reader = Connection::open(path_str).expect("reader");
8817        reader
8818            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
8819            .expect("reader snapshot");
8820        client
8821            .conn
8822            .busy_timeout(std::time::Duration::ZERO)
8823            .expect("busy timeout");
8824        let error = client
8825            .drop_unencodable_outbox(true, None)
8826            .expect_err("a blocked COMMIT must refuse the recovery");
8827        assert!(error.starts_with("client.storage_busy:"), "{error}");
8828        assert_eq!(client.failed_commits.len(), prior_failed);
8829        assert_eq!(client.pending_commit_ids(), prior_outbox);
8830        assert_eq!(client.rejections().len(), prior_rejections);
8831        assert_eq!(client.local_revision(), revision);
8832        // The retry retains the failed commit and drops it from the outbox.
8833        reader.execute_batch("ROLLBACK").expect("release reader");
8834        client
8835            .drop_unencodable_outbox(true, None)
8836            .expect("retry after the blocked commit");
8837        assert_eq!(client.failed_commits.len(), prior_failed + 1);
8838        assert!(client.pending_commit_ids().is_empty());
8839        assert_eq!(client.rejections().len(), prior_rejections + 1);
8840        drop(client);
8841        let _ = std::fs::remove_file(&path);
8842    }
8843
8844    #[test]
8845    fn client_error_maps_legacy_codes_and_static_messages() {
8846        let code_only = ClientError::from("sync.unknown_table".to_owned());
8847        assert_eq!(code_only.code, "sync.unknown_table");
8848        assert_eq!(code_only.message, "the commit targets an unknown table");
8849        assert!(code_only.details.is_none());
8850
8851        let dynamic = ClientError::from(
8852            "sync.invalid_request: table \"tasks\": patch cannot write scope column".to_owned(),
8853        );
8854        assert_eq!(dynamic.code, "sync.invalid_request");
8855        assert_eq!(dynamic.message, "the authoring request is invalid");
8856        assert!(dynamic.details.as_ref().unwrap()["legacyCause"]
8857            .as_str()
8858            .unwrap()
8859            .contains("patch cannot write scope column"));
8860
8861        let unclassified =
8862            ClientError::from("a commit must contain at least one operation".to_owned());
8863        assert_eq!(unclassified.code, "client.failed");
8864        assert_eq!(unclassified.message, "the local authoring operation failed");
8865        assert!(unclassified.details.as_ref().unwrap()["legacyCause"]
8866            .as_str()
8867            .unwrap()
8868            .contains("at least one operation"));
8869
8870        // An unclassified QueryReadFailure keeps its numeric SQLite evidence.
8871        let metadata = ClientError::from(QueryReadFailure {
8872            code: None,
8873            sqlite_code: Some(1),
8874            sqlite_message: Some("generic SQLite prose".to_owned()),
8875            rollback_failure: Some(Box::new(QueryReadFailure::from(
8876                rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(1), None),
8877            ))),
8878            message: "unclassified read".to_owned(),
8879        });
8880        assert_eq!(metadata.code, "client.failed");
8881        assert_eq!(metadata.message, "the local authoring operation failed");
8882        let details = metadata.details.as_ref().unwrap();
8883        assert_eq!(details["sqliteCode"], 1);
8884        assert_eq!(details["sqliteMessage"], "generic SQLite prose");
8885        assert_eq!(details["rollbackFailure"]["sqliteCode"], 1);
8886        assert_eq!(details["legacyCause"], "unclassified read");
8887    }
8888
8889    #[test]
8890    fn schema_marker_read_keeps_storage_classification() {
8891        let busy = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
8892            rusqlite::ffi::Error::new(5),
8893            Some("database is locked".to_owned()),
8894        ));
8895        assert!(busy.starts_with("client.storage_busy:"), "{busy}");
8896        let invalid = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
8897            rusqlite::ffi::Error::new(1),
8898            Some("unreadable".to_owned()),
8899        ));
8900        assert_eq!(
8901            invalid,
8902            "sync.local_corrupt: persisted local schema marker is unreadable or invalid"
8903        );
8904
8905        // A real exclusive lock on the marker read surfaces BUSY, not corrupt,
8906        // and the guard writes nothing.
8907        let path =
8908            std::env::temp_dir().join(format!("syncular-marker-busy-{}.db", uuid::Uuid::new_v4()));
8909        let setup = Connection::open(&path).expect("setup connection");
8910        setup
8911            .execute_batch(
8912                "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value TEXT);\n                 INSERT INTO _syncular_meta VALUES ('localSchemaVersion','1');",
8913            )
8914            .unwrap();
8915        let locker = Connection::open(&path).expect("locker connection");
8916        locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
8917        let reader = Connection::open(&path).expect("reader connection");
8918        reader.busy_timeout(std::time::Duration::ZERO).unwrap();
8919        let locked = read_local_schema_version(&reader, 1).expect_err("locked marker read");
8920        assert!(locked.starts_with("client.storage_busy:"), "{locked}");
8921        locker.execute_batch("ROLLBACK").unwrap();
8922        assert_eq!(read_local_schema_version(&reader, 1).unwrap(), Some(1));
8923        let rows = setup
8924            .query_row("SELECT count(*) FROM _syncular_meta", [], |row| {
8925                row.get::<_, i64>(0)
8926            })
8927            .unwrap();
8928        assert_eq!(rows, 1, "the guard performed no writes");
8929        drop(reader);
8930        drop(locker);
8931        drop(setup);
8932        let _ = std::fs::remove_file(&path);
8933    }
8934
8935    #[cfg(feature = "crdt-yjs")]
8936    #[test]
8937    fn crdt_authoring_pre_read_preserves_storage_classification() {
8938        let path =
8939            std::env::temp_dir().join(format!("syncular-crdt-busy-{}.db", uuid::Uuid::new_v4()));
8940        let schema = json!({"version":1,"tables":[{"name":"notes","primaryKey":"id","columns":[
8941            {"name":"id","type":"string","nullable":false},
8942            {"name":"project_id","type":"string","nullable":false},
8943            {"name":"doc","type":"crdt","nullable":true,"crdtType":"yjs-doc"}],
8944            "scopes":[{"pattern":"project:{project_id}"}]}]});
8945        let mut client = SyncClient::open_path(
8946            "crdt-busy".into(),
8947            &schema,
8948            ClientLimits::default(),
8949            path.to_str().unwrap(),
8950        )
8951        .expect("open client");
8952        client.create_synced_tables().expect("create synced tables");
8953        client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
8954
8955        let locker = Connection::open(&path).expect("locker connection");
8956        locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
8957        let busy = client
8958            .crdt_apply_update("notes", "n1", "doc", &[])
8959            .expect_err("busy crdt pre-read");
8960        assert_eq!(busy.code, "client.storage_busy");
8961        assert!(busy.retryable);
8962        assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
8963        assert!(client.pending_commit_ids().is_empty());
8964        assert_eq!(client.local_revision(), 0);
8965
8966        locker.execute_batch("ROLLBACK").unwrap();
8967        drop(locker);
8968        drop(client);
8969        let _ = std::fs::remove_file(&path);
8970    }
8971
8972    #[test]
8973    fn large_unique_import_copies_no_unrelated_tables() {
8974        let (mut client, image, table) = unique_import_fixture(10240);
8975        let pending = client.pending_commit_ids();
8976        client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
8977            CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
8978        assert!(matches!(
8979            client.apply_sqlite_image(
8980                &image,
8981                &table,
8982                true,
8983                &[("project_id".into(), vec!["p1".into()])],
8984                10240,
8985                7,
8986                "digest"
8987            ),
8988            Ok(10240)
8989        ));
8990        assert_eq!(client.overlay_rebuild_count.get(), 0);
8991        assert_eq!(client.pending_commit_ids(), pending);
8992        assert_eq!(
8993            client
8994                .query("SELECT count(*) AS n FROM tasks", &[])
8995                .unwrap()[0]["n"],
8996            10241
8997        );
8998        assert_eq!(
8999            client
9000                .query("SELECT count(*) AS n FROM tasks_fts", &[])
9001                .unwrap()[0]["n"],
9002            10241
9003        );
9004        assert_eq!(
9005            client
9006                .query("SELECT count(*) AS n FROM notes_fts", &[])
9007                .unwrap()[0]["n"],
9008            8192
9009        );
9010    }
9011
9012    #[test]
9013    #[ignore = "manual scaling benchmark; see bench/README.md"]
9014    fn benchmark_unique_image_import() {
9015        let mut samples = Vec::new();
9016        for rows in [10240, 51200, 102400] {
9017            for trial in 0..4 {
9018                let (mut client, image, table) = unique_import_fixture(rows);
9019                let start = std::time::Instant::now();
9020                assert!(
9021                    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)
9022                );
9023                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
9024                assert_eq!(
9025                    client
9026                        .query("SELECT count(*) AS n FROM tasks", &[])
9027                        .unwrap()[0]["n"],
9028                    rows + 1
9029                );
9030                assert_eq!(
9031                    client
9032                        .query("SELECT count(*) AS n FROM tasks_fts", &[])
9033                        .unwrap()[0]["n"],
9034                    rows + 1
9035                );
9036                assert_eq!(
9037                    client
9038                        .query("SELECT count(*) AS n FROM notes_fts", &[])
9039                        .unwrap()[0]["n"],
9040                    8192
9041                );
9042                assert_eq!(client.pending_commit_ids().len(), 1);
9043                if trial > 0 {
9044                    samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
9045                }
9046            }
9047        }
9048        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});
9049        let output = serde_json::to_string_pretty(&artifact).unwrap();
9050        if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
9051            use std::io::Write;
9052            let mut file = std::fs::OpenOptions::new()
9053                .write(true)
9054                .create_new(true)
9055                .open(path)
9056                .unwrap();
9057            file.write_all(output.as_bytes()).unwrap();
9058        } else {
9059            println!("{output}");
9060        }
9061    }
9062
9063    #[test]
9064    fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
9065        let schema = json!({
9066            "version": 1,
9067            "tables": [{
9068                "name": "catalogue_codes",
9069                "primaryKey": "id",
9070                "columns": [
9071                    { "name": "id", "type": "string", "nullable": false },
9072                    { "name": "release_id", "type": "string", "nullable": false },
9073                    { "name": "code", "type": "string", "nullable": false },
9074                    { "name": "title", "type": "string", "nullable": false }
9075                ],
9076                "scopes": [{ "pattern": "release:{release_id}" }],
9077                "ftsIndexes": [{
9078                    "name": "catalogue_codes_fts",
9079                    "columns": ["code", "title"],
9080                    "tokenize": "unicode61 remove_diacritics 2"
9081                }]
9082            }]
9083        });
9084        let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
9085            .expect("FTS5 client");
9086        let insert_trigger: String = client
9087            .conn
9088            .query_row(
9089                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
9090                [],
9091                |row| row.get(0),
9092            )
9093            .expect("insert trigger");
9094        let replace_guard: String = client
9095            .conn
9096            .query_row(
9097                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
9098                [],
9099                |row| row.get(0),
9100            )
9101            .expect("replace guard");
9102        assert!(!insert_trigger.contains("DELETE FROM"));
9103        assert!(replace_guard.contains("BEFORE INSERT"));
9104        assert!(replace_guard.contains("WHEN EXISTS"));
9105        let search = |client: &SyncClient, query: &str| {
9106            client
9107                .query(
9108                    "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",
9109                    &[Value::from(query)],
9110                )
9111                .expect("FTS query")
9112        };
9113
9114        client
9115            .mutate(vec![Mutation::Upsert {
9116                table: "catalogue_codes".to_owned(),
9117                values: Map::from_iter([
9118                    ("id".to_owned(), Value::from("c1")),
9119                    ("release_id".to_owned(), Value::from("r1")),
9120                    ("code".to_owned(), Value::from("A01")),
9121                    ("title".to_owned(), Value::from("Cholera")),
9122                ]),
9123                base_version: None,
9124            }])
9125            .expect("insert code");
9126        assert_eq!(search(&client, "cholera").len(), 1);
9127        let prior = client
9128            .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9129            .unwrap();
9130        client
9131            .conn
9132            .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
9133            .unwrap();
9134        client.create_synced_tables().unwrap();
9135        assert_eq!(
9136            client
9137                .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9138                .unwrap(),
9139            prior
9140        );
9141        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();
9142        let rendered = serde_json::to_string(&plan).unwrap();
9143        assert!(rendered.contains("COVERING INDEX"));
9144        assert!(rendered.contains("INDEX 0:="));
9145        client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
9146        assert_eq!(search(&client, "cholera").len(), 1);
9147
9148        client
9149            .mutate(vec![Mutation::Upsert {
9150                table: "catalogue_codes".to_owned(),
9151                values: Map::from_iter([
9152                    ("id".to_owned(), Value::from("c1")),
9153                    ("release_id".to_owned(), Value::from("r1")),
9154                    ("code".to_owned(), Value::from("A01")),
9155                    ("title".to_owned(), Value::from("Enteric infection")),
9156                ]),
9157                base_version: None,
9158            }])
9159            .expect("update code");
9160        assert!(search(&client, "cholera").is_empty());
9161        assert_eq!(search(&client, "enteric").len(), 1);
9162
9163        client
9164            .mutate(vec![Mutation::Delete {
9165                table: "catalogue_codes".to_owned(),
9166                row_id: "c1".to_owned(),
9167                base_version: None,
9168            }])
9169            .expect("delete code");
9170        assert!(search(&client, "enteric").is_empty());
9171    }
9172
9173    #[test]
9174    fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
9175        let schema = json!({
9176            "version": 1,
9177            "tables": [{
9178                "name": "patient_notes",
9179                "primaryKey": "id",
9180                "columns": [
9181                    { "name": "id", "type": "string", "nullable": false },
9182                    { "name": "practice_id", "type": "string", "nullable": false },
9183                    { "name": "encryption_key_id", "type": "string", "nullable": false },
9184                    { "name": "title", "type": "string", "nullable": false }
9185                ],
9186                "scopes": [{ "pattern": "practice:{practice_id}" }],
9187                "ftsIndexes": [{
9188                    "name": "patient_notes_fts",
9189                    "columns": ["title"],
9190                    "tokenize": "unicode61 remove_diacritics 2"
9191                }]
9192            }]
9193        });
9194        let mut client = SyncClient::new(
9195            "local-purge-test".to_owned(),
9196            &schema,
9197            ClientLimits::default(),
9198        )
9199        .expect("local purge client");
9200        client
9201            .conn
9202            .execute(
9203                "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
9204                 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
9205                 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
9206                [],
9207            )
9208            .expect("seed base rows");
9209        client.overlay_dirty.set(true);
9210        client.rebuild_overlay_if_dirty().expect("rebuild overlay");
9211
9212        let note = |id: &str, key_id: &str, title: &str| {
9213            Map::from_iter([
9214                ("id".to_owned(), Value::from(id)),
9215                ("practice_id".to_owned(), Value::from("practice-1")),
9216                ("encryption_key_id".to_owned(), Value::from(key_id)),
9217                ("title".to_owned(), Value::from(title)),
9218            ])
9219        };
9220        let doomed = client
9221            .mutate(vec![
9222                Mutation::Upsert {
9223                    table: "patient_notes".to_owned(),
9224                    values: note("target", "key-revoked", "Target changed"),
9225                    base_version: None,
9226                },
9227                Mutation::Upsert {
9228                    table: "patient_notes".to_owned(),
9229                    values: note("unrelated", "key-held", "Unrelated changed"),
9230                    base_version: None,
9231                },
9232            ])
9233            .expect("doomed commit");
9234        let kept = client
9235            .mutate(vec![Mutation::Upsert {
9236                table: "patient_notes".to_owned(),
9237                values: note("kept", "key-held", "Kept optimistic"),
9238                base_version: None,
9239            }])
9240            .expect("kept commit");
9241        client.drain_change_batches();
9242
9243        let input = LocalDataPurgeInput {
9244            purge_id: "purge-001".to_owned(),
9245            targets: vec![LocalDataPurgeTarget {
9246                table: "patient_notes".to_owned(),
9247                selectors: BTreeMap::from([(
9248                    "encryption_key_id".to_owned(),
9249                    vec!["key-revoked".to_owned()],
9250                )]),
9251            }],
9252        };
9253        assert_eq!(
9254            client.purge_local_data(&input).expect("apply purge"),
9255            LocalDataPurgeResult {
9256                already_applied: false,
9257                purged_rows: 1,
9258                dropped_commits: 1,
9259            }
9260        );
9261        let rows = client
9262            .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
9263            .expect("visible rows");
9264        assert_eq!(rows.len(), 2);
9265        assert_eq!(rows[0]["id"], "kept");
9266        assert_eq!(rows[1]["id"], "unrelated");
9267        assert_eq!(rows[1]["title"], "Unrelated original");
9268        let fts = client
9269            .query(
9270                "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'",
9271                &[],
9272            )
9273            .expect("fts query");
9274        assert!(fts.is_empty());
9275        assert_eq!(client.outbox.len(), 1);
9276        assert_eq!(client.outbox[0].client_commit_id, kept);
9277        let outcome = client
9278            .commit_outcome(&doomed)
9279            .expect("read doomed outcome")
9280            .expect("doomed outcome");
9281        assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
9282        match &outcome.results[0] {
9283            CommitOperationOutcome::Error { rejection } => {
9284                assert_eq!(rejection.code, "client.local_data_purged");
9285                assert_eq!(rejection.client_commit_id, doomed);
9286            }
9287            other => panic!("expected local purge rejection, got {other:?}"),
9288        }
9289        assert_eq!(client.drain_change_batches().len(), 1);
9290        assert_eq!(
9291            client.purge_local_data(&input).expect("retry purge"),
9292            LocalDataPurgeResult {
9293                already_applied: true,
9294                purged_rows: 0,
9295                dropped_commits: 0,
9296            }
9297        );
9298        let conflicting = LocalDataPurgeInput {
9299            purge_id: input.purge_id.clone(),
9300            targets: vec![LocalDataPurgeTarget {
9301                table: "patient_notes".to_owned(),
9302                selectors: BTreeMap::from([(
9303                    "encryption_key_id".to_owned(),
9304                    vec!["key-held".to_owned()],
9305                )]),
9306            }],
9307        };
9308        assert!(client
9309            .purge_local_data(&conflicting)
9310            .expect_err("id collision must fail")
9311            .contains("already used with a different plan"));
9312    }
9313}
9314
9315impl SubState {
9316    fn name(self) -> &'static str {
9317        match self {
9318            SubState::Active => "active",
9319            SubState::Revoked => "revoked",
9320            SubState::Failed => "failed",
9321        }
9322    }
9323
9324    fn parse(value: &str) -> Self {
9325        match value {
9326            "revoked" => Self::Revoked,
9327            "failed" => Self::Failed,
9328            _ => Self::Active,
9329        }
9330    }
9331}
9332
9333#[derive(Debug, Clone)]
9334struct Subscription {
9335    id: String,
9336    table: String,
9337    requested: Vec<(String, Vec<String>)>,
9338    params: Option<String>,
9339    cursor: i64,
9340    /// §4.7 resume token, round-tripped opaquely.
9341    bootstrap_state: Option<String>,
9342    state: SubState,
9343    reason_code: Option<String>,
9344    /// Last effective scopes echoed while active (§3.3: persisted for the
9345    /// purge contract; each active echo replaces it).
9346    effective: Option<Vec<(String, Vec<String>)>>,
9347    synced_once: bool,
9348}
9349
9350#[cfg(test)]
9351mod subscription_rearm_tests {
9352    use super::*;
9353    use serde_json::json;
9354
9355    fn client() -> SyncClient {
9356        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9357            {"name":"id","type":"string","nullable":false},
9358            {"name":"project_id","type":"string","nullable":false}],
9359            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[],"ftsIndexes":[]}]});
9360        SyncClient::new("rearm".into(), &schema, ClientLimits::default()).expect("client")
9361    }
9362
9363    fn scopes() -> Vec<(String, Vec<String>)> {
9364        vec![("project_id".to_owned(), vec!["p1".to_owned()])]
9365    }
9366
9367    /// §3.3: the stored registration carries progress the re-arm must drop.
9368    fn store_revoked(client: &mut SyncClient) {
9369        client
9370            .subscribe("sub".into(), "tasks".into(), scopes(), None)
9371            .expect("subscribe");
9372        let sub = &mut client.subs[0];
9373        sub.cursor = 41;
9374        sub.bootstrap_state = Some("resume".to_owned());
9375        sub.effective = Some(scopes());
9376        sub.state = SubState::Revoked;
9377        sub.reason_code = Some("sync.scope_revoked".to_owned());
9378        client.persist_sub(&client.subs[0]).expect("persist");
9379        client.sync_needed = false;
9380        client.sync_intent_queue.clear();
9381    }
9382
9383    #[test]
9384    fn resubscribing_the_same_intent_rearms_a_revoked_registration() {
9385        let mut client = client();
9386        store_revoked(&mut client);
9387
9388        client
9389            .subscribe("sub".into(), "tasks".into(), scopes(), None)
9390            .expect("re-arm");
9391
9392        let view = client.subscription_state("sub").expect("state");
9393        assert_eq!(view.status, "active");
9394        assert_eq!(view.cursor, -1);
9395        assert!(!view.has_resume_token);
9396        assert_eq!(view.effective_scopes, None);
9397        assert_eq!(view.reason_code, None);
9398        assert!(client.sync_needed, "a re-arm asks for a pull");
9399        assert!(matches!(
9400            client.drain_sync_intents().as_slice(),
9401            [SyncIntent::Interactive]
9402        ));
9403        let stored: String = client
9404            .conn
9405            .query_row(
9406                "SELECT state_json FROM _syncular_subscriptions WHERE id = 'sub'",
9407                [],
9408                |row| row.get(0),
9409            )
9410            .expect("persisted row");
9411        let stored: Value = serde_json::from_str(&stored).expect("stored json");
9412        assert_eq!(stored["status"], json!("active"));
9413        assert_eq!(stored["cursor"], json!(-1));
9414        assert_eq!(stored["bootstrapState"], Value::Null);
9415        assert_eq!(stored["effectiveScopes"], Value::Null);
9416        assert_eq!(stored["reasonCode"], Value::Null);
9417    }
9418
9419    #[test]
9420    fn a_failed_registration_is_not_rearmed() {
9421        let mut client = client();
9422        client
9423            .subscribe("sub".into(), "tasks".into(), scopes(), None)
9424            .expect("subscribe");
9425        client.subs[0].state = SubState::Failed;
9426        client.subs[0].reason_code = Some("sync.scope_revoked".to_owned());
9427        client.persist_sub(&client.subs[0]).expect("persist");
9428        client.sync_needed = false;
9429        client.sync_intent_queue.clear();
9430
9431        client
9432            .subscribe("sub".into(), "tasks".into(), scopes(), None)
9433            .expect("subscribe");
9434
9435        let view = client.subscription_state("sub").expect("state");
9436        assert_eq!(view.status, "failed");
9437        assert_eq!(view.reason_code.as_deref(), Some("sync.scope_revoked"));
9438        assert!(!client.sync_needed);
9439        assert!(client.drain_sync_intents().is_empty());
9440    }
9441}
9442
9443#[derive(Debug, Clone)]
9444struct OutboxOp {
9445    upsert: bool,
9446    table: String,
9447    row_id: String,
9448    base_version: Option<i64>,
9449    /// Schema-agnostic local form (§0): driver JSON values, encoded with
9450    /// the current codec at send time. A key's presence IS the §6.1 sparse
9451    /// presence set — both the overlay and the wire payload derive from it.
9452    values: Option<Map<String, Value>>,
9453}
9454
9455impl From<&OutboxOp> for CommitOperation {
9456    fn from(operation: &OutboxOp) -> Self {
9457        Self {
9458            table: operation.table.clone(),
9459            row_id: operation.row_id.clone(),
9460            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
9461            base_version: operation.base_version,
9462            values: operation.values.clone(),
9463        }
9464    }
9465}
9466
9467#[derive(Debug, Clone)]
9468struct OutboxCommit {
9469    client_commit_id: String,
9470    ops: Vec<OutboxOp>,
9471}
9472
9473/// One commit's `(table, value keys)` per upsert operation — the classification
9474/// shape the §7.4.4 send-time drop and the RFC 0005 D6 audit share, so the two
9475/// can never disagree about which commits cannot re-encode.
9476fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
9477    commit
9478        .ops
9479        .iter()
9480        .filter(|operation| operation.upsert)
9481        .map(|operation| {
9482            (
9483                operation.table.as_str(),
9484                operation
9485                    .values
9486                    .as_ref()
9487                    .map(|values| values.keys().map(String::as_str).collect())
9488                    .unwrap_or_default(),
9489            )
9490        })
9491        .collect()
9492}
9493
9494type FailedCommit = (OutboxCommit, Map<String, Value>);
9495
9496#[derive(Debug, Clone)]
9497struct CompiledLocalDataPurgeTarget {
9498    table: String,
9499    selectors: Vec<(String, Vec<String>)>,
9500}
9501
9502struct StoredCommitOutcomeRow {
9503    sequence: i64,
9504    client_commit_id: String,
9505    status: String,
9506    recorded_at_ms: i64,
9507    results_json: String,
9508    operations_json: Option<String>,
9509    resolution: String,
9510    resolved_at_ms: Option<i64>,
9511    replacement_client_commit_id: Option<String>,
9512}
9513
9514impl StoredCommitOutcomeRow {
9515    fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
9516        Ok(Self {
9517            sequence: row.get(0)?,
9518            client_commit_id: row.get(1)?,
9519            status: row.get(2)?,
9520            recorded_at_ms: row.get(3)?,
9521            results_json: row.get(4)?,
9522            operations_json: row.get(5)?,
9523            resolution: row.get(6)?,
9524            resolved_at_ms: row.get(7)?,
9525            replacement_client_commit_id: row.get(8)?,
9526        })
9527    }
9528}
9529
9530/// Section outcome distinguishing the §5.6 subscription-local fail-closed
9531/// path from a round-aborting failure (§1.4 rule 5).
9532enum SectionError {
9533    FailClosed,
9534    Abort(String, String),
9535}
9536
9537#[derive(Debug)]
9538pub(crate) struct RequestMeta {
9539    pushed_ids: Vec<String>,
9540    /// Subscription id → the request carried `cursor < 0` and no resume
9541    /// token (§5.6 first-page detection: a *fresh* bootstrap).
9542    fresh: Vec<(String, bool)>,
9543    pub(crate) accept: u8,
9544    /// §6.1 splitBatch: outbox commits held back from THIS request because
9545    /// the running operation count reached the push cap — the next round
9546    /// pushes them (`sync_needed` stays set while any remain).
9547    deferred_commits: usize,
9548}
9549
9550#[derive(Default)]
9551struct ChangeAccumulator {
9552    /// None means table-wide; Some is the exact scoped domain.
9553    tables: BTreeMap<String, Option<BTreeSet<String>>>,
9554    windows: BTreeMap<(String, String), BTreeSet<String>>,
9555    status: bool,
9556    conflicts: bool,
9557    rejections: bool,
9558    outcomes: bool,
9559}
9560
9561impl ChangeAccumulator {
9562    fn table(&mut self, table: &str) {
9563        self.tables.insert(table.to_owned(), None);
9564    }
9565
9566    fn scope(&mut self, table: &str, key: String) {
9567        match self.tables.get_mut(table) {
9568            Some(None) => {}
9569            Some(Some(keys)) => {
9570                keys.insert(key);
9571            }
9572            None => {
9573                self.tables
9574                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
9575            }
9576        }
9577    }
9578
9579    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
9580        self.windows
9581            .entry((base_key.to_owned(), table.to_owned()))
9582            .or_default()
9583            .insert(unit.to_owned());
9584    }
9585
9586    fn touched(&self) -> bool {
9587        !self.tables.is_empty()
9588            || !self.windows.is_empty()
9589            || self.status
9590            || self.conflicts
9591            || self.rejections
9592            || self.outcomes
9593    }
9594}
9595
9596/// §6.1: the server caps total operations per request (reference default
9597/// 500) and rejects the whole batch with `sync.too_many_operations`; the
9598/// client "splits and retries". Splitting happens at build time: commits are
9599/// included IN ORDER until the operation budget is spent, the rest wait for
9600/// the next round.
9601const PUSH_OPS_PER_REQUEST: usize = 500;
9602const MAX_LOCAL_PURGE_TARGETS: usize = 64;
9603const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
9604const MAX_LOCAL_PURGE_VALUES: usize = 128;
9605const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
9606pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
9607
9608/// Dirty visible tables; `None` means a whole-replica recovery/reset.
9609struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
9610
9611impl OverlayDirty {
9612    fn clean() -> Self {
9613        Self(RefCell::new(Some(BTreeSet::new())))
9614    }
9615    fn get(&self) -> bool {
9616        self.0
9617            .borrow()
9618            .as_ref()
9619            .is_none_or(|tables| !tables.is_empty())
9620    }
9621    fn set(&self, dirty: bool) {
9622        *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
9623    }
9624    fn table(&self, table: &str) {
9625        if let Some(tables) = self.0.borrow_mut().as_mut() {
9626            tables.insert(table.to_owned());
9627        }
9628    }
9629    fn snapshot(&self) -> Option<BTreeSet<String>> {
9630        self.0.borrow().clone()
9631    }
9632    fn restore(&self, tables: Option<BTreeSet<String>>) {
9633        *self.0.borrow_mut() = tables;
9634    }
9635}
9636
9637pub struct SyncClient {
9638    #[cfg(feature = "bench-internals")]
9639    benchmark_phases: Recorder,
9640    conn: Connection,
9641    schema: ClientSchema,
9642    client_id: String,
9643    limits: ClientLimits,
9644    subs: Vec<Subscription>,
9645    outbox: Vec<OutboxCommit>,
9646    failed_commits: Vec<FailedCommit>,
9647    retain_failed_commits: bool,
9648    conflicts: Vec<ConflictRecord>,
9649    rejections: Vec<RejectionRecord>,
9650    schema_floor: Option<SchemaFloor>,
9651    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
9652    lease_state: Option<LeaseState>,
9653    /// §1.6: the schema-floor response stops syncing until an upgrade.
9654    stopped: bool,
9655    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
9656    upgrading: bool,
9657    /// §8.4 coalesced sync-needed signal.
9658    sync_needed: bool,
9659    /// §8.8: how sync rounds treat the realtime binding, and its exact
9660    /// availability state with the evidence behind a lost or refused one.
9661    realtime_policy: RealtimePolicy,
9662    realtime_state: RealtimeState,
9663    realtime_reason_code: Option<String>,
9664    realtime_retry_delay_ms: Option<u64>,
9665    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
9666    presence: HashMap<String, HashMap<String, PresencePeer>>,
9667    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
9668    /// host may pin it (conformance runs on a virtual clock).
9669    now_ms: Option<i64>,
9670    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
9671    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
9672    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
9673    encryption: crate::values::EncryptionConfig,
9674    /// Fail-closed host bootstrap gate. While set, command hosts permit only
9675    /// status/lifecycle inspection and an exact authorized local purge.
9676    security_preflight: bool,
9677    transport_enabled: bool,
9678    /// RFC 0005 D2/D8: the previous-version capture config. `None` (default)
9679    /// means the feature is off; the descriptor write and the container orphan
9680    /// sweep still happen.
9681    previous_version: Option<PreviousVersionContextConfig>,
9682    /// Per-table primary-key upsert SQL, built once per (full table name) —
9683    /// the row write path runs per row during bootstrap (§5.6), so the SQL
9684    /// string (and, via `prepare_cached`, its compiled statement) is reused
9685    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
9686    /// schema reset (the column lists may have changed).
9687    insert_sql: RefCell<HashMap<String, String>>,
9688    /// §7.1: reconcile only tables whose base/outbox changed. Whole-replica
9689    /// reset and restart explicitly invalidate every table.
9690    overlay_dirty: OverlayDirty,
9691    // First SQLite failure of the current operation, before savepoint cleanup.
9692    storage_failure: RefCell<Option<QueryReadFailure>>,
9693    /// Test-only structural performance signal: response processing must not
9694    /// turn a batch of acknowledgements into one full overlay rebuild each.
9695    #[cfg(test)]
9696    overlay_rebuild_count: Cell<usize>,
9697    #[cfg(test)]
9698    acknowledged_replay_count: Cell<usize>,
9699    /// Test-only structural performance signal: outcome retention is enforced
9700    /// once per response rather than once per acknowledgement.
9701    #[cfg(test)]
9702    outcome_prune_count: Cell<usize>,
9703    /// Exact observer-transaction output drained by command/FFI hosts.
9704    progress: ProgressObserver,
9705    change_queue: VecDeque<ClientChangeBatch>,
9706    sync_intent_queue: VecDeque<SyncIntent>,
9707    /// Explicit exponential retry policy for transient transport failures.
9708    retry_delay_ms: u64,
9709    /// §7.6: the background retry the current round scheduled, if any.
9710    round_retry_delay_ms: Option<u64>,
9711    active_round: Option<uuid::Uuid>,
9712    last_round: Option<DiagnosticLastRound>,
9713    last_change: Option<DiagnosticLastChange>,
9714    /// §7.6: latest failed owned snapshot read per owner id, oldest first.
9715    query_failures: Vec<DiagnosticQueryFailure>,
9716    /// SPEC §1.4 rule 4: the open coalesced apply transaction of a response.
9717    coalesced: Option<CoalescedApply>,
9718    /// A coalesced apply transaction of the current response failed; every
9719    /// later observation of that response fails until it ends.
9720    coalesced_failure: Option<String>,
9721}
9722
9723/// SPEC §1.4 rule 4: the frames and blocks of one response share one local
9724/// write transaction of at most `COALESCED_APPLY_ROWS` rows. The in-memory
9725/// state captured at its `BEGIN` is restored when its `COMMIT` fails.
9726struct CoalescedApply {
9727    rows: usize,
9728    subs: Vec<Subscription>,
9729    outbox: Vec<OutboxCommit>,
9730    failed_commits: Vec<FailedCommit>,
9731    conflicts: Vec<ConflictRecord>,
9732    rejections: Vec<RejectionRecord>,
9733    schema_floor: Option<SchemaFloor>,
9734    lease_state: Option<LeaseState>,
9735    stopped: bool,
9736    upgrading: bool,
9737    sync_needed: bool,
9738    overlay_dirty: Option<BTreeSet<String>>,
9739    change_queue: usize,
9740    sync_intent_queue: usize,
9741    last_change: Option<DiagnosticLastChange>,
9742}
9743
9744const COALESCED_APPLY_ROWS: usize = 1024;
9745
9746pub(crate) fn quote_ident(name: &str) -> String {
9747    format!("\"{}\"", name.replace('"', "\"\""))
9748}
9749/// §7.4.1 `_syncular_meta` helpers. Free functions so RFC 0005's
9750/// previous-version module reads and writes the SAME records as the client
9751/// instead of growing a parallel meta layer.
9752pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
9753    conn.query_row(
9754        "SELECT value FROM _syncular_meta WHERE key = ?1",
9755        rusqlite::params![key],
9756        |row| row.get::<_, String>(0),
9757    )
9758    .ok()
9759}
9760
9761pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
9762    let _ = conn.execute(
9763        "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
9764        rusqlite::params![key, value],
9765    );
9766}
9767
9768pub(crate) fn meta_delete(conn: &Connection, key: &str) {
9769    let _ = conn.execute(
9770        "DELETE FROM _syncular_meta WHERE key = ?1",
9771        rusqlite::params![key],
9772    );
9773}
9774
9775/// Seek by the existing key while retaining the exact legacy text match.
9776fn row_id_predicate(table: &TableSchema) -> String {
9777    let key = quote_ident(&table.primary_key);
9778    let text_match = format!("CAST({key} AS TEXT) = ?1");
9779    match table.columns[table.pk_index].ty {
9780        ColumnType::String | ColumnType::Json => {
9781            format!("{key} = ?1 AND {text_match}")
9782        }
9783        // Native columns have no declared SQLite affinity. Match the stored
9784        // integer bind type explicitly. Unary + removes expression affinity so
9785        // SQLite can seek the typeless key; the text check preserves exact IDs.
9786        ColumnType::Integer | ColumnType::Boolean => {
9787            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
9788        }
9789        // REAL-to-text conversion can round distinct values to the same text.
9790        // Narrowing those matches by numeric equality would change behavior.
9791        _ => text_match,
9792    }
9793}
9794
9795fn is_local_operation_code_like(value: &str) -> bool {
9796    let bytes = value.as_bytes();
9797    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
9798        && bytes
9799            .iter()
9800            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
9801}
9802
9803fn base_table(name: &str) -> String {
9804    quote_ident(&format!("_syncular_base_{name}"))
9805}
9806
9807/// §4.8: a stable, server-opaque key for a window base — table + variable +
9808/// canonical fixed scopes. Two `set_window` calls with the same base
9809/// address the same registry rows.
9810/// §4.8 deferred eviction record: (sub id, table, effective scope map).
9811type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
9812
9813fn window_base_key(base: &WindowBase) -> String {
9814    format!(
9815        "{}\0{}\0{}",
9816        base.table,
9817        base.variable,
9818        canonical_scope_json(&base.fixed_scopes)
9819    )
9820}
9821
9822/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
9823fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
9824    let mut scopes = base.fixed_scopes.clone();
9825    scopes.retain(|(k, _)| k != &base.variable);
9826    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
9827    scopes
9828}
9829
9830/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
9831/// echoed not interpreted by the server, so the exact hash is client
9832/// convention; SHA-256 matches the SPEC's worked example.
9833fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
9834    let canonical = canonical_scope_json(&unit_scopes(base, unit));
9835    let digest = Sha256::digest(canonical.as_bytes());
9836    let hex = bytes_to_hex(&digest);
9837    format!("w:{}:{}", base.table, &hex[..16])
9838}
9839
9840fn visible_table(name: &str) -> String {
9841    quote_ident(name)
9842}
9843
9844const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
9845
9846/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
9847/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
9848fn is_synced_table_name(name: &str) -> bool {
9849    if name.starts_with("sqlite_") {
9850        return false;
9851    }
9852    if name.starts_with("_syncular_base_") {
9853        return true; // the base half of a synced table pair
9854    }
9855    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
9856    !name.starts_with("_syncular_")
9857}
9858
9859/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
9860fn blob_id_for(bytes: &[u8]) -> String {
9861    let digest = Sha256::digest(bytes);
9862    format!("sha256:{}", bytes_to_hex(&digest))
9863}
9864
9865/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
9866/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
9867/// per row on the §5.6 bootstrap path), scalars bind owned.
9868enum RowParam<'a> {
9869    Cell(&'a Option<ColumnValue>),
9870    Version(i64),
9871}
9872
9873impl rusqlite::ToSql for RowParam<'_> {
9874    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
9875        Ok(match self {
9876            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
9877            RowParam::Cell(cell) => match cell {
9878                None => ToSqlOutput::Owned(SqlValue::Null),
9879                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
9880                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
9881                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
9882                Some(ColumnValue::Boolean(b)) => {
9883                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
9884                }
9885                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
9886                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
9887                }
9888                // §5.10: crdt bytes store as BLOB, like bytes.
9889                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
9890                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
9891                }
9892            },
9893        })
9894    }
9895}
9896
9897// Retain a typed primary key across the current import chunk without copying row payloads.
9898fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
9899    match param.to_sql().map_err(|error| error.to_string())? {
9900        ToSqlOutput::Owned(value) => Ok(value),
9901        ToSqlOutput::Borrowed(value) => {
9902            SqlValue::try_from(value).map_err(|error| error.to_string())
9903        }
9904        _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
9905    }
9906}
9907
9908/// §5.3 image cell → bind parameter, strict per the declared column type
9909/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
9910/// nullable). Mismatches are image-producer violations. Returns the cell
9911/// borrowed when the stored representation already matches what the row
9912/// codec would write, or the normalized scalar (boolean → 0/1, float from
9913/// INTEGER → REAL) otherwise — the local write is byte-identical to the
9914/// old convert-then-insert path without allocating per cell.
9915fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
9916    use ssp2::segment::ColumnType;
9917    let mismatch = || {
9918        Err(format!(
9919            "image column {:?} holds a value of the wrong type",
9920            column.name
9921        ))
9922    };
9923    match value {
9924        ValueRef::Null => {
9925            if !column.nullable {
9926                return Err(format!(
9927                    "image column {:?} is NULL but not nullable",
9928                    column.name
9929                ));
9930            }
9931            Ok(ToSqlOutput::Owned(SqlValue::Null))
9932        }
9933        ValueRef::Integer(i) => match column.ty {
9934            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
9935            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
9936            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
9937            _ => mismatch(),
9938        },
9939        ValueRef::Real(_) => match column.ty {
9940            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
9941            _ => mismatch(),
9942        },
9943        ValueRef::Text(t) => {
9944            std::str::from_utf8(t)
9945                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
9946            match column.ty {
9947                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
9948                    Ok(ToSqlOutput::Borrowed(value))
9949                }
9950                _ => mismatch(),
9951            }
9952        }
9953        ValueRef::Blob(_) => match column.ty {
9954            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
9955            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
9956            _ => mismatch(),
9957        },
9958    }
9959}
9960
9961pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
9962    use rusqlite::types::ValueRef;
9963    match value {
9964        ValueRef::Null => Value::Null,
9965        ValueRef::Integer(i) => match column.ty {
9966            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
9967            ssp2::segment::ColumnType::Float => {
9968                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
9969            }
9970            _ => Value::from(i),
9971        },
9972        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
9973        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
9974        ValueRef::Blob(b) => {
9975            let mut map = Map::new();
9976            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
9977            Value::Object(map)
9978        }
9979    }
9980}
9981
9982/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
9983/// Objects are accepted in lossless `{"$bytes": hex}` and
9984/// `{"$bigint": decimal}` envelope forms.
9985fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
9986    Ok(match value {
9987        Value::Null => SqlValue::Null,
9988        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
9989        Value::Number(n) => {
9990            if let Some(i) = n.as_i64() {
9991                SqlValue::Integer(i)
9992            } else if let Some(f) = n.as_f64() {
9993                SqlValue::Real(f)
9994            } else {
9995                return Err(format!("query param number {n} is out of range"));
9996            }
9997        }
9998        Value::String(s) => SqlValue::Text(s.clone()),
9999        Value::Object(_) => {
10000            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
10001                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
10002            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
10003                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
10004                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
10005                })?)
10006            } else {
10007                return Err(
10008                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
10009                        .to_owned(),
10010                );
10011            }
10012        }
10013        Value::Array(_) => return Err("query array params are not supported".to_owned()),
10014    })
10015}
10016
10017/// Map a rusqlite value with no schema column to consult (arbitrary query
10018/// output): integers/reals/text pass through by stored affinity, blobs ride
10019/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
10020/// schema column type to recover booleans/floats/json.
10021fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
10022    use rusqlite::types::ValueRef;
10023    match value {
10024        ValueRef::Null => Value::Null,
10025        ValueRef::Integer(i) => {
10026            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
10027            // ordinary UI-sized integers ergonomic and envelope only values
10028            // beyond JavaScript's safe range.
10029            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
10030            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
10031                Value::from(i)
10032            } else {
10033                let mut map = Map::new();
10034                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
10035                Value::Object(map)
10036            }
10037        }
10038        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
10039        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
10040        ValueRef::Blob(b) => {
10041            let mut map = Map::new();
10042            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
10043            Value::Object(map)
10044        }
10045    }
10046}
10047
10048/// §7.5: SQLite failures classify by result code into [`QueryReadFailure`].
10049fn query_connection(
10050    conn: &Connection,
10051    sql: &str,
10052    params: &[QueryValue],
10053) -> Result<Vec<QueryRow>, QueryReadFailure> {
10054    crate::query_guard::assert_read_only_query(sql)?;
10055    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
10056    let bound: Vec<SqlValue> = params
10057        .iter()
10058        .map(json_param_to_sql)
10059        .collect::<Result<_, _>>()?;
10060    let mut stmt = conn.prepare(&lowered_sql)?;
10061    let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
10062    let bound_refs: Vec<&dyn rusqlite::ToSql> =
10063        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
10064    let mut sql_rows = stmt.query(bound_refs.as_slice())?;
10065    let mut out = Vec::new();
10066    while let Some(row) = sql_rows.next()? {
10067        let mut record = Map::new();
10068        for (i, name) in column_names.iter().enumerate() {
10069            let value = row.get_ref(i)?;
10070            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
10071        }
10072        out.push(record);
10073    }
10074    Ok(out)
10075}
10076
10077fn persisted_window_state(
10078    conn: &Connection,
10079    base: &WindowBase,
10080) -> Result<WindowState, QueryReadFailure> {
10081    let mut stmt = conn.prepare(
10082        "SELECT windows.unit, subscriptions.state_json
10083           FROM _syncular_windows AS windows
10084           JOIN _syncular_subscriptions AS subscriptions
10085             ON subscriptions.id = windows.sub_id
10086          WHERE windows.base = ?1
10087          ORDER BY windows.unit ASC",
10088    )?;
10089    let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
10090        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
10091    })?;
10092    let mut units = Vec::new();
10093    let mut pending = Vec::new();
10094    for row in rows {
10095        let (unit, raw) = row?;
10096        let state: Value = serde_json::from_str(&raw)
10097            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
10098        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
10099            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
10100            || state
10101                .get("bootstrapState")
10102                .is_some_and(|value| !value.is_null());
10103        if is_pending {
10104            pending.push(unit.clone());
10105        }
10106        units.push(unit);
10107    }
10108    Ok(WindowState { units, pending })
10109}
10110
10111/// One read-only statement in a §7.5 snapshot read.
10112#[derive(Debug, Clone, Copy)]
10113pub struct SnapshotStatement<'a> {
10114    pub sql: &'a str,
10115    pub params: &'a [Value],
10116}
10117
10118/// A §7.5 atomic read request: read-only statements, window coverage,
10119/// subscription catch-up states, and commit deliveries, all resolved from one
10120/// SQLite read transaction and one revision.
10121#[derive(Debug, Clone, Default)]
10122pub struct SnapshotReadRequest<'a> {
10123    pub statements: Vec<SnapshotStatement<'a>>,
10124    pub coverage: &'a [WindowCoverage],
10125    pub subscriptions: Vec<String>,
10126    pub commit_ids: Vec<String>,
10127}
10128
10129#[cfg(test)]
10130thread_local! {
10131    /// Test-only rendezvous that runs between the statements of one snapshot
10132    /// read. A test uses it to commit on another connection mid-read and prove
10133    /// the read transaction's snapshot and revision stay stable.
10134    static SNAPSHOT_READ_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
10135        std::cell::RefCell::new(None);
10136}
10137
10138#[cfg(test)]
10139fn run_snapshot_read_hook() {
10140    SNAPSHOT_READ_HOOK.with(|slot| {
10141        if let Some(hook) = slot.borrow_mut().take() {
10142            hook();
10143        }
10144    });
10145}
10146
10147/// §7.5: resolve a request over one SQLite read transaction. The revision,
10148/// every statement's rows, coverage, catch-up states, and deliveries are
10149/// captured under one `SAVEPOINT`; any statement's failure rolls the whole
10150/// read back and releases the connection.
10151fn snapshot_read_connection(
10152    conn: &Connection,
10153    request: &SnapshotReadRequest<'_>,
10154) -> Result<SnapshotRead, QueryReadFailure> {
10155    conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
10156    let corrupt = |message: &str| QueryReadFailure {
10157        code: Some("sync.local_corrupt"),
10158        sqlite_code: None,
10159        sqlite_message: None,
10160        rollback_failure: None,
10161        message: message.to_owned(),
10162    };
10163    let stored_failure = |error: rusqlite::Error, message: &str| match error {
10164        rusqlite::Error::Utf8Error(..)
10165        | rusqlite::Error::InvalidColumnType(..)
10166        | rusqlite::Error::FromSqlConversionFailure(..)
10167        | rusqlite::Error::IntegralValueOutOfRange(..) => corrupt(message),
10168        _ => QueryReadFailure::from(error),
10169    };
10170    let result: Result<SnapshotRead, QueryReadFailure> = (|| {
10171        // The durable revision as a canonical decimal `u64`; a missing marker
10172        // is the legacy zero, and any other non-canonical value is corrupt.
10173        let revision: Option<String> = conn
10174            .query_row(
10175                "SELECT value FROM _syncular_meta WHERE key = ?1",
10176                rusqlite::params![LOCAL_REVISION_KEY],
10177                |row| row.get(0),
10178            )
10179            .optional()
10180            .map_err(|error| stored_failure(error, "persisted local revision is invalid"))?;
10181        let revision = match revision {
10182            None => "0".to_owned(),
10183            Some(value)
10184                if value == "0"
10185                    || (matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
10186                        && value.bytes().all(|b| b.is_ascii_digit())
10187                        && value.parse::<u64>().is_ok()) =>
10188            {
10189                value
10190            }
10191            Some(_) => return Err(corrupt("persisted local revision is invalid")),
10192        };
10193        let mut queries = Vec::with_capacity(request.statements.len());
10194        for statement in &request.statements {
10195            queries.push(query_connection(conn, statement.sql, statement.params)?);
10196            #[cfg(test)]
10197            run_snapshot_read_hook();
10198        }
10199        let mut pending = Vec::new();
10200        let mut missing = Vec::new();
10201        for requested in request.coverage {
10202            let base_key = window_base_key(&requested.base);
10203            let state = persisted_window_state(conn, &requested.base)?;
10204            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
10205                let reference = WindowUnitRef {
10206                    base_key: base_key.clone(),
10207                    unit: unit.clone(),
10208                };
10209                if !state.units.iter().any(|held| held == &unit) {
10210                    missing.push(reference);
10211                } else if state.pending.iter().any(|held| held == &unit) {
10212                    pending.push(reference);
10213                }
10214            }
10215        }
10216        let mut subscriptions = Vec::with_capacity(request.subscriptions.len());
10217        for id in &request.subscriptions {
10218            let row: Option<(String, String)> = conn
10219                .query_row(
10220                    "SELECT tbl, state_json FROM _syncular_subscriptions WHERE id = ?1",
10221                    rusqlite::params![id],
10222                    |row| Ok((row.get(0)?, row.get(1)?)),
10223                )
10224                .optional()
10225                .map_err(|error| {
10226                    stored_failure(error, "persisted subscription state is invalid")
10227                })?;
10228            let Some((table, raw)) = row else {
10229                subscriptions.push(SubscriptionCatchup::Unknown { id: id.clone() });
10230                continue;
10231            };
10232            // Every persisted field is required to be well typed; a corrupt
10233            // record fails typed instead of inventing a state.
10234            let state: Value = serde_json::from_str(&raw)
10235                .map_err(|_| corrupt("persisted subscription state is invalid"))?;
10236            let status = match state.get("status").and_then(Value::as_str) {
10237                Some(value @ ("active" | "revoked" | "failed")) => value,
10238                _ => return Err(corrupt("persisted subscription state is invalid")),
10239            };
10240            let Some(cursor) = state.get("cursor").and_then(Value::as_i64) else {
10241                return Err(corrupt("persisted subscription state is invalid"));
10242            };
10243            if cursor.unsigned_abs() > MAX_JS_SAFE_INTEGER {
10244                return Err(corrupt("persisted subscription state is invalid"));
10245            }
10246            let effective_scopes = state
10247                .get("effectiveScopes")
10248                .filter(|value| !value.is_null());
10249            if let Some(scopes) = effective_scopes {
10250                json_to_scope_map(scopes)
10251                    .map_err(|_| corrupt("persisted subscription state is invalid"))?;
10252            }
10253            let has_resume_token = match state.get("bootstrapState") {
10254                None | Some(Value::Null) => false,
10255                Some(Value::String(_)) => true,
10256                Some(_) => return Err(corrupt("persisted subscription state is invalid")),
10257            };
10258            // A present reasonCode must be a string; a non-string value is a
10259            // corrupt record, never silently dropped.
10260            let reason_code = match state.get("reasonCode") {
10261                None | Some(Value::Null) => None,
10262                Some(Value::String(value)) => Some(value.clone()),
10263                Some(_) => return Err(corrupt("persisted subscription state is invalid")),
10264            };
10265            let active = status == "active";
10266            subscriptions.push(SubscriptionCatchup::Known {
10267                id: id.clone(),
10268                table,
10269                status: status.to_owned(),
10270                cursor,
10271                has_resume_token,
10272                bootstrap_complete: active && cursor >= 0 && !has_resume_token,
10273                known_pending_pages: active && (has_resume_token || cursor < 0),
10274                effective_scopes: effective_scopes.cloned(),
10275                reason_code,
10276            });
10277        }
10278        let mut deliveries = Vec::with_capacity(request.commit_ids.len());
10279        for id in &request.commit_ids {
10280            let pending: bool = conn.query_row(
10281                "SELECT EXISTS(SELECT 1 FROM _syncular_outbox WHERE commit_id = ?1)",
10282                rusqlite::params![id],
10283                |row| row.get(0),
10284            )?;
10285            if pending {
10286                deliveries.push(CommitDelivery::Pending {
10287                    client_commit_id: id.clone(),
10288                });
10289                continue;
10290            }
10291            let row = conn
10292                .query_row(
10293                    "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
10294                            resolution, resolved_at_ms, replacement_client_commit_id
10295                       FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
10296                    rusqlite::params![id],
10297                    StoredCommitOutcomeRow::from_row,
10298                )
10299                .optional()
10300                .map_err(|error| stored_failure(error, "persisted commit outcome is invalid"))?;
10301            deliveries.push(match row {
10302                Some(row) => CommitDelivery::Known {
10303                    client_commit_id: id.clone(),
10304                    outcome: SyncClient::outcome_from_row(row)
10305                        .map_err(|_| corrupt("persisted commit outcome is invalid"))?,
10306                },
10307                None => CommitDelivery::Unknown {
10308                    client_commit_id: id.clone(),
10309                },
10310            });
10311        }
10312        Ok(SnapshotRead {
10313            revision,
10314            queries,
10315            coverage: CoverageSnapshot {
10316                complete: pending.is_empty() && missing.is_empty(),
10317                pending,
10318                missing,
10319            },
10320            subscriptions,
10321            deliveries,
10322        })
10323    })();
10324    match result {
10325        Ok(read) => {
10326            conn.execute_batch("RELEASE syncular_snapshot_read")?;
10327            Ok(read)
10328        }
10329        Err(mut error) => {
10330            if let Err(rollback) = conn
10331                .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
10332            {
10333                error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
10334            }
10335            Err(error)
10336        }
10337    }
10338}
10339
10340fn snapshot_connection(
10341    conn: &Connection,
10342    sql: &str,
10343    params: &[Value],
10344    coverage: &[WindowCoverage],
10345) -> Result<QuerySnapshot, QueryReadFailure> {
10346    let read = snapshot_read_connection(
10347        conn,
10348        &SnapshotReadRequest {
10349            statements: vec![SnapshotStatement { sql, params }],
10350            coverage,
10351            subscriptions: Vec::new(),
10352            commit_ids: Vec::new(),
10353        },
10354    )?;
10355    Ok(QuerySnapshot {
10356        revision: read.revision,
10357        rows: read
10358            .queries
10359            .into_iter()
10360            .next()
10361            .expect("single-query snapshot returns one result"),
10362        coverage: read.coverage,
10363    })
10364}
10365
10366/// A long-lived read-only SQLite sidecar for latency-critical native views.
10367/// Network rounds stay serialized on the mutable core owner, while atomic
10368/// query snapshots use this independent connection and therefore never queue
10369/// behind HTTP/WebSocket latency.
10370pub struct FileQuerySnapshotReader {
10371    path: String,
10372    conn: Option<Connection>,
10373}
10374
10375impl FileQuerySnapshotReader {
10376    #[must_use]
10377    pub fn new(path: impl Into<String>) -> Self {
10378        Self {
10379            path: path.into(),
10380            conn: None,
10381        }
10382    }
10383
10384    fn connection(&mut self) -> Result<&Connection, QueryReadFailure> {
10385        if self.conn.is_none() {
10386            let conn = Connection::open_with_flags(
10387                &self.path,
10388                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
10389            )
10390            .map_err(QueryReadFailure::from)?;
10391            conn.busy_timeout(std::time::Duration::from_millis(250))
10392                .map_err(QueryReadFailure::from)?;
10393            self.conn = Some(conn);
10394        }
10395        self.conn
10396            .as_ref()
10397            .ok_or_else(|| QueryReadFailure::from("read sidecar connection missing".to_owned()))
10398    }
10399
10400    /// One atomic read over the shared primitive: every statement, coverage,
10401    /// catch-up, and delivery under one `SAVEPOINT` and one revision.
10402    pub fn snapshot_read(
10403        &mut self,
10404        request: &SnapshotReadRequest<'_>,
10405    ) -> Result<SnapshotRead, QueryReadFailure> {
10406        snapshot_read_connection(self.connection()?, request)
10407    }
10408
10409    /// The current durable local revision. A host can skip a read when it is
10410    /// unchanged; a corrupt or unreadable marker fails typed.
10411    pub fn local_revision(&mut self) -> Result<String, QueryReadFailure> {
10412        Ok(self
10413            .snapshot_read(&SnapshotReadRequest::default())?
10414            .revision)
10415    }
10416
10417    /// One subscription's persisted catch-up state (see the §7.5 contract).
10418    pub fn subscription_catchup(
10419        &mut self,
10420        id: &str,
10421    ) -> Result<SubscriptionCatchup, QueryReadFailure> {
10422        let read = self.snapshot_read(&SnapshotReadRequest {
10423            subscriptions: vec![id.to_owned()],
10424            ..Default::default()
10425        })?;
10426        Ok(read
10427            .subscriptions
10428            .into_iter()
10429            .next()
10430            .expect("one requested catch-up"))
10431    }
10432
10433    /// One client commit's delivery status: pending, its retained outcome, or
10434    /// an explicit unknown (never held, or pruned by retention).
10435    pub fn commit_delivery(&mut self, id: &str) -> Result<CommitDelivery, QueryReadFailure> {
10436        let read = self.snapshot_read(&SnapshotReadRequest {
10437            commit_ids: vec![id.to_owned()],
10438            ..Default::default()
10439        })?;
10440        Ok(read
10441            .deliveries
10442            .into_iter()
10443            .next()
10444            .expect("one requested delivery"))
10445    }
10446
10447    /// Hosts report an owned failure, and the first success after it, to the
10448    /// owning core through [`SyncClient::record_query_read`] (§7.6).
10449    pub fn query_snapshot(
10450        &mut self,
10451        sql: &str,
10452        params: &[Value],
10453        coverage: &[WindowCoverage],
10454    ) -> Result<QuerySnapshot, QueryReadFailure> {
10455        snapshot_connection(self.connection()?, sql, params, coverage)
10456    }
10457}
10458
10459fn validate_authority_reads(
10460    schema: &ClientSchema,
10461    declarations: &[crate::api::AuthorityReadDeclaration],
10462) -> Result<(), String> {
10463    let mut seen = BTreeSet::new();
10464    for read in declarations {
10465        let table = schema
10466            .table(&read.table)
10467            .ok_or("client.authority_read_forbidden: authority table is not declared")?;
10468        let plain = |name: &str| {
10469            table.columns.iter().enumerate().any(|(i, c)| {
10470                c.name == name
10471                    && name != "authority_version"
10472                    && !name.starts_with("_sync")
10473                    && !table.encrypted_columns.iter().any(|e| e.index == i)
10474                    && matches!(
10475                        c.ty,
10476                        ssp2::segment::ColumnType::String
10477                            | ssp2::segment::ColumnType::Integer
10478                            | ssp2::segment::ColumnType::Float
10479                            | ssp2::segment::ColumnType::Boolean
10480                            | ssp2::segment::ColumnType::Json
10481                    )
10482            })
10483        };
10484        if !seen.insert(&read.table)
10485            || read.columns.is_empty()
10486            || read.columns.iter().collect::<BTreeSet<_>>().len() != read.columns.len()
10487            || !read.columns.contains(&table.primary_key)
10488            || !read.columns.iter().all(|c| plain(c))
10489            || read.scopes.is_empty()
10490            || !read.scopes.iter().all(|(v, values)| {
10491                table
10492                    .scope_column(v)
10493                    .is_some_and(|c| read.columns.iter().any(|column| column == c) && plain(c))
10494                    && !values.is_empty()
10495                    && values.iter().all(|v| !v.is_empty() && v != "*")
10496            })
10497        {
10498            return Err("client.authority_read_forbidden: authority reads require plain columns, a primary key and nonempty plain scope selectors".into());
10499        }
10500    }
10501    Ok(())
10502}
10503
10504// §7.4.2: every open and recreation checks before any persistent writes.
10505/// A marker read that hit a recognized SQLite storage failure keeps that
10506/// classification (BUSY/LOCKED stay retryable); only an unclassified read
10507/// failure is the invalid-marker `sync.local_corrupt`.
10508fn schema_marker_read_failure(error: rusqlite::Error) -> String {
10509    let failure = QueryReadFailure::from(error);
10510    if failure.code.is_some() {
10511        failure.to_string()
10512    } else {
10513        "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned()
10514    }
10515}
10516
10517/// §7.1: reject an invalid configured client limit before any storage is
10518/// opened or written. Zero would wedge the push queue and an out-of-range
10519/// value would differ per target; the TS core enforces the same bounds.
10520fn validate_client_limits(limits: &crate::api::ClientLimits) -> Result<(), String> {
10521    if limits.outcome_retention_max_entries == Some(0) {
10522        return Err("sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned());
10523    }
10524    if [
10525        limits.max_push_commits_per_request,
10526        limits.max_push_operations_per_request,
10527        limits.max_push_request_bytes,
10528    ]
10529    .into_iter()
10530    .flatten()
10531    .any(|value| value == 0 || value > u32::MAX as usize)
10532    {
10533        return Err(
10534            "sync.invalid_request: push limit must be an integer in 1..=4294967295".to_owned(),
10535        );
10536    }
10537    Ok(())
10538}
10539
10540fn read_local_schema_version(conn: &Connection, requested: i32) -> Result<Option<i32>, String> {
10541    let corrupt =
10542        || "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned();
10543    let table_type: Option<String> = conn
10544        .query_row(
10545            "SELECT type FROM sqlite_master WHERE name = '_syncular_meta'",
10546            [],
10547            |row| row.get(0),
10548        )
10549        .optional()
10550        .map_err(schema_marker_read_failure)?;
10551    let Some(table_type) = table_type else {
10552        return Ok(None);
10553    };
10554    if table_type != "table" {
10555        return Err(corrupt());
10556    }
10557    // §7.4.2: a duplicated marker cannot be resolved. `query_row` would take
10558    // whichever row SQLite returns first, and an older one would drive a
10559    // destructive reset over a newer replica.
10560    let mut statement = conn
10561        .prepare("SELECT value FROM _syncular_meta WHERE key = ?1")
10562        .map_err(schema_marker_read_failure)?;
10563    let mut rows = statement
10564        .query([LOCAL_SCHEMA_VERSION_KEY])
10565        .map_err(schema_marker_read_failure)?;
10566    let value: Option<String> = rows
10567        .next()
10568        .map_err(schema_marker_read_failure)?
10569        .map(|row| row.get(0))
10570        .transpose()
10571        .map_err(schema_marker_read_failure)?;
10572    if rows.next().map_err(schema_marker_read_failure)?.is_some() {
10573        return Err(corrupt());
10574    }
10575    let Some(value) = value else {
10576        let has_descriptor = conn
10577            .query_row(
10578                "SELECT 1 FROM _syncular_meta WHERE key = ?1 LIMIT 1",
10579                [crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY],
10580                |_| Ok(()),
10581            )
10582            .optional()
10583            .map_err(schema_marker_read_failure)?
10584            .is_some();
10585        if has_descriptor {
10586            return Err(corrupt());
10587        }
10588        return Ok(None);
10589    };
10590    if !matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
10591        || !value.bytes().all(|b| b.is_ascii_digit())
10592    {
10593        return Err(corrupt());
10594    }
10595    let version = value.parse::<i32>().map_err(|_| corrupt())?;
10596    if version > requested {
10597        return Err(
10598            "client.schema_downgrade: persisted local schema is newer than the requested schema"
10599                .to_owned(),
10600        );
10601    }
10602    Ok(Some(version))
10603}
10604
10605impl SyncClient {
10606    pub fn new_with_identity(
10607        client_id: Option<String>,
10608        schema_json: &Value,
10609        limits: ClientLimits,
10610    ) -> Result<Self, String> {
10611        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
10612        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
10613        Self::with_connection(resolved, schema_json, limits, conn)
10614    }
10615
10616    pub fn new(
10617        client_id: String,
10618        schema_json: &Value,
10619        limits: ClientLimits,
10620    ) -> Result<Self, String> {
10621        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
10622        Self::with_connection(client_id, schema_json, limits, conn)
10623    }
10624
10625    /// Build a client backed by an on-disk SQLite database at `path` — the
10626    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
10627    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
10628    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
10629    /// of the command router's dependency set (the router only holds a path).
10630    pub fn open_path(
10631        client_id: String,
10632        schema_json: &Value,
10633        limits: ClientLimits,
10634        path: &str,
10635    ) -> Result<Self, String> {
10636        validate_client_limits(&limits)?;
10637        parse_schema_json(schema_json)?;
10638        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
10639        Self::with_connection(client_id, schema_json, limits, conn)
10640    }
10641
10642    pub fn open_path_with_identity(
10643        client_id: Option<String>,
10644        schema_json: &Value,
10645        limits: ClientLimits,
10646        path: &str,
10647    ) -> Result<Self, String> {
10648        validate_client_limits(&limits)?;
10649        // §7.4.2: an invalid requested version is refused before the replica is
10650        // even created, so a rejected constructor leaves no file behind.
10651        parse_schema_json(schema_json)?;
10652        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
10653        // A file-backed native client uses an independent read connection for
10654        // latency-critical snapshots. WAL is SQLite's intended reader/writer
10655        // concurrency mode: a view read never holds a rollback-journal lock
10656        // that delays the client's next commit. The busy timeout must precede
10657        // this replica's own startup transaction; WAL is applied only after
10658        // startup succeeded, because a refused open must leave the replica's
10659        // journal mode and contents untouched (§7.4.2).
10660        conn.busy_timeout(std::time::Duration::from_millis(250))
10661            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
10662        let client = Self::with_connection_identity(client_id, schema_json, limits, conn)?;
10663        let journal_mode: String = client
10664            .conn
10665            .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
10666            .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
10667        if journal_mode != "wal" {
10668            return Err(
10669                "sync.invalid_request: file-backed client requires WAL journal mode".to_owned(),
10670            );
10671        }
10672        Ok(client)
10673    }
10674
10675    #[must_use]
10676    pub fn client_id(&self) -> &str {
10677        &self.client_id
10678    }
10679
10680    /// Build a client over a caller-supplied rusqlite connection — the seam a
10681    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
10682    /// with an on-disk database (`Connection::open(path)`) rather than the
10683    /// default `:memory:`. The connection MUST be fresh (no pre-existing
10684    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
10685    /// the same file across process restarts reuses the persisted rows.
10686    pub fn with_connection(
10687        client_id: String,
10688        schema_json: &Value,
10689        limits: ClientLimits,
10690        conn: Connection,
10691    ) -> Result<Self, String> {
10692        Self::with_connection_identity(Some(client_id), schema_json, limits, conn)
10693    }
10694
10695    /// [`Self::with_connection`] for `open_path_with_identity`: no requested id
10696    /// means a durable replica's persisted `clientId` wins over a fresh one.
10697    fn with_connection_identity(
10698        client_id: Option<String>,
10699        schema_json: &Value,
10700        limits: ClientLimits,
10701        conn: Connection,
10702    ) -> Result<Self, String> {
10703        validate_client_limits(&limits)?;
10704        let schema = parse_schema_json(schema_json)?;
10705        validate_authority_reads(&schema, &limits.authority_reads)?;
10706        // RFC 0005 D8: resolve the previous-version config before the opening
10707        // reset runs — the capture happens inside it. Bad bounds fail loud.
10708        let previous_version = match limits.previous_version_context {
10709            Some(config) => {
10710                config.validate()?;
10711                Some(config)
10712            }
10713            None => None,
10714        };
10715        let mut client = SyncClient {
10716            #[cfg(feature = "bench-internals")]
10717            benchmark_phases: Recorder::default(),
10718            conn,
10719            schema,
10720            client_id: client_id
10721                .clone()
10722                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
10723            limits,
10724            subs: Vec::new(),
10725            outbox: Vec::new(),
10726            failed_commits: Vec::new(),
10727            retain_failed_commits: false,
10728            conflicts: Vec::new(),
10729            rejections: Vec::new(),
10730            schema_floor: None,
10731            lease_state: None,
10732            stopped: false,
10733            upgrading: false,
10734            sync_needed: false,
10735            realtime_policy: RealtimePolicy::Optional,
10736            realtime_state: RealtimeState::Disconnected,
10737            realtime_reason_code: None,
10738            realtime_retry_delay_ms: None,
10739            presence: HashMap::new(),
10740            now_ms: None,
10741            encryption: crate::values::EncryptionConfig::default(),
10742            security_preflight: false,
10743            transport_enabled: true,
10744            previous_version,
10745            insert_sql: RefCell::new(HashMap::new()),
10746            overlay_dirty: OverlayDirty::clean(),
10747            storage_failure: RefCell::new(None),
10748            #[cfg(test)]
10749            overlay_rebuild_count: Cell::new(0),
10750            #[cfg(test)]
10751            acknowledged_replay_count: Cell::new(0),
10752            #[cfg(test)]
10753            outcome_prune_count: Cell::new(0),
10754            progress: ProgressObserver::default(),
10755            change_queue: VecDeque::new(),
10756            sync_intent_queue: VecDeque::new(),
10757            retry_delay_ms: 250,
10758            round_retry_delay_ms: None,
10759            active_round: None,
10760            last_round: None,
10761            last_change: None,
10762            query_failures: Vec::new(),
10763            coalesced: None,
10764            coalesced_failure: None,
10765        };
10766        // The row write path leans on the prepared-statement cache (two
10767        // insert statements per synced table, plus the bookkeeping
10768        // statements); size it so a multi-table schema never thrashes.
10769        client
10770            .conn
10771            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
10772        // §7.4.2: one write transaction (SQLite `BEGIN IMMEDIATE`, a reserved
10773        // writer) covers the marker read, the identity check, and every
10774        // bookkeeping/schema write, so a concurrent open that upgraded the
10775        // replica cannot interleave between the guard and the writes, and a
10776        // refusal leaves the replica unchanged.
10777        client
10778            .conn
10779            .execute_batch("BEGIN IMMEDIATE")
10780            .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
10781        let startup = (|| -> Result<(), String> {
10782            // The marker guard runs inside the write transaction, so the version
10783            // it validates is the version every write below commits under.
10784            let marker = read_local_schema_version(&client.conn, client.schema.version)?;
10785            // New app indexes may reference columns that only exist after a
10786            // version-bump reset.
10787            client.create_bookkeeping_tables()?;
10788            match client.get_meta(CLIENT_ID_KEY) {
10789                Some(existing) => match client_id {
10790                    Some(requested) if existing != requested => {
10791                        return Err(format!(
10792                            "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
10793                        ))
10794                    }
10795                    _ => client.client_id = existing,
10796                },
10797                None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
10798            }
10799            client.restore_persisted_state()?;
10800            client.failed_commits = client.load_failed_commits()?;
10801            match marker {
10802                None => {
10803                    client.create_synced_tables()?;
10804                    client.persist_schema_version()?;
10805                    client.save_subscription_scope_schema();
10806                }
10807                Some(version) if version == client.schema.version => {
10808                    client.create_synced_tables()?;
10809                    // RFC 0005 D1: the same-version open backfills the descriptor
10810                    // for a database first opened by an unaware binary, so the
10811                    // NEXT bump can capture without needing a schema bump first.
10812                    set_local_schema_descriptor(&client.conn, &client.schema)
10813                        .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
10814                    client.save_subscription_scope_schema();
10815                }
10816                Some(_) => client.run_schema_reset()?,
10817            }
10818            // RFC 0005 D9: the reset sweep cannot see a container whose capture
10819            // committed in its OWN file while the replica's savepoint never
10820            // released — a crash at that seam leaves a container beside a matching
10821            // OLD marker, and the next open at that version runs no reset. Discard
10822            // it before anything can read it. No-op when no container exists.
10823            client.reconcile_previous_version_at_boot()?;
10824            client.clear_satisfied_persisted_schema_floor();
10825            client.prune_unknown_subscriptions(false)?;
10826            // §7.1/§7.4.4: recover intrinsically codec-invalid persisted values at
10827            // the full startup/reset reconciliation boundary, inside this open
10828            // transaction and after the schema and identity guards, so a commit
10829            // an earlier version accepted is dropped with durable rejection
10830            // evidence before any replay. A table the current schema removed
10831            // keeps the §7.4.4 send-time classification, and an unresolved
10832            // encryption key stays with the send seam because hosts configure
10833            // keys after open.
10834            client.drop_unencodable_outbox(false, None)?;
10835            if marker == Some(client.schema.version)
10836                && (!client.outbox.is_empty()
10837                    || !client.failed_commits.is_empty()
10838                    || client.has_acknowledged_rows())
10839            {
10840                // Reconstruct the visible optimistic overlay from the durable
10841                // base plus outbox instead of trusting a process-interrupted
10842                // mirror.
10843                client.overlay_dirty.set(true);
10844                client.rebuild_overlay()?;
10845            }
10846            // Every persisted active subscription needs one catch-up pull on
10847            // open: realtime only covers changes after connection, while an
10848            // idempotent setWindow correctly creates no fresh command effect.
10849            // Pending outbox work has the same restart requirement. The core
10850            // owns this intent so native hosts never poll or require an
10851            // application-issued sync().
10852            client.enqueue_startup_sync_if_needed()?;
10853            Ok(())
10854        })();
10855        match startup {
10856            Ok(()) => match client.conn.execute_batch("COMMIT") {
10857                Ok(()) => Ok(client),
10858                Err(error) => {
10859                    let failure = Self::sqlite_failure(&client.storage_failure, error);
10860                    if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
10861                        // Keep a failed rollback's typed storage classification.
10862                        let _ = Self::sqlite_failure(&client.storage_failure, rollback);
10863                    }
10864                    Err(failure)
10865                }
10866            },
10867            Err(error) => {
10868                if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
10869                    // Keep a failed rollback's typed storage classification.
10870                    let _ = Self::sqlite_failure(&client.storage_failure, rollback);
10871                }
10872                Err(error)
10873            }
10874        }
10875    }
10876
10877    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
10878    /// against this instead of system time (conformance virtual clock).
10879    pub fn set_now_ms(&mut self, now_ms: i64) {
10880        self.now_ms = Some(now_ms);
10881    }
10882
10883    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
10884    /// The command router parses these from the `create` command's
10885    /// `encryption` config (keys as `{$bytes: hex}`).
10886    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
10887        self.encryption = encryption;
10888    }
10889
10890    #[must_use]
10891    pub fn security_lifecycle(&self) -> &'static str {
10892        if self.security_preflight {
10893            "preflight"
10894        } else {
10895            "active"
10896        }
10897    }
10898
10899    #[must_use]
10900    pub fn security_preflight(&self) -> bool {
10901        self.security_preflight
10902    }
10903
10904    /// Host-owned, non-persisted network gate. Local operations remain available.
10905    pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
10906        if self.transport_enabled != enabled {
10907            self.transport_enabled = enabled;
10908            self.sync_intent_queue.clear();
10909            self.sync_intent_queue
10910                .push_back(if enabled && !self.security_preflight {
10911                    SyncIntent::Interactive
10912                } else {
10913                    SyncIntent::None
10914                });
10915        }
10916        // A captured reply keeps its normal apply/revocation semantics.
10917        if !enabled && self.active_round.is_none() {
10918            self.disconnect_realtime(transport);
10919        }
10920    }
10921
10922    pub fn transport_enabled(&self) -> bool {
10923        self.transport_enabled
10924    }
10925
10926    /// Quarantine this replica: enter the fail-closed gate, release all
10927    /// core-owned key material, and record the gate in the database so
10928    /// reopening re-enters preflight until `activate_security` clears it.
10929    ///
10930    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
10931    /// which must NOT leave that durable mark.
10932    pub fn begin_security_preflight(&mut self) {
10933        self.seal_security_on_teardown();
10934        // Persist the quarantine so it survives handle teardown and restart:
10935        // reopening this replica re-enters preflight until activation clears it.
10936        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
10937    }
10938
10939    /// Release core-owned key material as a host tears the client down,
10940    /// WITHOUT recording a quarantine.
10941    ///
10942    /// Shutting an activated client down is not a quarantine event. Persisting
10943    /// the gate here marks every cleanly closed replica as pending, and the
10944    /// next plain `create` is then refused permanently: the reopen path
10945    /// restores the flag from the marker and the create guard rejects it. The
10946    /// in-memory flag still closes the gate for anything still holding this
10947    /// instance, which is all a teardown barrier needs.
10948    pub fn seal_security_on_teardown(&mut self) {
10949        self.cancel_sync_round();
10950        self.security_preflight = true;
10951        self.encryption = crate::values::EncryptionConfig::default();
10952        self.sync_intent_queue.clear();
10953    }
10954
10955    /// Install the post-authentication keyring and release the host loop.
10956    pub fn activate_security(
10957        &mut self,
10958        encryption: crate::values::EncryptionConfig,
10959    ) -> Result<(), String> {
10960        if !self.security_preflight {
10961            return Err(
10962                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
10963            );
10964        }
10965        self.enqueue_startup_sync_if_needed()?;
10966        self.encryption = encryption;
10967        self.security_preflight = false;
10968        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
10969        Ok(())
10970    }
10971
10972    fn clock_now_ms(&self) -> i64 {
10973        self.now_ms.unwrap_or_else(crate::wall_clock_ms)
10974    }
10975
10976    fn create_bookkeeping_tables(&self) -> Result<(), String> {
10977        // Durable client bookkeeping (outbox + subscription + meta).
10978        self.conn
10979            .execute_batch(
10980                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
10981                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
10982                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
10983                 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
10984                   tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
10985                 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
10986                   commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
10987                   commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
10988                   PRIMARY KEY(commit_id, idx));
10989                 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
10990                   seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
10991                   operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
10992                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
10993                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
10994                   client_commit_id TEXT NOT NULL UNIQUE,
10995                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
10996                   recorded_at_ms INTEGER NOT NULL,
10997                   results_json TEXT NOT NULL,
10998                   operations_json TEXT,
10999                   resolution TEXT NOT NULL DEFAULT 'active'
11000                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
11001                   resolved_at_ms INTEGER,
11002                   replacement_client_commit_id TEXT);
11003                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
11004                   ON _syncular_commit_outcomes(resolution, seq);
11005                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
11006                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
11007                 CREATE TABLE IF NOT EXISTS _syncular_meta (
11008                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
11009                 CREATE TABLE IF NOT EXISTS _syncular_windows (
11010                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
11011                   PRIMARY KEY (base, unit));
11012                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
11013                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
11014                   effective_scopes TEXT NOT NULL);
11015                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
11016                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
11017                   PRIMARY KEY(commit_id, blob_id));
11018                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
11019                   ON _syncular_blob_commit_refs(blob_id);",
11020            )
11021            .map_err(|e| e.to_string())?;
11022        // Migrate an outcome journal created before failed aggregate
11023        // envelopes were retained. Historical rows intentionally stay NULL.
11024        let _ = self
11025            .conn
11026            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
11027        if self.get_meta(LOCAL_REVISION_KEY).is_none() {
11028            self.set_meta(LOCAL_REVISION_KEY, "0");
11029        }
11030        // §5.9.7 blob cache. Mutable upload state stays off the body row so
11031        // completing a large upload does not rewrite its BLOB record.
11032        if self.schema_has_blobs() {
11033            let exists = self
11034                .conn
11035                .query_row(
11036                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
11037                    [],
11038                    |row| row.get::<_, bool>(0),
11039                )
11040                .map_err(|e| e.to_string())?;
11041            if exists {
11042                let mut statement = self
11043                    .conn
11044                    .prepare("PRAGMA table_info(_syncular_blobs)")
11045                    .map_err(|e| e.to_string())?;
11046                let mut columns = statement
11047                    .query_map([], |row| row.get::<_, String>(1))
11048                    .map_err(|e| e.to_string())?
11049                    .collect::<Result<Vec<_>, _>>()
11050                    .map_err(|e| e.to_string())?;
11051                columns.sort();
11052                let mut expected = vec![
11053                    "blob_id",
11054                    "byte_length",
11055                    "bytes",
11056                    "created_at_ms",
11057                    "media_type",
11058                ];
11059                expected.sort_unstable();
11060                if columns != expected {
11061                    return Err(
11062                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
11063                    );
11064                }
11065            } else {
11066                self.conn
11067                    .execute_batch(
11068                        "CREATE TABLE _syncular_blobs(
11069                           blob_id TEXT PRIMARY KEY,
11070                           bytes BLOB NOT NULL,
11071                           byte_length INTEGER NOT NULL,
11072                           media_type TEXT,
11073                           created_at_ms INTEGER NOT NULL);",
11074                    )
11075                    .map_err(|e| e.to_string())?;
11076            }
11077            self.conn
11078                .execute_batch(
11079                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
11080                       blob_id TEXT PRIMARY KEY,
11081                       media_type TEXT,
11082                       created_at_ms INTEGER NOT NULL);",
11083                )
11084                .map_err(|e| e.to_string())?;
11085        }
11086        Ok(())
11087    }
11088
11089    /// True iff any synced table declares a `blob_ref` column (§5.9).
11090    fn schema_has_blobs(&self) -> bool {
11091        self.schema
11092            .tables
11093            .iter()
11094            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
11095    }
11096
11097    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
11098
11099    fn get_meta(&self, key: &str) -> Option<String> {
11100        meta_get(&self.conn, key)
11101    }
11102
11103    fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
11104        self.conn
11105            .query_row(
11106                "SELECT value FROM _syncular_meta WHERE key = ?1",
11107                rusqlite::params![key],
11108                |row| row.get::<_, String>(0),
11109            )
11110            .optional()
11111            .map_err(|_| {
11112                "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
11113            })
11114    }
11115
11116    // The caller holds the startup/reset transaction. Either both schema
11117    // records persist or the enclosing operation fails and rolls back.
11118    fn persist_schema_version(&self) -> Result<(), String> {
11119        self.conn
11120            .execute(
11121                "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
11122                rusqlite::params![LOCAL_SCHEMA_VERSION_KEY, self.schema.version.to_string()],
11123            )
11124            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11125        set_local_schema_descriptor(&self.conn, &self.schema)
11126            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
11127    }
11128
11129    fn set_meta(&self, key: &str, value: &str) {
11130        meta_set(&self.conn, key, value);
11131    }
11132
11133    fn delete_meta(&self, key: &str) {
11134        meta_delete(&self.conn, key);
11135    }
11136
11137    /// §7.1: the durable outbox in FIFO order, in schema-agnostic local form.
11138    fn load_outbox(&self) -> Result<Vec<OutboxCommit>, String> {
11139        let mut stmt = self
11140            .conn
11141            .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
11142            .map_err(|error| error.to_string())?;
11143        let rows = stmt
11144            .query_map([], |row| {
11145                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11146            })
11147            .map_err(|error| error.to_string())?;
11148        let mut commits = Vec::new();
11149        for row in rows {
11150            let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
11151            let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
11152                format!("invalid persisted outbox {client_commit_id:?}: {error}")
11153            })?;
11154            let mut ops = Vec::with_capacity(entries.len());
11155            for entry in entries {
11156                let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
11157                ops.push(OutboxOp {
11158                    upsert: op == "upsert",
11159                    table: entry
11160                        .get("table")
11161                        .and_then(Value::as_str)
11162                        .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
11163                        .to_owned(),
11164                    row_id: entry
11165                        .get("rowId")
11166                        .and_then(Value::as_str)
11167                        .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
11168                        .to_owned(),
11169                    base_version: entry.get("baseVersion").and_then(Value::as_i64),
11170                    // A legacy entry's `changedFields` key is ignored:
11171                    // the values map is the presence set (§6.1).
11172                    values: entry.get("values").and_then(Value::as_object).cloned(),
11173                });
11174            }
11175            commits.push(OutboxCommit {
11176                client_commit_id,
11177                ops,
11178            });
11179        }
11180        Ok(commits)
11181    }
11182
11183    fn restore_persisted_state(&mut self) -> Result<(), String> {
11184        self.subs = {
11185            let mut stmt = self
11186                .conn
11187                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
11188                .map_err(|error| error.to_string())?;
11189            let rows = stmt
11190                .query_map([], |row| {
11191                    Ok((
11192                        row.get::<_, String>(0)?,
11193                        row.get::<_, String>(1)?,
11194                        row.get::<_, String>(2)?,
11195                    ))
11196                })
11197                .map_err(|error| error.to_string())?;
11198            let mut subscriptions = Vec::new();
11199            for row in rows {
11200                let (id, table, raw) = row.map_err(|error| error.to_string())?;
11201                let state: Value = serde_json::from_str(&raw)
11202                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
11203                let requested = json_to_scope_map(
11204                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
11205                )?;
11206                let effective = state
11207                    .get("effectiveScopes")
11208                    .filter(|value| !value.is_null())
11209                    .map(json_to_scope_map)
11210                    .transpose()?;
11211                subscriptions.push(Subscription {
11212                    id,
11213                    table,
11214                    requested,
11215                    params: state
11216                        .get("params")
11217                        .and_then(Value::as_str)
11218                        .map(str::to_owned),
11219                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
11220                    bootstrap_state: state
11221                        .get("bootstrapState")
11222                        .and_then(Value::as_str)
11223                        .map(str::to_owned),
11224                    state: SubState::parse(
11225                        state
11226                            .get("status")
11227                            .and_then(Value::as_str)
11228                            .unwrap_or("active"),
11229                    ),
11230                    reason_code: state
11231                        .get("reasonCode")
11232                        .and_then(Value::as_str)
11233                        .map(str::to_owned),
11234                    effective,
11235                    synced_once: state
11236                        .get("syncedOnce")
11237                        .and_then(Value::as_bool)
11238                        .unwrap_or(false),
11239                });
11240            }
11241            subscriptions
11242        };
11243
11244        self.outbox = self.load_outbox()?;
11245
11246        self.prune_commit_outcomes()?;
11247        let active = self.commit_outcomes(CommitOutcomeQuery {
11248            active_only: true,
11249            ..CommitOutcomeQuery::default()
11250        })?;
11251        self.conflicts = active
11252            .iter()
11253            .flat_map(|outcome| outcome.results.iter())
11254            .filter_map(|result| match result {
11255                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
11256                _ => None,
11257            })
11258            .collect();
11259        self.rejections = active
11260            .iter()
11261            .flat_map(|outcome| outcome.results.iter())
11262            .filter_map(|result| match result {
11263                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
11264                _ => None,
11265            })
11266            .collect();
11267
11268        self.lease_state = self
11269            .get_meta(LEASE_STATE_KEY)
11270            .map(|raw| serde_json::from_str(&raw))
11271            .transpose()
11272            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
11273        self.schema_floor = self
11274            .get_meta(SCHEMA_FLOOR_KEY)
11275            .map(|raw| serde_json::from_str(&raw))
11276            .transpose()
11277            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
11278        self.stopped = self.schema_floor.is_some();
11279        // A replica left in unactivated preflight reopens gated: the quarantine
11280        // decision persists with the data across handle teardown and restart.
11281        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
11282            self.security_preflight = true;
11283        }
11284        Ok(())
11285    }
11286
11287    fn save_subscription_scope_schema(&self) {
11288        let declarations: BTreeMap<_, BTreeMap<_, _>> = self
11289            .schema
11290            .tables
11291            .iter()
11292            .map(|table| {
11293                (
11294                    table.name.clone(),
11295                    table
11296                        .scope_variables
11297                        .iter()
11298                        .map(|scope| {
11299                            (
11300                                scope.variable.clone(),
11301                                [scope.prefix.clone(), scope.column.clone()],
11302                            )
11303                        })
11304                        .collect(),
11305                )
11306            })
11307            .collect();
11308        self.set_meta(
11309            "subscriptionScopeSchema",
11310            &serde_json::to_string(&declarations).expect("scope schema serialization"),
11311        );
11312    }
11313
11314    /// Remove registrations with incompatible table/key/prefix/column meaning.
11315    fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
11316        let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
11317            self.get_meta("subscriptionScopeSchema")
11318                .map(|raw| serde_json::from_str(&raw))
11319                .transpose()
11320                .map_err(|error| format!("invalid persisted scope schema: {error}"))?
11321        } else {
11322            None
11323        };
11324        let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
11325            let Some(table) = self.schema.table(table_name) else {
11326                return false;
11327            };
11328            if requested
11329                .iter()
11330                .any(|(variable, _)| table.scope_column(variable).is_none())
11331            {
11332                return false;
11333            }
11334            if !bump {
11335                return true;
11336            }
11337            let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
11338                return false;
11339            };
11340            let variables: BTreeSet<_> = if requested.is_empty() {
11341                old.keys()
11342                    .cloned()
11343                    .chain(
11344                        table
11345                            .scope_variables
11346                            .iter()
11347                            .map(|scope| scope.variable.clone()),
11348                    )
11349                    .collect()
11350            } else {
11351                requested
11352                    .iter()
11353                    .map(|(variable, _)| variable.clone())
11354                    .collect()
11355            };
11356            variables.iter().all(|variable| {
11357                let current = table
11358                    .scope_variables
11359                    .iter()
11360                    .find(|scope| &scope.variable == variable);
11361                match (old.get(variable), current) {
11362                    (Some(old), Some(current)) => {
11363                        old == &[current.prefix.clone(), current.column.clone()]
11364                    }
11365                    _ => false,
11366                }
11367            })
11368        };
11369        let mut stale_ids: BTreeSet<_> = self
11370            .subs
11371            .iter()
11372            .filter(|sub| !compatible(&sub.table, &sub.requested))
11373            .map(|sub| sub.id.clone())
11374            .collect();
11375        for (id, table, effective) in self.load_pending_evictions()? {
11376            if !compatible(&table, &effective) {
11377                stale_ids.insert(id);
11378            }
11379        }
11380        for id in &stale_ids {
11381            for sql in [
11382                "DELETE FROM _syncular_windows WHERE sub_id = ?1",
11383                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
11384                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
11385            ] {
11386                self.conn
11387                    .execute(sql, rusqlite::params![id])
11388                    .map_err(|error| error.to_string())?;
11389            }
11390        }
11391        self.subs.retain(|sub| !stale_ids.contains(&sub.id));
11392        Ok(())
11393    }
11394
11395    #[must_use]
11396    pub fn local_revision(&self) -> u64 {
11397        self.get_meta(LOCAL_REVISION_KEY)
11398            .and_then(|value| value.parse().ok())
11399            .unwrap_or(0)
11400    }
11401
11402    /// §7.5: resolve a snapshot read on the mutable owner connection through
11403    /// the same primitive the read-only sidecar uses, so the command surface
11404    /// and the file sidecar return one revision, rows, coverage, catch-up, and
11405    /// delivery shape. The structured [`QueryReadFailure`] is preserved; an
11406    /// `owner` records its failure (and its next success) in diagnostics.
11407    pub fn snapshot_read(
11408        &mut self,
11409        request: &SnapshotReadRequest<'_>,
11410        owner: Option<QueryOwner<'_>>,
11411    ) -> Result<SnapshotRead, QueryReadFailure> {
11412        let result = snapshot_read_connection(&self.conn, request);
11413        if let Some(owner) = owner {
11414            self.record_query_read(owner, result.as_ref().err());
11415        }
11416        result
11417    }
11418
11419    #[must_use]
11420    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
11421        SyncStatusSnapshot {
11422            current_schema_version: self.schema.version,
11423            outbox: self.outbox.len(),
11424            upgrading: self.upgrading,
11425            lease_state: self.lease_state.clone(),
11426            schema_floor: self.schema_floor.clone(),
11427            sync_needed: self.sync_needed,
11428            previous_version_context: self.previous_version_status(),
11429        }
11430    }
11431
11432    /// RFC 0005 A2: container presence for a host that must act on it (Diego's
11433    /// rollback precondition), read without creating the file.
11434    fn previous_version_status(&self) -> PreviousVersionStatus {
11435        let Some(replica_path) = self.previous_version_replica_path() else {
11436            return PreviousVersionStatus {
11437                present: false,
11438                created_at_ms: None,
11439            };
11440        };
11441        match crate::previous_version::read_previous_version_record(&replica_path) {
11442            Ok(Some(record)) => PreviousVersionStatus {
11443                present: true,
11444                created_at_ms: Some(record.created_at_ms),
11445            },
11446            _ => PreviousVersionStatus {
11447                present: std::path::Path::new(
11448                    &crate::previous_version::previous_version_container_path(&replica_path),
11449                )
11450                .exists(),
11451                created_at_ms: None,
11452            },
11453        }
11454    }
11455
11456    /// RFC 0005 D7: read captured rows from the previous local schema. `state`
11457    /// is always `previousVersion`; the container never makes
11458    /// `query_snapshot()` coverage complete. Lease stop/expiry, scope
11459    /// revocation, coverage completion and the TTL DISCARD the container and
11460    /// close the read with their own reason.
11461    pub fn previous_version_snapshot(
11462        &self,
11463        spec: &PreviousVersionReadSpec,
11464    ) -> Result<PreviousVersionSnapshot, String> {
11465        let now_ms = self.clock_now_ms();
11466        crate::previous_version::previous_version_snapshot(
11467            &self.conn,
11468            self.previous_version_replica_path().as_deref(),
11469            self.previous_version.as_ref(),
11470            PreviousVersionLifecycle {
11471                lease_inactive: self.previous_version_lease_inactive(now_ms),
11472                scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
11473                coverage_complete: self.previous_version_coverage_complete(),
11474            },
11475            now_ms,
11476            self.schema.version,
11477            spec,
11478        )
11479    }
11480
11481    /// RFC 0005 D6: the advisory pre-reset compatibility audit.
11482    #[must_use]
11483    pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
11484        crate::previous_version::stored_previous_version_audit(&self.conn)
11485    }
11486
11487    /// RFC 0005 A2/D9: the executable downgrade step. Drop the container file
11488    /// and both metadata records, and report whether anything was present.
11489    /// Idempotent: absence is a successful no-op.
11490    pub fn previous_version_discard(
11491        &self,
11492    ) -> crate::previous_version::PreviousVersionDiscardOutcome {
11493        self.drop_previous_version()
11494    }
11495
11496    /// The module's discard has no failure path (it removes the file even when
11497    /// the container cannot be opened), so the only error a caller could ever
11498    /// see is a bug; report absence rather than claiming a discard happened.
11499    fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
11500        let absent = crate::previous_version::PreviousVersionDiscardOutcome {
11501            present: false,
11502            discarded: false,
11503        };
11504        let Some(replica_path) = self.previous_version_replica_path() else {
11505            return absent;
11506        };
11507        crate::previous_version::discard_previous_version(&self.conn, &replica_path)
11508            .unwrap_or(absent)
11509    }
11510
11511    /// RFC 0005 D7: `leaseState.errorCode` or an expired lease.
11512    fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
11513        let Some(lease) = &self.lease_state else {
11514            return false;
11515        };
11516        if lease.error_code.is_some() {
11517            return true;
11518        }
11519        lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
11520    }
11521
11522    /// RFC 0005 D7: §7.4.5 replacement coverage — every ACTIVE subscription
11523    /// has `cursor >= 0` and no resume token. An empty active set is not
11524    /// completion (mirrors the TS predicate exactly).
11525    fn previous_version_coverage_complete(&self) -> bool {
11526        let active = self
11527            .subs
11528            .iter()
11529            .filter(|sub| sub.state == SubState::Active)
11530            .collect::<Vec<_>>();
11531        if active.is_empty() {
11532            return false;
11533        }
11534        active
11535            .iter()
11536            .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
11537    }
11538
11539    /// RFC 0005 D7: lifetime triggers evaluated once per sync round, after the
11540    /// apply. A container that no longer has a reason to exist is discarded so
11541    /// the rows do not outlive the window they were kept for.
11542    fn previous_version_lifetime_check(&self) {
11543        let Some(config) = self.previous_version.filter(|config| config.enabled) else {
11544            return;
11545        };
11546        let now_ms = self.clock_now_ms();
11547        if self.previous_version_lease_inactive(now_ms) {
11548            let _ = self.drop_previous_version();
11549            return;
11550        }
11551        let Some(replica_path) = self.previous_version_replica_path() else {
11552            return;
11553        };
11554        let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
11555        else {
11556            return;
11557        };
11558        if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
11559            let _ = self.drop_previous_version();
11560            return;
11561        }
11562        if now_ms - record.created_at_ms > config.max_age_ms {
11563            let _ = self.drop_previous_version();
11564            return;
11565        }
11566        if self.previous_version_coverage_complete() {
11567            let _ = self.drop_previous_version();
11568        }
11569    }
11570
11571    pub fn diagnostics_snapshot(
11572        &self,
11573        request: &ClientDiagnosticsRequest,
11574    ) -> Result<ClientDiagnosticsSnapshot, String> {
11575        if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
11576            return Err(format!(
11577                "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
11578            ));
11579        }
11580        let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
11581        for sub in &self.subs {
11582            let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
11583            let complete =
11584                sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
11585            let state = match sub.state {
11586                SubState::Revoked => "revoked",
11587                SubState::Failed => "failed",
11588                SubState::Active if reset => "reset",
11589                SubState::Active if complete => "complete",
11590                SubState::Active => "bootstrapping",
11591            };
11592            subscriptions.insert(
11593                sub.id.clone(),
11594                DiagnosticSubscription {
11595                    id: sub.id.clone(),
11596                    table: sub.table.clone(),
11597                    state: state.to_owned(),
11598                    complete,
11599                    cursor: Some(sub.cursor),
11600                    reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
11601                },
11602            );
11603        }
11604        for expected in &request.expected_subscriptions {
11605            if expected.id.is_empty() || expected.table.is_empty() {
11606                return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
11607            }
11608            if subscriptions
11609                .get(&expected.id)
11610                .is_some_and(|registered| registered.table != expected.table)
11611            {
11612                subscriptions.insert(
11613                    expected.id.clone(),
11614                    DiagnosticSubscription {
11615                        id: expected.id.clone(),
11616                        table: expected.table.clone(),
11617                        state: "failed".to_owned(),
11618                        complete: false,
11619                        cursor: None,
11620                        reason_code: Some("client.subscription_intent_mismatch".to_owned()),
11621                    },
11622                );
11623            } else {
11624                subscriptions.entry(expected.id.clone()).or_insert_with(|| {
11625                    DiagnosticSubscription {
11626                        id: expected.id.clone(),
11627                        table: expected.table.clone(),
11628                        state: "unregistered".to_owned(),
11629                        complete: false,
11630                        cursor: None,
11631                        reason_code: None,
11632                    }
11633                });
11634            }
11635        }
11636        let mut ordered_subscriptions = Vec::new();
11637        let mut included = BTreeSet::new();
11638        for expected in &request.expected_subscriptions {
11639            if included.insert(expected.id.clone()) {
11640                if let Some(subscription) = subscriptions.get(&expected.id) {
11641                    ordered_subscriptions.push(subscription.clone());
11642                }
11643            }
11644        }
11645        for (id, subscription) in &subscriptions {
11646            if included.insert(id.clone()) {
11647                ordered_subscriptions.push(subscription.clone());
11648            }
11649        }
11650        let subscriptions_truncated =
11651            ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
11652        ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
11653        let captured_at_ms = self.clock_now_ms();
11654        let lease = if let Some(error_code) = self
11655            .lease_state
11656            .as_ref()
11657            .and_then(|state| state.error_code.clone())
11658        {
11659            ClientDiagnosticsLease {
11660                state: "stopped".to_owned(),
11661                expires_at_ms: self
11662                    .lease_state
11663                    .as_ref()
11664                    .and_then(|state| state.expires_at_ms),
11665                error_code: Some(Self::diagnostic_code(&error_code)),
11666            }
11667        } else if let Some(expires_at_ms) = self
11668            .lease_state
11669            .as_ref()
11670            .and_then(|state| state.expires_at_ms)
11671        {
11672            ClientDiagnosticsLease {
11673                state: if expires_at_ms <= captured_at_ms {
11674                    "expired".to_owned()
11675                } else {
11676                    "active".to_owned()
11677                },
11678                expires_at_ms: Some(expires_at_ms),
11679                error_code: None,
11680            }
11681        } else {
11682            ClientDiagnosticsLease {
11683                state: "none".to_owned(),
11684                expires_at_ms: None,
11685                error_code: None,
11686            }
11687        };
11688        let connectivity = match self.last_round.as_ref() {
11689            _ if !self.transport_enabled && self.active_round.is_none() => "offline",
11690            Some(round) if round.status == "succeeded" => "online",
11691            Some(round)
11692                if round.status == "failed"
11693                    && round
11694                        .error_code
11695                        .as_deref()
11696                        .is_some_and(Self::retryable_transport_code) =>
11697            {
11698                "offline"
11699            }
11700            _ => "unknown",
11701        };
11702        Ok(ClientDiagnosticsSnapshot {
11703            version: CLIENT_DIAGNOSTICS_VERSION,
11704            captured_at_ms,
11705            host: ClientDiagnosticsHost {
11706                kind: "direct".to_owned(),
11707                role: "single".to_owned(),
11708                connectivity: connectivity.to_owned(),
11709                realtime: self.realtime_state,
11710                realtime_policy: self.realtime_policy,
11711                realtime_reason_code: self.realtime_reason_code.clone(),
11712                realtime_retry_delay_ms: self.realtime_retry_delay_ms,
11713            },
11714            security_lifecycle: self.security_lifecycle().to_owned(),
11715            schema: ClientDiagnosticsSchema {
11716                current_version: self.schema.version,
11717                upgrading: self.upgrading,
11718                required_version: self
11719                    .schema_floor
11720                    .as_ref()
11721                    .and_then(|floor| floor.required_schema_version),
11722                latest_version: self
11723                    .schema_floor
11724                    .as_ref()
11725                    .and_then(|floor| floor.latest_schema_version),
11726            },
11727            replica: ClientDiagnosticsReplica {
11728                local_revision: self.local_revision().to_string(),
11729                sync_needed: self.sync_needed,
11730                pending_outbox: self.outbox.len(),
11731            },
11732            lease,
11733            subscriptions: ordered_subscriptions,
11734            subscriptions_truncated,
11735            last_round: self.last_round.clone(),
11736            last_change: self.last_change.clone(),
11737            storage: self.diagnostics_storage(),
11738            query_failures: self.query_failures.clone(),
11739        })
11740    }
11741
11742    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
11743        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
11744            let page_count: i64 = self
11745                .conn
11746                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
11747            let page_size: i64 = self
11748                .conn
11749                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
11750            // Reuse compiled aggregates; every invocation still reads current storage.
11751            let outbox_bytes: i64 = self
11752                .conn
11753                .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
11754                .query_row([], |row| row.get(0))?;
11755            let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
11756                "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
11757            )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
11758            let blob_bytes = if self.schema_has_blobs() {
11759                self.conn
11760                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
11761                    .query_row([], |row| row.get(0))?
11762            } else {
11763                0
11764            };
11765            let pressure = self
11766                .limits
11767                .blob_cache_max_bytes
11768                .is_some_and(|limit| blob_bytes > limit);
11769            Ok(ClientDiagnosticsStorage {
11770                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
11771                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
11772                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
11773                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
11774                retained_outcome_entries: Some(outcome_entries.max(0)),
11775                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
11776                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
11777            })
11778        };
11779        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
11780            status: "unreadable".to_owned(),
11781            database_bytes_approx: None,
11782            pending_outbox_bytes_approx: None,
11783            retained_outcome_bytes_approx: None,
11784            retained_outcome_entries: None,
11785            blob_cache_bytes_approx: None,
11786            pressure_reason_code: None,
11787        })
11788    }
11789
11790    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
11791        self.change_queue.drain(..).collect()
11792    }
11793
11794    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
11795        let intents = self.sync_intent_queue.drain(..).collect();
11796        if self.transport_enabled {
11797            intents
11798        } else {
11799            vec![SyncIntent::None]
11800        }
11801    }
11802
11803    fn schedule_background_retry(&mut self) -> u64 {
11804        let delay_ms = self.retry_delay_ms;
11805        self.sync_intent_queue
11806            .push_back(SyncIntent::Background { delay_ms });
11807        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
11808        self.round_retry_delay_ms = Some(delay_ms);
11809        delay_ms
11810    }
11811
11812    fn reset_background_retry(&mut self) {
11813        self.retry_delay_ms = 250;
11814    }
11815
11816    fn retryable_transport_code(code: &str) -> bool {
11817        code == "transport.failed"
11818            || code == "transport.unavailable"
11819            || code == "sync.transport_failed"
11820    }
11821
11822    /// A failed round worth a background retry: a transport failure, or a
11823    /// §10.2 code the catalog marks retryable (clients may hardcode the
11824    /// catalog metadata, §10.1). Matches the TypeScript core, which retries
11825    /// on the server's `retryable` flag.
11826    fn retryable_failure_code(code: &str) -> bool {
11827        Self::retryable_transport_code(code)
11828            || matches!(
11829                code,
11830                "sync.auth_required"
11831                    | "sync.auth_lease_required"
11832                    | "sync.auth_lease_revoked"
11833                    | "sync.internal_error"
11834                    | "sync.idempotency_cache_miss"
11835                    | "sync.schema_not_ready"
11836                    | "sync.segment_expired"
11837                    | "sync.rate_limited"
11838                    | "sync.websocket_connection_limit"
11839            )
11840    }
11841
11842    fn diagnostic_code(code: &str) -> String {
11843        let valid = !code.is_empty()
11844            && code.len() <= 96
11845            && code.contains('.')
11846            && code
11847                .bytes()
11848                .next()
11849                .is_some_and(|byte| byte.is_ascii_lowercase())
11850            && code.bytes().all(|byte| {
11851                byte.is_ascii_lowercase()
11852                    || byte.is_ascii_digit()
11853                    || matches!(byte, b'.' | b'_' | b'-')
11854            });
11855        if valid {
11856            code.to_owned()
11857        } else {
11858            "client.unknown_failure".to_owned()
11859        }
11860    }
11861
11862    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
11863        if self.sync_needed != value {
11864            if self.begin_observation("syncular_status").is_ok() {
11865                self.sync_needed = value;
11866                let batch = ChangeAccumulator {
11867                    status: true,
11868                    ..ChangeAccumulator::default()
11869                };
11870                if self.finish_observation("syncular_status", batch).is_err() {
11871                    self.rollback_observation("syncular_status");
11872                }
11873            } else {
11874                self.sync_needed = value;
11875            }
11876        }
11877        if value && interactive {
11878            self.sync_intent_queue.push_back(SyncIntent::Interactive);
11879        }
11880    }
11881
11882    fn sqlite_failure(
11883        storage_failure: &RefCell<Option<QueryReadFailure>>,
11884        error: rusqlite::Error,
11885    ) -> String {
11886        let message = error.to_string();
11887        let failure = QueryReadFailure::from(error);
11888        let code = failure.code;
11889        if code.is_some() && storage_failure.borrow().is_none() {
11890            *storage_failure.borrow_mut() = Some(failure);
11891        }
11892        match code {
11893            Some(code) => format!("{code}: {message}"),
11894            None => message,
11895        }
11896    }
11897
11898    fn begin_observation(&self, name: &str) -> Result<(), String> {
11899        if let Some(failure) = &self.coalesced_failure {
11900            return Err(failure.clone());
11901        }
11902        // SQLite rolls a whole transaction back on some I/O and capacity
11903        // errors; later frames must not commit on their own after that.
11904        if self.coalesced.is_some() && self.conn.is_autocommit() {
11905            return Err("the coalesced apply transaction was rolled back".to_owned());
11906        }
11907        self.conn
11908            .execute_batch(&format!("SAVEPOINT {name}"))
11909            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
11910    }
11911
11912    /// SPEC §1.4 rule 4: open a coalesced apply transaction unless a caller
11913    /// already owns one.
11914    fn begin_coalesced_apply(&mut self) -> Result<(), String> {
11915        if self.coalesced.is_some() || !self.conn.is_autocommit() {
11916            return Ok(());
11917        }
11918        self.conn
11919            .execute_batch("BEGIN")
11920            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11921        self.coalesced = Some(CoalescedApply {
11922            rows: 0,
11923            subs: self.subs.clone(),
11924            outbox: self.outbox.clone(),
11925            failed_commits: self.failed_commits.clone(),
11926            conflicts: self.conflicts.clone(),
11927            rejections: self.rejections.clone(),
11928            schema_floor: self.schema_floor.clone(),
11929            lease_state: self.lease_state.clone(),
11930            stopped: self.stopped,
11931            upgrading: self.upgrading,
11932            sync_needed: self.sync_needed,
11933            overlay_dirty: self.overlay_dirty.snapshot(),
11934            change_queue: self.change_queue.len(),
11935            sync_intent_queue: self.sync_intent_queue.len(),
11936            last_change: self.last_change.clone(),
11937        });
11938        Ok(())
11939    }
11940
11941    /// Account the rows of the next frame or block. A unit that would take the
11942    /// coalesced transaction past the bound commits the earlier units first.
11943    fn coalesce_unit(&mut self, rows: usize) -> Result<(), String> {
11944        let Some(open) = &self.coalesced else {
11945            return Ok(());
11946        };
11947        if open.rows > 0 && open.rows + rows > COALESCED_APPLY_ROWS {
11948            self.commit_coalesced_apply()?;
11949            self.begin_coalesced_apply()?;
11950        }
11951        if let Some(open) = &mut self.coalesced {
11952            open.rows += rows;
11953        }
11954        Ok(())
11955    }
11956
11957    /// COMMIT the coalesced transaction. A failed COMMIT rolls every coalesced
11958    /// frame back, restores the in-memory state of its `BEGIN`, drops the
11959    /// change batches and sync intents queued since, and fails the rest of the
11960    /// response.
11961    fn commit_coalesced_apply(&mut self) -> Result<(), String> {
11962        let Some(open) = self.coalesced.take() else {
11963            return Ok(());
11964        };
11965        let committed = if self.conn.is_autocommit() {
11966            Err("the coalesced apply transaction was rolled back".to_owned())
11967        } else {
11968            self.conn
11969                .execute_batch("COMMIT")
11970                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
11971        };
11972        let Err(error) = committed else {
11973            return Ok(());
11974        };
11975        if !self.conn.is_autocommit() {
11976            if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
11977                let _ = Self::sqlite_failure(&self.storage_failure, rollback);
11978            }
11979        }
11980        self.subs = open.subs;
11981        self.outbox = open.outbox;
11982        self.failed_commits = open.failed_commits;
11983        self.conflicts = open.conflicts;
11984        self.rejections = open.rejections;
11985        self.schema_floor = open.schema_floor;
11986        self.lease_state = open.lease_state;
11987        self.stopped = open.stopped;
11988        self.upgrading = open.upgrading;
11989        self.sync_needed = open.sync_needed;
11990        self.overlay_dirty.restore(open.overlay_dirty);
11991        self.change_queue.truncate(open.change_queue);
11992        self.sync_intent_queue.truncate(open.sync_intent_queue);
11993        self.last_change = open.last_change;
11994        self.coalesced_failure = Some(error.clone());
11995        Err(error)
11996    }
11997
11998    /// End the response's coalescing: COMMIT what is open and clear a failure.
11999    fn finish_coalesced_apply(&mut self) -> Result<(), String> {
12000        let committed = self.commit_coalesced_apply();
12001        match self.coalesced_failure.take() {
12002            Some(failure) => Err(failure),
12003            None => committed,
12004        }
12005    }
12006
12007    fn rollback_observation(&self, name: &str) {
12008        if let Err(error) = self
12009            .conn
12010            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
12011        {
12012            if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
12013                if failure.rollback_failure.is_none() {
12014                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
12015                }
12016            }
12017        }
12018    }
12019
12020    fn finish_observation(
12021        &mut self,
12022        name: &str,
12023        mut batch: ChangeAccumulator,
12024    ) -> Result<(), String> {
12025        #[cfg(feature = "bench-internals")]
12026        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
12027        if self.failed_commits.iter().any(|(commit, _)| {
12028            commit
12029                .ops
12030                .iter()
12031                .any(|operation| batch.tables.contains_key(&operation.table))
12032        }) {
12033            batch.outcomes = true;
12034        }
12035        if !batch.touched() {
12036            self.conn
12037                .execute_batch(&format!("RELEASE {name}"))
12038                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12039            return Ok(());
12040        }
12041        let revision = self
12042            .local_revision()
12043            .checked_add(1)
12044            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
12045        self.conn
12046            .execute(
12047                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
12048                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
12049            )
12050            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12051        let status = batch.status.then(|| self.status_snapshot());
12052        let event = ClientChangeBatch {
12053            revision: revision.to_string(),
12054            tables: batch
12055                .tables
12056                .into_iter()
12057                .map(|(table, scope_keys)| TableChange {
12058                    table,
12059                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
12060                })
12061                .collect(),
12062            windows: batch
12063                .windows
12064                .into_iter()
12065                .map(|((base_key, table), units)| WindowChange {
12066                    base_key,
12067                    table,
12068                    units: units.into_iter().collect(),
12069                })
12070                .collect(),
12071            status,
12072            conflicts_changed: batch.conflicts,
12073            rejections_changed: batch.rejections,
12074            outcomes_changed: batch.outcomes,
12075        };
12076        self.conn
12077            .execute_batch(&format!("RELEASE {name}"))
12078            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12079        let mut diagnostic_tables = event
12080            .tables
12081            .iter()
12082            .map(|entry| entry.table.clone())
12083            .collect::<BTreeSet<_>>()
12084            .into_iter()
12085            .collect::<Vec<_>>();
12086        let mut diagnostic_windows = event
12087            .windows
12088            .iter()
12089            .map(|entry| entry.table.clone())
12090            .collect::<BTreeSet<_>>()
12091            .into_iter()
12092            .collect::<Vec<_>>();
12093        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
12094            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
12095        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
12096        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
12097        self.last_change = Some(DiagnosticLastChange {
12098            revision: event.revision.clone(),
12099            recorded_at_ms: self.clock_now_ms(),
12100            tables: diagnostic_tables,
12101            windows: diagnostic_windows,
12102            domains_truncated,
12103            status_changed: event.status.is_some(),
12104            conflicts_changed: event.conflicts_changed,
12105            rejections_changed: event.rejections_changed,
12106            outcomes_changed: event.outcomes_changed,
12107        });
12108        self.change_queue.push_back(event);
12109        Ok(())
12110    }
12111
12112    fn record_scope_map(
12113        &self,
12114        batch: &mut ChangeAccumulator,
12115        table_name: &str,
12116        scopes: &[(String, Vec<String>)],
12117    ) {
12118        let Some(table) = self.schema.table(table_name) else {
12119            return;
12120        };
12121        for (variable, values) in scopes {
12122            let Some(scope) = table
12123                .scope_variables
12124                .iter()
12125                .find(|scope| &scope.variable == variable)
12126            else {
12127                continue;
12128            };
12129            for value in values {
12130                batch.scope(table_name, format!("{}:{value}", scope.prefix));
12131            }
12132        }
12133    }
12134
12135    /// Record a row's current scope keys from the base or visible table.
12136    fn record_row_scopes(
12137        &self,
12138        batch: &mut ChangeAccumulator,
12139        table_name: &str,
12140        row_id: &str,
12141        base: bool,
12142    ) -> bool {
12143        let Some(table) = self.schema.table(table_name) else {
12144            return false;
12145        };
12146        if table.scope_variables.is_empty() {
12147            return false;
12148        }
12149        let columns = table
12150            .scope_variables
12151            .iter()
12152            .map(|scope| quote_ident(&scope.column))
12153            .collect::<Vec<_>>()
12154            .join(", ");
12155        let full_table = if base {
12156            base_table(table_name)
12157        } else {
12158            visible_table(table_name)
12159        };
12160        let sql = format!(
12161            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
12162            row_id_predicate(table)
12163        );
12164        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
12165            return false;
12166        };
12167        let values = stmt.query_row(rusqlite::params![row_id], |row| {
12168            let mut values = Vec::with_capacity(table.scope_variables.len());
12169            for index in 0..table.scope_variables.len() {
12170                values.push(row.get::<_, Option<String>>(index)?);
12171            }
12172            Ok(values)
12173        });
12174        let Ok(values) = values else {
12175            return false;
12176        };
12177        let mut recorded = false;
12178        for (scope, value) in table.scope_variables.iter().zip(values) {
12179            if let Some(value) = value {
12180                batch.scope(table_name, format!("{}:{value}", scope.prefix));
12181                recorded = true;
12182            }
12183        }
12184        recorded
12185    }
12186
12187    fn record_commit_changes(
12188        &self,
12189        batch: &mut ChangeAccumulator,
12190        tables: &[String],
12191        changes: &[ssp2::model::Change],
12192    ) {
12193        #[cfg(feature = "bench-internals")]
12194        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
12195        for change in changes {
12196            let Some(table_name) = tables.get(change.table_index as usize) else {
12197                continue;
12198            };
12199            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
12200            if let Some(table) = self.schema.table(table_name) {
12201                for (variable, value) in &change.scopes {
12202                    if let Some(scope) = table
12203                        .scope_variables
12204                        .iter()
12205                        .find(|scope| &scope.variable == variable)
12206                    {
12207                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
12208                        precise = true;
12209                    }
12210                }
12211            }
12212            if !precise {
12213                batch.table(table_name);
12214            }
12215        }
12216    }
12217
12218    fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
12219        if effective.is_empty() {
12220            return false;
12221        }
12222        let Some(table) = self.schema.table(table_name) else {
12223            return false;
12224        };
12225        let mut clauses = Vec::new();
12226        let mut params = Vec::new();
12227        for (variable, values) in effective {
12228            let Some(column) = table.scope_column(variable) else {
12229                return false;
12230            };
12231            if values.is_empty() {
12232                return false;
12233            }
12234            let placeholders = values
12235                .iter()
12236                .map(|value| {
12237                    params.push(SqlValue::Text(value.clone()));
12238                    "?"
12239                })
12240                .collect::<Vec<_>>()
12241                .join(", ");
12242            clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
12243        }
12244        let sql = format!(
12245            "SELECT 1 FROM {} WHERE {} LIMIT 1",
12246            base_table(table_name),
12247            clauses.join(" AND ")
12248        );
12249        self.conn
12250            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
12251            .is_ok()
12252    }
12253
12254    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
12255    pub fn upgrading(&self) -> bool {
12256        self.upgrading
12257    }
12258
12259    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
12260    /// keeping this client's local database (identity, outbox, tables). The
12261    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
12262    /// version increases. Recreation runs the same marker guard as opening
12263    /// a durable replica, holds the guard and every write it authorizes under
12264    /// one write transaction, and preserves the current client (compiled
12265    /// schema, outbox, tables) on refusal.
12266    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
12267        let new_schema = parse_schema_json(schema_json)?;
12268        validate_authority_reads(&new_schema, &self.limits.authority_reads)?;
12269        self.conn
12270            .execute_batch("BEGIN IMMEDIATE")
12271            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12272        let prior_schema = std::mem::replace(&mut self.schema, new_schema);
12273        // §7.4.3/§7.4.4: a reset inside this transaction mutates in-memory
12274        // state (dropped outbox commits, rejections, upgrade and floor flags,
12275        // overlay dirtiness) and appends change/intent batches. A COMMIT that
12276        // fails after the reset succeeded rolls the database back, so every
12277        // one of those fields is captured here and restored with it.
12278        let prior_subs = self.subs.clone();
12279        let prior_outbox = self.outbox.clone();
12280        let prior_rejections = self.rejections.clone();
12281        let prior_upgrading = self.upgrading;
12282        let prior_stopped = self.stopped;
12283        let prior_schema_floor = self.schema_floor.clone();
12284        let prior_overlay_dirty = self.overlay_dirty.snapshot();
12285        let prior_sync_needed = self.sync_needed;
12286        let prior_active_round = self.active_round;
12287        let prior_changes = self.change_queue.len();
12288        let prior_intents = self.sync_intent_queue.len();
12289        let prior_last_change = self.last_change.clone();
12290        let guarded = (|| -> Result<(), String> {
12291            // §7.4.2: the marker is re-read under the write lock the caller
12292            // holds, so a replica another process upgraded meanwhile refuses
12293            // this recreation instead of having its marker and tables
12294            // downgraded.
12295            let marker = read_local_schema_version(&self.conn, self.schema.version)?;
12296            self.create_bookkeeping_tables()?;
12297            match marker {
12298                Some(version) if version < self.schema.version => self.run_schema_reset()?,
12299                None => {
12300                    self.create_synced_tables()?;
12301                    self.persist_schema_version()?;
12302                }
12303                _ => {}
12304            }
12305            // §7.4.2: recreation IS the boot for the in-memory core, so reload the
12306            // durable outbox the same-version path is about to replay. Only the
12307            // outbox is reloaded: the recovery below reads it, and the caller's
12308            // rollback snapshot already covers this field.
12309            self.outbox = self.load_outbox()?;
12310            // §7.1/§7.4.4: recover intrinsically codec-invalid persisted values at
12311            // this guarded boundary once the schema and marker agree. A bump went
12312            // through `run_schema_reset`, which performs the same recovery after
12313            // it creates the new physical tables and before its final replay.
12314            self.drop_unencodable_outbox(false, None)?;
12315            if marker == Some(self.schema.version)
12316                && (!self.outbox.is_empty()
12317                    || !self.failed_commits.is_empty()
12318                    || self.has_acknowledged_rows())
12319            {
12320                self.overlay_dirty.set(true);
12321                self.rebuild_overlay()?;
12322            }
12323            self.prune_unknown_subscriptions(false)?;
12324            self.save_subscription_scope_schema();
12325            // The conformance recreate is the in-memory equivalent of reopening
12326            // a durable client. Apply the same startup catch-up contract even
12327            // when the schema itself did not change.
12328            self.enqueue_startup_sync_if_needed()?;
12329            Ok(())
12330        })();
12331        let outcome = match guarded {
12332            Ok(()) => self
12333                .conn
12334                .execute_batch("COMMIT")
12335                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error)),
12336            Err(error) => Err(error),
12337        };
12338        if let Err(error) = outcome {
12339            if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
12340                // Keep a failed rollback's typed storage classification.
12341                let _ = Self::sqlite_failure(&self.storage_failure, rollback);
12342            }
12343            self.schema = prior_schema;
12344            self.subs = prior_subs;
12345            self.outbox = prior_outbox;
12346            self.rejections = prior_rejections;
12347            self.upgrading = prior_upgrading;
12348            self.stopped = prior_stopped;
12349            self.schema_floor = prior_schema_floor;
12350            self.overlay_dirty.restore(prior_overlay_dirty);
12351            self.sync_needed = prior_sync_needed;
12352            self.active_round = prior_active_round;
12353            self.change_queue.truncate(prior_changes);
12354            self.sync_intent_queue.truncate(prior_intents);
12355            self.last_change = prior_last_change;
12356            // Prepared insert statements were derived from the reverted schema.
12357            self.insert_sql.borrow_mut().clear();
12358            return Err(error);
12359        }
12360        Ok(())
12361    }
12362
12363    fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
12364        let startup_work = !self.stopped
12365            && (!self.outbox.is_empty()
12366                || self.has_acknowledged_rows()
12367                || !self.load_pending_evictions()?.is_empty()
12368                || self.subs.iter().any(|sub| sub.state == SubState::Active));
12369        if startup_work {
12370            self.sync_needed = true;
12371            self.sync_intent_queue.push_back(SyncIntent::Interactive);
12372        }
12373        Ok(())
12374    }
12375
12376    /// A native client persists schema-floor stops across process restarts.
12377    /// Once the running app already satisfies that floor, the persisted stop
12378    /// is only stale evidence from an older server round (for example, the
12379    /// server was restarted after catching up to an app that was ahead).
12380    /// Clear it and let the normal startup pull re-negotiate. If the server is
12381    /// still incompatible it will return the floor again in that first round.
12382    fn clear_satisfied_persisted_schema_floor(&mut self) {
12383        let satisfied = self
12384            .schema_floor
12385            .as_ref()
12386            .and_then(|floor| floor.required_schema_version)
12387            .is_some_and(|required| self.schema.version >= required);
12388        if !satisfied {
12389            return;
12390        }
12391        self.schema_floor = None;
12392        self.stopped = false;
12393        self.delete_meta(SCHEMA_FLOOR_KEY);
12394    }
12395
12396    /// RFC 0005 D1: discard an orphan or stale container at boot. The reset
12397    /// path already swept it; this covers a same-version or fresh-install open,
12398    /// which runs no reset and therefore no sweep — plus the D7 TTL, which only
12399    /// an aware binary can apply.
12400    fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
12401        let Some(replica_path) = self.previous_version_replica_path() else {
12402            return Ok(());
12403        };
12404        let max_age_ms = self
12405            .previous_version
12406            .filter(|config| config.enabled)
12407            .map(|config| config.max_age_ms);
12408        reconcile_previous_version_at_boot(
12409            &self.conn,
12410            &replica_path,
12411            self.schema.version,
12412            max_age_ms,
12413            self.clock_now_ms(),
12414        )
12415    }
12416
12417    /// RFC 0005 D3: the sibling container path, or `None` when the replica has
12418    /// no file — an in-memory replica returns an empty path, and a `file:` URI
12419    /// has no plain sibling path — so the feature resolves as off there.
12420    fn previous_version_replica_path(&self) -> Option<String> {
12421        let path = self.conn.path()?;
12422        if path.is_empty() || path.starts_with("file:") {
12423            return None;
12424        }
12425        Some(path.to_owned())
12426    }
12427
12428    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
12429    /// and leaseState. Drops/recreates every synced table from the new
12430    /// schema, resets subscription sync-state (keeping registrations), clears
12431    /// the schema-floor stop state, rewrites the marker, drops outbox commits
12432    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
12433    fn run_schema_reset(&mut self) -> Result<(), String> {
12434        self.begin_observation("syncular_schema_reset")?;
12435        let prior_subs = self.subs.clone();
12436        let prior_outbox = self.outbox.clone();
12437        let prior_rejections = self.rejections.clone();
12438        let prior_upgrading = self.upgrading;
12439        let prior_stopped = self.stopped;
12440        let prior_schema_floor = self.schema_floor.clone();
12441        let prior_overlay_dirty = self.overlay_dirty.snapshot();
12442        let prior_sync_needed = self.sync_needed;
12443        let prior_active_round = self.active_round;
12444        let mut batch = ChangeAccumulator::default();
12445        let result = self
12446            .run_schema_reset_observed(&mut batch, true)
12447            .and_then(|()| self.finish_observation("syncular_schema_reset", batch));
12448        if let Err(error) = result {
12449            self.rollback_observation("syncular_schema_reset");
12450            self.subs = prior_subs;
12451            self.outbox = prior_outbox;
12452            self.rejections = prior_rejections;
12453            self.upgrading = prior_upgrading;
12454            self.stopped = prior_stopped;
12455            self.schema_floor = prior_schema_floor;
12456            self.overlay_dirty.restore(prior_overlay_dirty);
12457            self.sync_needed = prior_sync_needed;
12458            self.active_round = prior_active_round;
12459            return Err(error);
12460        }
12461        Ok(())
12462    }
12463
12464    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
12465        if self.get_meta(LOG_EPOCH_KEY).is_none() {
12466            self.begin_observation("syncular_epoch_acquisition")?;
12467            let previous_sync_needed = self.sync_needed;
12468            if let Err(error) = self.conn.execute(
12469                "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
12470                rusqlite::params![LOG_EPOCH_KEY, log_epoch],
12471            ) {
12472                self.rollback_observation("syncular_epoch_acquisition");
12473                return Err(Self::sqlite_failure(&self.storage_failure, error));
12474            }
12475            self.sync_needed = true;
12476            let batch = ChangeAccumulator {
12477                status: true,
12478                ..ChangeAccumulator::default()
12479            };
12480            if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
12481                self.rollback_observation("syncular_epoch_acquisition");
12482                self.sync_needed = previous_sync_needed;
12483                return Err(error);
12484            }
12485            self.sync_intent_queue.push_back(SyncIntent::Interactive);
12486            return Ok(Vec::new());
12487        }
12488        let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
12489        self.begin_observation("syncular_log_epoch_reset")?;
12490        let prior_subs = self.subs.clone();
12491        let prior_outbox = self.outbox.clone();
12492        let prior_rejections = self.rejections.clone();
12493        let prior_upgrading = self.upgrading;
12494        let prior_stopped = self.stopped;
12495        let prior_schema_floor = self.schema_floor.clone();
12496        let prior_overlay_dirty = self.overlay_dirty.snapshot();
12497        let prior_sync_needed = self.sync_needed;
12498        let prior_active_round = self.active_round;
12499        let mut batch = ChangeAccumulator::default();
12500        let result = self
12501            .run_schema_reset_observed(&mut batch, false)
12502            .and_then(|()| {
12503                self.conn
12504                    .execute(
12505                        "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
12506                        rusqlite::params![LOG_EPOCH_KEY, log_epoch],
12507                    )
12508                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12509                self.sync_needed = true;
12510                batch.status = true;
12511                self.finish_observation("syncular_log_epoch_reset", batch)
12512            });
12513        if let Err(error) = result {
12514            self.rollback_observation("syncular_log_epoch_reset");
12515            self.subs = prior_subs;
12516            self.outbox = prior_outbox;
12517            self.rejections = prior_rejections;
12518            self.upgrading = prior_upgrading;
12519            self.stopped = prior_stopped;
12520            self.schema_floor = prior_schema_floor;
12521            self.overlay_dirty.restore(prior_overlay_dirty);
12522            self.sync_needed = prior_sync_needed;
12523            self.active_round = prior_active_round;
12524            return Err(error);
12525        }
12526        self.sync_intent_queue.push_back(SyncIntent::Interactive);
12527        Ok(resets)
12528    }
12529
12530    fn run_schema_reset_observed(
12531        &mut self,
12532        batch: &mut ChangeAccumulator,
12533        capture: bool,
12534    ) -> Result<(), String> {
12535        // §7.4.2: the reset is the only path that wipes local tables for a
12536        // version bump, and it rewrites the marker last. Re-read the marker
12537        // under this reset's transaction: a replica another process upgraded
12538        // meanwhile refuses here instead of being reset to this client's
12539        // older schema.
12540        read_local_schema_version(&self.conn, self.schema.version)?;
12541        self.conn
12542            .execute_batch(
12543                "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
12544            )
12545            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12546        self.cancel_sync_round();
12547        self.upgrading = true;
12548        batch.status = true;
12549        // RFC 0005 D6: classify the pending outbox against the NEW compiled
12550        // schema BEFORE the wipe, and record the advisory audit. This pass
12551        // drops nothing, so the audit sees the original pending intent; the
12552        // §7.4.4 send-time drop and the intrinsic value recovery later in this
12553        // reset are the paths that remove a commit. The classification is
12554        // shared with the send-time drop so the two cannot disagree about what
12555        // is incompatible.
12556        if self.previous_version.is_some_and(|config| config.enabled) {
12557            let previous_version = self
12558                .get_meta(LOCAL_SCHEMA_VERSION_KEY)
12559                .and_then(|value| value.parse::<i32>().ok())
12560                .unwrap_or(self.schema.version);
12561            let pending = self
12562                .outbox
12563                .iter()
12564                .map(|commit| PendingCommitAudit {
12565                    commit_id: &commit.client_commit_id,
12566                    operations: commit_audit_operations(commit),
12567                })
12568                .collect::<Vec<_>>();
12569            crate::previous_version::write_previous_version_audit(
12570                &self.conn,
12571                &crate::previous_version::build_previous_version_audit(
12572                    &self.schema,
12573                    &pending,
12574                    previous_version,
12575                    self.schema.version,
12576                    self.clock_now_ms(),
12577                ),
12578            );
12579        }
12580        // RFC 0005 D5: the container lives in its own FILE, so the reset can
12581        // never reach it. Sweep first (unconditional — also on the log-epoch
12582        // reset, which captures nothing), then capture BEFORE the wipe drops
12583        // any row. A refused capture records its reason and leaves no file.
12584        if let Some(replica_path) = self.previous_version_replica_path() {
12585            sweep_previous_version_container(&self.conn, &replica_path)?;
12586            if capture {
12587                if let Some(config) = self.previous_version.filter(|config| config.enabled) {
12588                    let previous_version = self
12589                        .get_meta(LOCAL_SCHEMA_VERSION_KEY)
12590                        .and_then(|value| value.parse::<i32>().ok())
12591                        .unwrap_or(self.schema.version);
12592                    capture_previous_version_from_replica(
12593                        &self.conn,
12594                        &replica_path,
12595                        previous_version,
12596                        self.schema.version,
12597                        &config,
12598                        self.clock_now_ms(),
12599                    )?;
12600                }
12601            }
12602        }
12603        for table in &self.schema.tables {
12604            batch.table(&table.name);
12605        }
12606        for (base_key, unit, table) in self.load_registered_window_units() {
12607            batch.window(&base_key, &table, &unit);
12608        }
12609        if capture {
12610            self.prune_unknown_subscriptions(true)?;
12611        }
12612        // The per-table insert SQL is derived from the OLD column lists.
12613        self.insert_sql.borrow_mut().clear();
12614        self.overlay_dirty.set(true);
12615        // Drop every synced table (base + visible) that currently exists —
12616        // discovered from sqlite_master so a bump that adds/removes tables is
12617        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
12618        // and the blob cache) are preserved; base tables are `_syncular_base_*`
12619        // so they are matched explicitly, not by the bookkeeping filter.
12620        // Drop virtual tables first so SQLite removes every FTS shadow table
12621        // atomically instead of the generic discovery tearing it apart.
12622        let virtual_tables: Vec<String> = {
12623            let mut stmt = self
12624                .conn
12625                .prepare(
12626                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
12627                )
12628                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12629            let rows = stmt
12630                .query_map([], |row| row.get::<_, String>(0))
12631                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12632            rows.filter_map(Result::ok)
12633                .filter(|name| is_synced_table_name(name))
12634                .collect()
12635        };
12636        for name in virtual_tables {
12637            self.conn
12638                .execute_batch(&format!(
12639                    "DROP TABLE IF EXISTS {}",
12640                    quote_ident(&format!("_syncular_fts_{name}"))
12641                ))
12642                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12643            self.conn
12644                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
12645                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12646        }
12647        let existing: Vec<String> = {
12648            let mut stmt = self
12649                .conn
12650                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
12651                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12652            let rows = stmt
12653                .query_map([], |row| row.get::<_, String>(0))
12654                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12655            rows.filter_map(Result::ok)
12656                .filter(|name| is_synced_table_name(name))
12657                .collect()
12658        };
12659        for name in &existing {
12660            if let Some(table) = name.strip_prefix("_syncular_base_") {
12661                batch.table(table);
12662            } else if !name.starts_with("_syncular_") {
12663                batch.table(name);
12664            }
12665        }
12666        for name in existing {
12667            self.conn
12668                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
12669                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12670        }
12671        // Recreate the synced tables from the NEW schema.
12672        self.create_synced_tables()?;
12673        // Reset every subscription's sync-state, keeping the registration.
12674        for sub in &mut self.subs {
12675            sub.cursor = -1;
12676            sub.bootstrap_state = None;
12677            sub.effective = None;
12678            sub.state = SubState::Active;
12679            sub.reason_code = None;
12680            sub.synced_once = false;
12681        }
12682        let subs = self.subs.clone();
12683        for sub in &subs {
12684            self.persist_sub(sub)?;
12685        }
12686        // The stop state is over: this client now ships a servable schema.
12687        self.stopped = false;
12688        self.schema_floor = None;
12689        self.delete_meta(SCHEMA_FLOOR_KEY);
12690        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
12691        self.persist_schema_version()?;
12692        self.save_subscription_scope_schema();
12693        // §7.1/§7.4.4: the previous-version capture and audit above saw the
12694        // original pending intent; the synced tables now carry the new schema, so
12695        // recover intrinsically codec-invalid persisted values here, before the
12696        // final replay. A pending upsert that names a column the new schema
12697        // removed stays pending for the send-time `drop_incompatible_outbox`
12698        // prepass. The reset wrapper snapshots and restores the outbox,
12699        // rejections, and change queues, so a failed commit rolls this recovery
12700        // back with the rest of the reset.
12701        // §7.4.4: the enclosing reset observation owns the publication, so the
12702        // recovery contributes its rejection, outcome, and table domains to it.
12703        self.drop_unencodable_outbox(false, Some(batch))?;
12704        // Re-apply the surviving outbox optimistically over the empty tables.
12705        self.rebuild_overlay()?;
12706        Ok(())
12707    }
12708
12709    /// §7.4.4 send-time prepass: a persisted upsert whose values reference a
12710    /// column the current schema lacks (or a removed table) cannot be encoded
12711    /// (`commit_audit_operations` inspects upserts, so a value-free delete stays
12712    /// encodable and is validated by the server). Drop the commit, undo its
12713    /// optimistic projection, and raise a client-local
12714    /// `sync.outbox_incompatible` rejection.
12715    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
12716        let incompatible = self
12717            .outbox
12718            .iter()
12719            .filter(|commit| {
12720                first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
12721            })
12722            .cloned()
12723            .collect::<Vec<_>>();
12724        if incompatible.is_empty() {
12725            return Ok(false);
12726        }
12727        // §7.4.4: one observation covers the durable outcome writes, the outbox
12728        // removal, the in-memory drop, and the overlay rebuild, so a storage
12729        // fault leaves the replica and the client unchanged and publishes no
12730        // change; a successful drop publishes exactly one batch carrying the
12731        // rejections, outcomes, status, and the tables whose projection changed.
12732        let prior_outbox = self.outbox.clone();
12733        let prior_rejections = self.rejections.clone();
12734        let prior_overlay_dirty = self.overlay_dirty.snapshot();
12735        let owns_transaction = self.conn.is_autocommit();
12736        self.begin_observation("syncular_outbox_drop")?;
12737        let mut batch = ChangeAccumulator::default();
12738        let dropped = (|| -> Result<(), String> {
12739            let mut rejections = Vec::new();
12740            for commit in &incompatible {
12741                let results = commit
12742                    .ops
12743                    .iter()
12744                    .enumerate()
12745                    .map(|(op_index, operation)| {
12746                        let rejection = RejectionRecord {
12747                            client_commit_id: commit.client_commit_id.clone(),
12748                            op_index: op_index as i32,
12749                            code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
12750                            message: "the persisted commit cannot encode under the current schema"
12751                                .to_owned(),
12752                            retryable: false,
12753                            details: None,
12754                            operation: Some(CommitOperation::from(operation)),
12755                        };
12756                        rejections.push(rejection.clone());
12757                        CommitOperationOutcome::Error { rejection }
12758                    })
12759                    .collect::<Vec<_>>();
12760                self.persist_commit_outcome(
12761                    &commit.client_commit_id,
12762                    CommitOutcomeStatus::Rejected,
12763                    &results,
12764                    Some(&commit.ops),
12765                )?;
12766                self.delete_outbox_persisted(&commit.client_commit_id)?;
12767                // A removed table has no local projection to invalidate; every
12768                // surviving table the dropped commit touched does.
12769                for operation in &commit.ops {
12770                    if self.schema.table(&operation.table).is_some() {
12771                        batch.table(&operation.table);
12772                    }
12773                }
12774            }
12775            self.prune_commit_outcomes()?;
12776            let incompatible_ids = incompatible
12777                .iter()
12778                .map(|commit| commit.client_commit_id.as_str())
12779                .collect::<BTreeSet<_>>();
12780            self.outbox
12781                .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
12782            self.rejections.extend(rejections);
12783            // The dropped commits' purely-optimistic rows are undone: the
12784            // visible projection is re-derived from the surviving base + outbox.
12785            self.overlay_dirty.set(true);
12786            self.rebuild_overlay()?;
12787            batch.status = true;
12788            batch.rejections = true;
12789            batch.outcomes = true;
12790            Ok(())
12791        })();
12792        let result = dropped.and_then(|()| self.finish_observation("syncular_outbox_drop", batch));
12793        if let Err(error) = result {
12794            // An outer RELEASE is the COMMIT. Roll back that transaction
12795            // directly: retrying RELEASE after ROLLBACK TO still needs the
12796            // reader's lock. A nested observation only rolls back its savepoint.
12797            if owns_transaction {
12798                if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
12799                    let mut failure = self.storage_failure.borrow_mut();
12800                    let failure =
12801                        failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
12802                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
12803                }
12804            } else {
12805                self.rollback_observation("syncular_outbox_drop");
12806            }
12807            self.outbox = prior_outbox;
12808            self.rejections = prior_rejections;
12809            self.overlay_dirty.restore(prior_overlay_dirty);
12810            return Err(error);
12811        }
12812        Ok(true)
12813    }
12814
12815    /// §7.4.3: (re)create the base + visible table pair for every synced
12816    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
12817    fn create_synced_tables(&self) -> Result<(), String> {
12818        for table in &self.schema.tables {
12819            // The base half + the visible half form the synced-table pair. An
12820            // index name is global in SQLite, so the base half's indexes are
12821            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
12822            for (full, index_prefix) in [
12823                (base_table(&table.name), "_syncular_base_"),
12824                (visible_table(&table.name), ""),
12825            ] {
12826                let mut cols: Vec<String> =
12827                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
12828                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
12829                let sql = format!(
12830                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
12831                    cols.join(", "),
12832                    quote_ident(&table.primary_key)
12833                );
12834                self.conn
12835                    .execute(&sql, [])
12836                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12837                // Local secondary indexes (CREATE INDEX subset). Created on
12838                // both halves so mirror reads hit an index on either. Runs on
12839                // both the initial create and the §7.4.3 reset recreate path.
12840                for index in &table.indexes {
12841                    let unique = if index.unique { "UNIQUE " } else { "" };
12842                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
12843                    let cols_sql = index
12844                        .columns
12845                        .iter()
12846                        .map(|c| quote_ident(c))
12847                        .collect::<Vec<_>>()
12848                        .join(", ");
12849                    let index_sql = format!(
12850                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
12851                    );
12852                    self.conn
12853                        .execute(&index_sql, [])
12854                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12855                }
12856            }
12857        }
12858        // FTS exists only on the visible half. It is a local search
12859        // projection, never a synced/base table or a wire-schema member.
12860        for table in &self.schema.tables {
12861            for index in &table.fts_indexes {
12862                self.create_fts_projection(table, index)?;
12863            }
12864        }
12865        Ok(())
12866    }
12867
12868    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
12869        let count: i64 = self
12870            .conn
12871            .query_row(
12872                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
12873                rusqlite::params![index.name],
12874                |row| row.get(0),
12875            )
12876            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12877        Ok(count > 0)
12878    }
12879
12880    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
12881        for suffix in ["ai", "ad", "au"] {
12882            self.conn
12883                .execute(
12884                    &format!(
12885                        "DROP TRIGGER IF EXISTS {}",
12886                        quote_ident(&format!("{}_{suffix}", index.name))
12887                    ),
12888                    [],
12889                )
12890                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12891        }
12892        Ok(())
12893    }
12894
12895    fn create_fts_triggers(
12896        &self,
12897        table: &TableSchema,
12898        index: &FtsIndexSchema,
12899    ) -> Result<(), String> {
12900        let fts = quote_ident(&index.name);
12901        let source = visible_table(&table.name);
12902        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
12903        let pk = quote_ident(&table.primary_key);
12904        let projection_columns = std::iter::once(source_id.clone())
12905            .chain(index.columns.iter().map(|column| quote_ident(column)))
12906            .collect::<Vec<_>>()
12907            .join(", ");
12908        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
12909            .chain(
12910                index
12911                    .columns
12912                    .iter()
12913                    .map(|column| format!("new.{}", quote_ident(column))),
12914            )
12915            .collect::<Vec<_>>()
12916            .join(", ");
12917        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
12918        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)");
12919        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)");
12920        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})");
12921        let bi = quote_ident(&format!("{}_bi", index.name));
12922        let ai = quote_ident(&format!("{}_ai", index.name));
12923        let ad = quote_ident(&format!("{}_ad", index.name));
12924        let au = quote_ident(&format!("{}_au", index.name));
12925        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
12926        let sql = format!(
12927            "DROP TRIGGER IF EXISTS {bi};
12928             DROP TRIGGER IF EXISTS {ai};
12929             DROP TRIGGER IF EXISTS {ad};
12930             DROP TRIGGER IF EXISTS {au};
12931             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
12932             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
12933             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
12934             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
12935        );
12936        self.conn
12937            .execute_batch(&sql)
12938            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12939    }
12940
12941    fn rebuild_fts_projection(
12942        &self,
12943        table: &TableSchema,
12944        index: &FtsIndexSchema,
12945    ) -> Result<(), String> {
12946        let fts = quote_ident(&index.name);
12947        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
12948        let pk = quote_ident(&table.primary_key);
12949        let indexed_columns = index
12950            .columns
12951            .iter()
12952            .map(|column| quote_ident(column))
12953            .collect::<Vec<_>>();
12954        let projection_columns = std::iter::once(source_id.clone())
12955            .chain(indexed_columns.iter().cloned())
12956            .collect::<Vec<_>>()
12957            .join(", ");
12958        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
12959        let sql = format!(
12960            "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};",
12961            columns = indexed_columns.join(", "),
12962            source = visible_table(&table.name),
12963        );
12964        self.conn
12965            .execute_batch(&sql)
12966            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12967    }
12968
12969    fn create_fts_projection(
12970        &self,
12971        table: &TableSchema,
12972        index: &FtsIndexSchema,
12973    ) -> Result<(), String> {
12974        self.begin_observation("syncular_fts_schema")?;
12975        let result = (|| {
12976            let existed = self.fts_projection_exists(index)?;
12977            let tokenizer = index.tokenize.replace('\'', "''");
12978            let columns = index
12979                .columns
12980                .iter()
12981                .map(|column| quote_ident(column))
12982                .collect::<Vec<_>>()
12983                .join(", ");
12984            let sql = format!(
12985            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
12986            fts = quote_ident(&index.name),
12987            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
12988        );
12989            self.conn.execute(&sql, []).map_err(|error| {
12990                format!(
12991                    "cannot create local FTS5 projection {:?}: {error}",
12992                    index.name
12993                )
12994            })?;
12995            let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
12996            let mapped: bool = self
12997                .conn
12998                .query_row(
12999                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
13000                    [format!("_syncular_fts_{}", index.name)],
13001                    |row| row.get(0),
13002                )
13003                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13004            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))?;
13005            if !mapped && existed {
13006                self.conn
13007                    .execute_batch(&format!(
13008                        "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
13009                        quote_ident(FTS_SOURCE_ID_COLUMN),
13010                        quote_ident(&index.name)
13011                    ))
13012                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13013            }
13014            self.create_fts_triggers(table, index)?;
13015            if !existed {
13016                self.rebuild_fts_projection(table, index)?;
13017            }
13018            Ok(())
13019        })();
13020        if let Err(error) = result {
13021            self.rollback_observation("syncular_fts_schema");
13022            return Err(error);
13023        }
13024        if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
13025            self.rollback_observation("syncular_fts_schema");
13026            return Err(error.to_string());
13027        }
13028        Ok(())
13029    }
13030
13031    // -- persistence write-through --------------------------------------------
13032
13033    fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
13034        let state = serde_json::json!({
13035            "requested": scope_map_to_json(&sub.requested),
13036            "params": sub.params,
13037            "cursor": sub.cursor,
13038            "bootstrapState": sub.bootstrap_state,
13039            "status": sub.state.name(),
13040            "reasonCode": sub.reason_code,
13041            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
13042            "syncedOnce": sub.synced_once,
13043        });
13044        self.conn.execute(
13045            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
13046            rusqlite::params![sub.id, sub.table, state.to_string()],
13047        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13048    }
13049
13050    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
13051        let ops: Vec<Value> = commit
13052            .ops
13053            .iter()
13054            .map(|op| {
13055                serde_json::json!({
13056                    "op": if op.upsert { "upsert" } else { "delete" },
13057                    "table": op.table,
13058                    "rowId": op.row_id,
13059                    "baseVersion": op.base_version,
13060                    "values": op.values.clone().map(Value::Object),
13061                })
13062            })
13063            .collect();
13064        self.conn
13065            .execute(
13066                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
13067                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
13068            )
13069            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13070        for operation in &commit.ops {
13071            let Some(values) = &operation.values else {
13072                continue;
13073            };
13074            let Some(table) = self.schema.table(&operation.table) else {
13075                continue;
13076            };
13077            for column in table
13078                .columns
13079                .iter()
13080                .filter(|column| column.ty == ColumnType::BlobRef)
13081            {
13082                let Some(Value::String(raw)) = values.get(&column.name) else {
13083                    continue;
13084                };
13085                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
13086                    continue;
13087                };
13088                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
13089                    continue;
13090                };
13091                self.conn
13092                    .execute(
13093                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
13094                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
13095                        rusqlite::params![commit.client_commit_id, blob_id],
13096                    )
13097                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13098            }
13099        }
13100        Ok(())
13101    }
13102
13103    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
13104        if let Some(commit) = self
13105            .outbox
13106            .iter()
13107            .find(|commit| commit.client_commit_id == client_commit_id)
13108        {
13109            for operation in &commit.ops {
13110                self.overlay_dirty.table(&operation.table);
13111            }
13112        }
13113
13114        self.conn
13115            .execute(
13116                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
13117                rusqlite::params![client_commit_id],
13118            )
13119            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13120        self.conn
13121            .execute(
13122                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
13123                rusqlite::params![client_commit_id],
13124            )
13125            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13126        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)", [])
13127            .map(|_| ())
13128            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13129    }
13130
13131    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
13132        match status {
13133            CommitOutcomeStatus::Applied => "applied",
13134            CommitOutcomeStatus::Cached => "cached",
13135            CommitOutcomeStatus::Conflict => "conflict",
13136            CommitOutcomeStatus::Rejected => "rejected",
13137        }
13138    }
13139
13140    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
13141        match resolution {
13142            CommitOutcomeResolution::Active => "active",
13143            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
13144            CommitOutcomeResolution::Superseded => "superseded",
13145            CommitOutcomeResolution::Dismissed => "dismissed",
13146        }
13147    }
13148
13149    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
13150        match value {
13151            "applied" => Ok(CommitOutcomeStatus::Applied),
13152            "cached" => Ok(CommitOutcomeStatus::Cached),
13153            "conflict" => Ok(CommitOutcomeStatus::Conflict),
13154            "rejected" => Ok(CommitOutcomeStatus::Rejected),
13155            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
13156        }
13157    }
13158
13159    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
13160        match value {
13161            "active" => Ok(CommitOutcomeResolution::Active),
13162            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
13163            "superseded" => Ok(CommitOutcomeResolution::Superseded),
13164            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
13165            _ => Err(format!(
13166                "invalid persisted commit outcome resolution {value:?}"
13167            )),
13168        }
13169    }
13170
13171    fn persist_commit_outcome(
13172        &self,
13173        client_commit_id: &str,
13174        status: CommitOutcomeStatus,
13175        results: &[CommitOperationOutcome],
13176        operations: Option<&[OutboxOp]>,
13177    ) -> Result<(), String> {
13178        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
13179        let operations_json = operations
13180            .map(|items| {
13181                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
13182            })
13183            .transpose()
13184            .map_err(|error| error.to_string())?;
13185        self.conn
13186            .execute(
13187                "INSERT INTO _syncular_commit_outcomes (
13188                   client_commit_id, status, recorded_at_ms, results_json,
13189                   operations_json, resolution
13190                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
13191                rusqlite::params![
13192                    client_commit_id,
13193                    Self::outcome_status_name(status),
13194                    self.clock_now_ms(),
13195                    results_json,
13196                    operations_json
13197                ],
13198            )
13199            .map(|_| ())
13200            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13201    }
13202
13203    /// §7.2: the journal holds a stored cell only in its canonical shape. A cell
13204    /// the byte-envelope rule refuses cannot be journaled verbatim.
13205    fn stored_cell_is_representable(cell: &Value) -> bool {
13206        cell.is_null()
13207            || cell.is_string()
13208            || cell.is_boolean()
13209            || cell.is_number()
13210            || is_canonical_bytes_envelope(cell)
13211    }
13212
13213    fn stored_row_is_representable(value: &Value) -> bool {
13214        value
13215            .as_object()
13216            .is_some_and(|row| row.values().all(Self::stored_cell_is_representable))
13217    }
13218
13219    fn stored_operation_is_representable(value: &Value) -> bool {
13220        value.get("table").is_some_and(Value::is_string)
13221            && value.get("rowId").is_some_and(Value::is_string)
13222            && value.get("baseVersion").is_none_or(|v| {
13223                v.as_i64()
13224                    .is_some_and(|n| n.unsigned_abs() <= MAX_JS_SAFE_INTEGER)
13225            })
13226            && match value.get("op").and_then(Value::as_str) {
13227                Some("upsert") => value
13228                    .get("values")
13229                    .is_some_and(Self::stored_row_is_representable),
13230                Some("delete") => value.get("values").is_none(),
13231                _ => false,
13232            }
13233    }
13234
13235    /// §7.2/§7.4.4: a dropped commit keeps its operation envelope in the journal
13236    /// only when the journal reader accepts the serialized operation, which is the
13237    /// rule `outcome_from_row` applies to this same row later.
13238    fn outbox_op_is_journalable(operation: &OutboxOp) -> bool {
13239        serde_json::to_value(CommitOperation::from(operation))
13240            .is_ok_and(|value| Self::stored_operation_is_representable(&value))
13241    }
13242
13243    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
13244        let StoredCommitOutcomeRow {
13245            sequence,
13246            client_commit_id,
13247            status,
13248            recorded_at_ms,
13249            results_json,
13250            operations_json,
13251            resolution,
13252            resolved_at_ms,
13253            replacement_client_commit_id,
13254        } = row;
13255        if sequence.unsigned_abs() > MAX_JS_SAFE_INTEGER
13256            || recorded_at_ms.unsigned_abs() > MAX_JS_SAFE_INTEGER
13257            || resolved_at_ms.is_some_and(|value| value.unsigned_abs() > MAX_JS_SAFE_INTEGER)
13258        {
13259            return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned());
13260        }
13261        let invalid = || "sync.local_corrupt: persisted commit outcome is invalid".to_owned();
13262        let stored: Vec<Value> = serde_json::from_str(&results_json).map_err(|_| invalid())?;
13263        for result in &stored {
13264            let nested = match result.get("status").and_then(Value::as_str) {
13265                Some("conflict") => result.get("conflict"),
13266                Some("error") => result.get("rejection"),
13267                _ => None,
13268            };
13269            if nested
13270                .and_then(|record| record.get("operation"))
13271                .is_some_and(|op| !Self::stored_operation_is_representable(op))
13272                || (result.get("status").and_then(Value::as_str) == Some("conflict")
13273                    && nested
13274                        .and_then(|record| record.get("serverRow"))
13275                        .is_some_and(|row| !Self::stored_row_is_representable(row)))
13276            {
13277                return Err(invalid());
13278            }
13279        }
13280        let results: Vec<CommitOperationOutcome> =
13281            serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())?;
13282        let has_conflict = results
13283            .iter()
13284            .any(|result| matches!(result, CommitOperationOutcome::Conflict { .. }));
13285        let has_error = results
13286            .iter()
13287            .any(|result| matches!(result, CommitOperationOutcome::Error { .. }));
13288        let status = Self::parse_outcome_status(&status).map_err(|_| invalid())?;
13289        let redacted = results.is_empty() && operations_json.is_none();
13290        if match status {
13291            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => has_conflict || has_error,
13292            CommitOutcomeStatus::Conflict => !(has_conflict || redacted),
13293            CommitOutcomeStatus::Rejected => has_conflict || !(has_error || redacted),
13294        } {
13295            return Err(invalid());
13296        }
13297        let operations: Option<Vec<CommitOperation>> = operations_json
13298            .map(|raw| {
13299                let stored: Vec<Value> = serde_json::from_str(&raw).map_err(|_| invalid())?;
13300                if !stored.iter().all(Self::stored_operation_is_representable) {
13301                    return Err(invalid());
13302                }
13303                serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())
13304            })
13305            .transpose()?;
13306        for result in &results {
13307            match result {
13308                CommitOperationOutcome::Conflict { conflict }
13309                    if conflict.server_version.unsigned_abs() > MAX_JS_SAFE_INTEGER =>
13310                {
13311                    return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned())
13312                }
13313                CommitOperationOutcome::Error { rejection } => {
13314                    if let Some(details) = &rejection.details {
13315                        let encoded = serde_json::to_string(details).map_err(|_| {
13316                            "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
13317                        })?;
13318                        RejectionDetails::parse(&encoded).map_err(|_| {
13319                            "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
13320                        })?;
13321                    }
13322                }
13323                _ => {}
13324            }
13325        }
13326        Ok(CommitOutcome {
13327            sequence,
13328            client_commit_id,
13329            status,
13330            recorded_at_ms,
13331            results,
13332            operations,
13333            retained_rows: None,
13334            resolution: Self::parse_outcome_resolution(&resolution)?,
13335            resolved_at_ms,
13336            replacement_client_commit_id,
13337        })
13338    }
13339
13340    /// Resolve declared unique keys through SQLite, including affinity and NULL semantics.
13341    fn unique_conflicts(
13342        &self,
13343        table: &TableSchema,
13344        values: &Map<String, Value>,
13345        base: bool,
13346    ) -> Result<Vec<RetainedUniqueConflict>, String> {
13347        let source = if base {
13348            base_table(&table.name)
13349        } else {
13350            visible_table(&table.name)
13351        };
13352        let mut conflicts = Vec::new();
13353        for index in table.indexes.iter().filter(|index| index.unique) {
13354            let mut params = vec![json_param_to_sql(
13355                values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
13356            )?];
13357            for name in &index.columns {
13358                let column = table
13359                    .columns
13360                    .iter()
13361                    .find(|column| column.name == *name)
13362                    .ok_or("sync.local_corrupt")?;
13363                let cell = json_to_column_value(column, values.get(name))?;
13364                params.push(owned_sql_value(RowParam::Cell(&cell))?);
13365            }
13366            let sql = format!(
13367                "SELECT * FROM {source} WHERE {} != ? AND {}",
13368                quote_ident(&table.primary_key),
13369                index
13370                    .columns
13371                    .iter()
13372                    .map(|column| format!("{} = ?", quote_ident(column)))
13373                    .collect::<Vec<_>>()
13374                    .join(" AND ")
13375            );
13376            let mut statement = self
13377                .conn
13378                .prepare_cached(&sql)
13379                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13380            let records = statement
13381                .query_map(rusqlite::params_from_iter(params), |record| {
13382                    let values = table
13383                        .columns
13384                        .iter()
13385                        .enumerate()
13386                        .map(|(index, column)| {
13387                            let value = sql_ref_to_json(column, record.get_ref(index)?);
13388                            json_to_column_value(column, Some(&value)).map_err(|message| {
13389                                rusqlite::Error::ToSqlConversionFailure(message.into())
13390                            })
13391                        })
13392                        .collect::<Result<Row, _>>()?;
13393                    Ok((values, record.get::<_, i64>(table.columns.len())?))
13394                })
13395                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13396            for record in records {
13397                let (row, version) =
13398                    record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13399                let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
13400                conflicts.push(RetainedUniqueConflict {
13401                    index: index.name.clone(),
13402                    columns: index.columns.clone(),
13403                    row_id,
13404                    server_row: table
13405                        .columns
13406                        .iter()
13407                        .zip(&row)
13408                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
13409                        .collect(),
13410                    server_version: version,
13411                });
13412            }
13413        }
13414        Ok(conflicts)
13415    }
13416
13417    fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
13418        if let Some((commit, initial)) = self
13419            .failed_commits
13420            .iter()
13421            .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
13422        {
13423            let mut rows = Vec::new();
13424            for operation in &commit.ops {
13425                let table = self
13426                    .schema
13427                    .table(&operation.table)
13428                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
13429                let key =
13430                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
13431                        .map_err(|_| "sync.local_corrupt".to_owned())?;
13432                let base = self.stored_row(table, &operation.row_id, true)?;
13433                rows.push(RetainedCommitRow {
13434                    table: operation.table.clone(),
13435                    row_id: operation.row_id.clone(),
13436                    unique_conflicts: if operation.upsert {
13437                        self.unique_conflicts(
13438                            table,
13439                            initial
13440                                .get(&key)
13441                                .and_then(Value::as_object)
13442                                .or(operation.values.as_ref())
13443                                .ok_or("sync.local_corrupt")?,
13444                            true,
13445                        )?
13446                    } else {
13447                        Vec::new()
13448                    },
13449                    local_row: if operation.upsert {
13450                        initial
13451                            .get(&key)
13452                            .and_then(Value::as_object)
13453                            .cloned()
13454                            .or_else(|| operation.values.clone())
13455                    } else {
13456                        None
13457                    },
13458                    server_row: base.as_ref().map(|(values, _)| {
13459                        table
13460                            .columns
13461                            .iter()
13462                            .zip(values)
13463                            .map(|(column, value)| {
13464                                (column.name.clone(), column_value_to_json(value))
13465                            })
13466                            .collect()
13467                    }),
13468                    server_version: base.map(|(_, version)| version),
13469                });
13470            }
13471            outcome.retained_rows = Some(rows);
13472        }
13473        Ok(outcome)
13474    }
13475
13476    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
13477        let row = self
13478            .conn
13479            .query_row(
13480                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
13481                        resolution, resolved_at_ms, replacement_client_commit_id
13482                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
13483                rusqlite::params![client_commit_id],
13484                |row| {
13485                    Ok(StoredCommitOutcomeRow {
13486                        sequence: row.get(0)?,
13487                        client_commit_id: row.get(1)?,
13488                        status: row.get(2)?,
13489                        recorded_at_ms: row.get(3)?,
13490                        results_json: row.get(4)?,
13491                        operations_json: row.get(5)?,
13492                        resolution: row.get(6)?,
13493                        resolved_at_ms: row.get(7)?,
13494                        replacement_client_commit_id: row.get(8)?,
13495                    })
13496                },
13497            )
13498            .optional()
13499            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13500        row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
13501            .transpose()
13502    }
13503
13504    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
13505        if query.limit == Some(0) {
13506            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
13507        }
13508        let mut sql = String::from(
13509            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
13510                    resolution, resolved_at_ms, replacement_client_commit_id
13511               FROM _syncular_commit_outcomes",
13512        );
13513        if query.active_only {
13514            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
13515        }
13516        sql.push_str(" ORDER BY seq DESC");
13517        if let Some(limit) = query.limit {
13518            sql.push_str(&format!(" LIMIT {limit}"));
13519        }
13520        let mut stmt = self
13521            .conn
13522            .prepare(&sql)
13523            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13524        let rows = stmt
13525            .query_map([], |row| {
13526                Ok(StoredCommitOutcomeRow {
13527                    sequence: row.get(0)?,
13528                    client_commit_id: row.get(1)?,
13529                    status: row.get(2)?,
13530                    recorded_at_ms: row.get(3)?,
13531                    results_json: row.get(4)?,
13532                    operations_json: row.get(5)?,
13533                    resolution: row.get(6)?,
13534                    resolved_at_ms: row.get(7)?,
13535                    replacement_client_commit_id: row.get(8)?,
13536                })
13537            })
13538            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13539        let mut outcomes = Vec::new();
13540        for row in rows {
13541            outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
13542                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
13543            )?)?);
13544        }
13545        Ok(outcomes)
13546    }
13547
13548    fn prune_commit_outcomes(&self) -> Result<(), String> {
13549        #[cfg(test)]
13550        self.outcome_prune_count
13551            .set(self.outcome_prune_count.get() + 1);
13552        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
13553        let count = self
13554            .conn
13555            .query_row(
13556                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
13557                [],
13558                |row| row.get::<_, i64>(0),
13559            )
13560            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13561            as usize;
13562        let excess = count.saturating_sub(max_entries);
13563        if excess == 0 {
13564            return Ok(());
13565        }
13566        let mut stmt = self
13567            .conn
13568            .prepare(
13569                "SELECT seq FROM _syncular_commit_outcomes
13570                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
13571                  ORDER BY seq ASC LIMIT ?1",
13572            )
13573            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13574        let rows = stmt
13575            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
13576            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13577        let sequences = rows
13578            .collect::<Result<Vec<_>, _>>()
13579            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13580        drop(stmt);
13581        for sequence in sequences {
13582            self.conn
13583                .execute(
13584                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
13585                    rusqlite::params![sequence],
13586                )
13587                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13588        }
13589        Ok(())
13590    }
13591
13592    // -- driver surface ---------------------------------------------------------
13593
13594    pub fn subscribe(
13595        &mut self,
13596        id: String,
13597        table: String,
13598        scopes: Vec<(String, Vec<String>)>,
13599        params: Option<String>,
13600    ) -> Result<(), String> {
13601        if self.schema.table(&table).is_none() {
13602            return Err(format!("unknown table {table:?}"));
13603        }
13604        let registered_at = self
13605            .subs
13606            .iter()
13607            .position(|subscription| subscription.id == id);
13608        if let Some(index) = registered_at {
13609            let existing = &self.subs[index];
13610            let same_intent = existing.table == table
13611                && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
13612                && existing.params == params;
13613            if !same_intent {
13614                return Err(
13615                    "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
13616                        .to_owned(),
13617                );
13618            }
13619            // §3.3: a revoked registration keeps its id and intent but no
13620            // progress. Subscribing again with the same intent re-arms it and
13621            // asks for a pull, which resolves its scopes from scratch. A
13622            // `failed` registration is a configuration error and stays put.
13623            if existing.state != SubState::Revoked {
13624                return Ok(());
13625            }
13626        }
13627        let sub = Subscription {
13628            id: id.clone(),
13629            table,
13630            requested: scopes,
13631            params,
13632            cursor: -1,
13633            bootstrap_state: None,
13634            state: SubState::Active,
13635            reason_code: None,
13636            effective: None,
13637            synced_once: false,
13638        };
13639        self.persist_sub(&sub)?;
13640        match registered_at {
13641            Some(index) => self.subs[index] = sub,
13642            None => self.subs.push(sub),
13643        }
13644        // Only a re-arm asks for a sync here: a fresh registration reaches the
13645        // next round through the host's schedule, and `set_window` (§4.8)
13646        // batches its own intent for the units it registers.
13647        if registered_at.is_some() {
13648            self.set_sync_needed(true, true);
13649        }
13650        Ok(())
13651    }
13652
13653    /// Register several subscriptions in one local transaction: the same
13654    /// rules as `subscribe` for each request, and one commit for all of them
13655    /// (on OPFS a commit costs milliseconds, so a cold start with hundreds of
13656    /// registrations pays it once). An error rolls back every registration
13657    /// of the call.
13658    pub fn subscribe_many(&mut self, requests: Vec<SubscriptionRequest>) -> Result<(), String> {
13659        let prior_subs = self.subs.clone();
13660        self.begin_observation("syncular_subscribe")?;
13661        let registered = (|| {
13662            for request in requests {
13663                self.subscribe(request.id, request.table, request.scopes, request.params)?;
13664            }
13665            self.conn
13666                .execute_batch("RELEASE syncular_subscribe")
13667                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13668        })();
13669        if let Err(error) = registered {
13670            self.rollback_observation("syncular_subscribe");
13671            self.subs = prior_subs;
13672            return Err(error);
13673        }
13674        Ok(())
13675    }
13676
13677    pub fn unsubscribe(&mut self, id: &str) {
13678        if self.subs.iter().any(|s| s.id == id) {
13679            self.cancel_sync_round();
13680        }
13681        self.subs.retain(|s| s.id != id);
13682        let _ = self.conn.execute(
13683            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
13684            rusqlite::params![id],
13685        );
13686    }
13687
13688    // -- windowed subscriptions (§4.8) ------------------------------------------
13689
13690    /// §4.8: set the live window units for a base — a value-sharded family
13691    /// of subscriptions, one per unit. Added units get fresh subscriptions
13692    /// (image-lane bootstrap on the next sync); removed units are
13693    /// unsubscribed with their first eviction chunk in one transaction (E1–E4).
13694    /// Idempotent; re-entry cancels any deferred eviction.
13695    pub fn set_window(
13696        &mut self,
13697        base: &WindowBase,
13698        units: &[String],
13699    ) -> Result<CommandEffects, String> {
13700        let table = self
13701            .schema
13702            .table(&base.table)
13703            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
13704        if table.scope_column(&base.variable).is_none() {
13705            return Err(format!(
13706                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
13707                base.table, base.variable
13708            ));
13709        }
13710        let base_key = window_base_key(base);
13711        let wanted: std::collections::HashSet<&String> = units.iter().collect();
13712        let live = self.load_window_units(&base_key);
13713        self.begin_observation("syncular_window")?;
13714        let mut batch = ChangeAccumulator::default();
13715        let mut changed = false;
13716
13717        let prior_subs = self.subs.clone();
13718        let widened = (|| {
13719            // Widen: units wanted but not live → fresh subscription + registry row.
13720            for unit in units {
13721                if live.iter().any(|(u, _)| u == unit) {
13722                    continue;
13723                }
13724                let sub_id = derive_sub_id(base, unit);
13725                self.delete_pending_evict(&sub_id)?;
13726                self.insert_window_unit(&base_key, unit, &sub_id)?;
13727                self.subscribe(
13728                    sub_id,
13729                    base.table.clone(),
13730                    unit_scopes(base, unit),
13731                    base.params.clone(),
13732                )?;
13733                batch.window(&base_key, &base.table, unit);
13734                changed = true;
13735            }
13736
13737            self.finish_observation("syncular_window", batch)
13738        })();
13739        if let Err(error) = widened {
13740            self.rollback_observation("syncular_window");
13741            self.subs = prior_subs;
13742            return Err(error);
13743        }
13744        for (unit, sub_id) in live {
13745            if wanted.contains(&unit) {
13746                continue;
13747            }
13748            self.rebuild_overlay_if_dirty()?;
13749            let prior_subs = self.subs.clone();
13750            self.begin_observation("syncular_window")?;
13751            let effective = self
13752                .subs
13753                .iter()
13754                .find(|sub| sub.id == sub_id)
13755                .and_then(|sub| sub.effective.clone())
13756                .unwrap_or_else(|| unit_scopes(base, &unit));
13757            let mut batch = ChangeAccumulator::default();
13758            self.record_scope_map(&mut batch, &base.table, &effective);
13759            batch.window(&base_key, &base.table, &unit);
13760            let result = self.evict_unit(&base_key, base, &unit, &sub_id);
13761            let remaining = match result {
13762                Ok(remaining) => remaining,
13763                Err(error) => {
13764                    self.rollback_observation("syncular_window");
13765                    self.subs = prior_subs;
13766                    return Err(error);
13767                }
13768            };
13769            if let Err(error) = self.finish_observation("syncular_window", batch) {
13770                self.rollback_observation("syncular_window");
13771                self.subs = prior_subs;
13772                return Err(error);
13773            }
13774            if remaining {
13775                self.drain_pending_evictions()?;
13776            }
13777            changed = true;
13778        }
13779        Ok(if changed {
13780            CommandEffects::interactive()
13781        } else {
13782            CommandEffects::none()
13783        })
13784    }
13785
13786    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
13787    /// the subset still bootstrap-pending. Registration alone is not
13788    /// completeness — a unit is pending until its subscription completes a
13789    /// bootstrap round (cursor advances past -1 with no resume token held).
13790    pub fn window_state(&self, base: &WindowBase) -> WindowState {
13791        let mut units = Vec::new();
13792        let mut pending = Vec::new();
13793        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
13794            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
13795                Some(sub) => {
13796                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
13797                }
13798                None => true,
13799            };
13800            if is_pending {
13801                pending.push(unit.clone());
13802            }
13803            units.push(unit);
13804        }
13805        WindowState { units, pending }
13806    }
13807
13808    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
13809        let mut stmt = match self
13810            .conn
13811            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
13812        {
13813            Ok(stmt) => stmt,
13814            Err(_) => return Vec::new(),
13815        };
13816        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
13817            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
13818        });
13819        match rows {
13820            Ok(rows) => rows.filter_map(Result::ok).collect(),
13821            Err(_) => Vec::new(),
13822        }
13823    }
13824
13825    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
13826        let mut stmt = match self.conn.prepare(
13827            "SELECT windows.base, windows.unit, subscriptions.tbl
13828               FROM _syncular_windows AS windows
13829               JOIN _syncular_subscriptions AS subscriptions
13830                 ON subscriptions.id = windows.sub_id
13831               ORDER BY windows.base, windows.unit",
13832        ) {
13833            Ok(stmt) => stmt,
13834            Err(_) => return Vec::new(),
13835        };
13836        let rows = stmt.query_map([], |row| {
13837            Ok((
13838                row.get::<_, String>(0)?,
13839                row.get::<_, String>(1)?,
13840                row.get::<_, String>(2)?,
13841            ))
13842        });
13843        match rows {
13844            Ok(rows) => rows.filter_map(Result::ok).collect(),
13845            Err(_) => Vec::new(),
13846        }
13847    }
13848
13849    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
13850        self.conn
13851            .query_row(
13852                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
13853                rusqlite::params![sub_id],
13854                |row| Ok((row.get(0)?, row.get(1)?)),
13855            )
13856            .ok()
13857    }
13858
13859    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
13860        self.conn
13861            .execute(
13862                "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
13863                rusqlite::params![base_key, unit, sub_id],
13864            )
13865            .map(|_| ())
13866            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13867    }
13868
13869    fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
13870        self.conn
13871            .execute(
13872                "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
13873                rusqlite::params![base_key, unit],
13874            )
13875            .map(|_| ())
13876            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13877    }
13878
13879    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
13880    /// Deletes the unit's rows except those pinned by a pending outbox
13881    /// commit (E1); records a deferred eviction if any pin remains; discards
13882    /// the subscription's cursor/resume/effective-echo (E3) and version
13883    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
13884    /// nothing.
13885    fn evict_unit(
13886        &mut self,
13887        base_key: &str,
13888        base: &WindowBase,
13889        unit: &str,
13890        sub_id: &str,
13891    ) -> Result<bool, String> {
13892        let effective = self
13893            .subs
13894            .iter()
13895            .find(|s| s.id == sub_id)
13896            .and_then(|s| s.effective.clone())
13897            .unwrap_or_else(|| unit_scopes(base, unit));
13898        let pinned = self.pinned_row_ids(&base.table);
13899        let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
13900        self.delete_window_unit(base_key, unit)?;
13901        self.conn
13902            .execute(
13903                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
13904                [sub_id],
13905            )
13906            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13907        self.subs.retain(|sub| sub.id != sub_id);
13908        if deferred {
13909            self.save_pending_evict(sub_id, &base.table, &effective)?;
13910        } else {
13911            self.delete_pending_evict(sub_id)?;
13912        }
13913        Ok(remaining)
13914    }
13915
13916    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
13917    /// primary keys. Returns (more unpinned work, any scoped rows remain).
13918    /// A missing scope-column mapping fails without deleting rows.
13919    fn evict_scope_rows(
13920        &self,
13921        table_name: &str,
13922        effective: &[(String, Vec<String>)],
13923        pinned: &std::collections::HashSet<String>,
13924    ) -> Result<(bool, bool), String> {
13925        if effective.is_empty() {
13926            return Ok((false, false));
13927        }
13928        let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
13929        let mut clauses = Vec::new();
13930        let mut params: Vec<SqlValue> = Vec::new();
13931        for (variable, values) in effective {
13932            let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
13933            if values.is_empty() {
13934                return Ok((false, false));
13935            }
13936            let holes = values
13937                .iter()
13938                .map(|v| {
13939                    params.push(SqlValue::Text(v.clone()));
13940                    "?"
13941                })
13942                .collect::<Vec<_>>()
13943                .join(", ");
13944            clauses.push(format!("{} IN ({holes})", quote_ident(column)));
13945        }
13946        let scope = clauses.join(" AND ");
13947        let scope_params = params.len();
13948        let pk = quote_ident(&table.primary_key);
13949        if !pinned.is_empty() {
13950            let holes = pinned
13951                .iter()
13952                .map(|id| {
13953                    params.push(SqlValue::Text(id.clone()));
13954                    "?"
13955                })
13956                .collect::<Vec<_>>()
13957                .join(", ");
13958            clauses.push(format!("{pk} NOT IN ({holes})"));
13959        }
13960        let predicate = clauses.join(" AND ");
13961        let base = base_table(table_name);
13962        let visible = visible_table(table_name);
13963        let mut ids: Vec<SqlValue> = Vec::new();
13964        for source in [&base, &visible] {
13965            let mut stmt = self
13966                .conn
13967                .prepare_cached(&format!(
13968                    "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
13969                ))
13970                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13971            ids = stmt
13972                .query_map(rusqlite::params_from_iter(&params), |row| row.get(0))
13973                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13974                .collect::<Result<_, _>>()
13975                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13976            if !ids.is_empty() {
13977                break;
13978            }
13979        }
13980        if !ids.is_empty() {
13981            for id in &ids {
13982                self.conn
13983                    .execute(
13984                        "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
13985                        rusqlite::params![table_name, id],
13986                    )
13987                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13988            }
13989            let holes = vec!["?"; ids.len()].join(", ");
13990            for source in [&base, &visible] {
13991                self.conn
13992                    .execute(
13993                        &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
13994                        rusqlite::params_from_iter(&ids),
13995                    )
13996                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13997            }
13998        }
13999        let mut remaining = false;
14000        let mut deferred = false;
14001        for source in [&base, &visible] {
14002            remaining |= self
14003                .conn
14004                .query_row(
14005                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
14006                    rusqlite::params_from_iter(&params),
14007                    |row| row.get::<_, bool>(0),
14008                )
14009                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14010            deferred |= self
14011                .conn
14012                .query_row(
14013                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
14014                    rusqlite::params_from_iter(&params[..scope_params]),
14015                    |row| row.get::<_, bool>(0),
14016                )
14017                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14018        }
14019        Ok((remaining, deferred))
14020    }
14021
14022    /// Resume committed eviction chunks, retaining outbox pins and their durable marker.
14023    fn drain_pending_evictions(&mut self) -> Result<(), String> {
14024        let pending = self.load_pending_evictions()?;
14025        if pending.is_empty() {
14026            return Ok(());
14027        }
14028        self.rebuild_overlay_if_dirty()?;
14029        for (sub_id, table_name, effective) in pending {
14030            if self.schema.table(&table_name).is_none() {
14031                self.delete_pending_evict(&sub_id)?;
14032                continue;
14033            }
14034            loop {
14035                std::thread::yield_now();
14036                self.begin_observation("syncular_evict")?;
14037                let pinned = self.pinned_row_ids(&table_name);
14038                let result = self.evict_scope_rows(&table_name, &effective, &pinned);
14039                let (remaining, deferred) = match result {
14040                    Ok(result) => result,
14041                    Err(error) => {
14042                        self.rollback_observation("syncular_evict");
14043                        return Err(error);
14044                    }
14045                };
14046                if !deferred {
14047                    if let Err(error) = self.delete_pending_evict(&sub_id) {
14048                        self.rollback_observation("syncular_evict");
14049                        return Err(error);
14050                    }
14051                }
14052                let mut batch = ChangeAccumulator::default();
14053                self.record_scope_map(&mut batch, &table_name, &effective);
14054                if let Err(error) = self.finish_observation("syncular_evict", batch) {
14055                    self.rollback_observation("syncular_evict");
14056                    return Err(error);
14057                }
14058                if !remaining {
14059                    break;
14060                }
14061            }
14062        }
14063        Ok(())
14064    }
14065
14066    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
14067    /// commit — rows that MUST NOT be evicted until the commit drains.
14068    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
14069        let mut pinned = std::collections::HashSet::new();
14070        for commit in self
14071            .outbox
14072            .iter()
14073            .chain(self.failed_commits.iter().map(|(commit, _)| commit))
14074        {
14075            for op in &commit.ops {
14076                if op.table == table {
14077                    pinned.insert(op.row_id.clone());
14078                }
14079            }
14080        }
14081        pinned
14082    }
14083
14084    fn save_pending_evict(
14085        &self,
14086        sub_id: &str,
14087        table: &str,
14088        effective: &[(String, Vec<String>)],
14089    ) -> Result<(), String> {
14090        self.conn.execute(
14091            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
14092            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
14093        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14094    }
14095
14096    fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
14097        self.conn
14098            .execute(
14099                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
14100                rusqlite::params![sub_id],
14101            )
14102            .map(|_| ())
14103            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14104    }
14105
14106    fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
14107        let mut stmt = self
14108            .conn
14109            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
14110            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14111        let rows = stmt
14112            .query_map([], |row| {
14113                Ok((
14114                    row.get::<_, String>(0)?,
14115                    row.get::<_, String>(1)?,
14116                    row.get::<_, String>(2)?,
14117                ))
14118            })
14119            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14120        rows.map(|row| {
14121            let (sub_id, table, json) =
14122                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14123            let value: Value = serde_json::from_str(&json)
14124                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
14125            let effective = json_to_scope_map(&value)
14126                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
14127            Ok((sub_id, table, effective))
14128        })
14129        .collect()
14130    }
14131
14132    /// Select failed-intent retention; reload durable retained aggregates.
14133    pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
14134        self.retain_failed_commits = enabled;
14135        for (commit, _) in &self.failed_commits {
14136            for operation in &commit.ops {
14137                self.overlay_dirty.table(&operation.table);
14138            }
14139        }
14140        self.failed_commits = self.load_failed_commits()?;
14141        for (commit, _) in &self.failed_commits {
14142            for operation in &commit.ops {
14143                self.overlay_dirty.table(&operation.table);
14144            }
14145        }
14146        Ok(())
14147    }
14148
14149    fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
14150        let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
14151            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14152        let rows = statement
14153            .query_map([], |row| {
14154                Ok((
14155                    row.get::<_, String>(0)?,
14156                    row.get::<_, String>(1)?,
14157                    row.get::<_, String>(2)?,
14158                ))
14159            })
14160            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14161        rows.map(|row| {
14162            let (id, operations, initial) =
14163                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14164            let operations: Vec<CommitOperation> =
14165                serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
14166            let initial =
14167                serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
14168            Ok((
14169                OutboxCommit {
14170                    client_commit_id: id,
14171                    ops: operations
14172                        .into_iter()
14173                        .map(|op| OutboxOp {
14174                            upsert: op.op == "upsert",
14175                            table: op.table,
14176                            row_id: op.row_id,
14177                            base_version: op.base_version,
14178                            values: op.values,
14179                        })
14180                        .collect(),
14181                },
14182                initial,
14183            ))
14184        })
14185        .collect()
14186    }
14187
14188    fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
14189        let mut initial = Map::new();
14190        for operation in operations {
14191            let table = self
14192                .schema
14193                .table(&operation.table)
14194                .ok_or_else(|| "sync.unknown_table".to_owned())?;
14195            let initial_row = match self.stored_row(table, &operation.row_id, true)? {
14196                Some(row) => Some(row),
14197                None => self.visible_row(table, &operation.row_id)?,
14198            };
14199            if let Some((values, _)) = initial_row {
14200                let mut intended: Map<String, Value> = table
14201                    .columns
14202                    .iter()
14203                    .zip(values.iter())
14204                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
14205                    .collect();
14206                if let Some(patch) = &operation.values {
14207                    intended.extend(patch.clone());
14208                }
14209                initial.insert(
14210                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14211                        .map_err(|_| "sync.local_corrupt".to_owned())?,
14212                    Value::Object(intended),
14213                );
14214            }
14215        }
14216        self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
14217            serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
14218            serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
14219            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14220        self.failed_commits.push((
14221            OutboxCommit {
14222                client_commit_id: id.to_owned(),
14223                ops: operations.to_vec(),
14224            },
14225            initial,
14226        ));
14227        Ok(())
14228    }
14229
14230    fn drop_failed_commits(
14231        &mut self,
14232        matches: impl Fn(&str, &Map<String, Value>) -> bool,
14233    ) -> Result<Vec<OutboxCommit>, String> {
14234        let mut doomed = self.drop_acknowledged_rows(&matches)?;
14235        for (commit, initial) in &self.failed_commits {
14236            for operation in &commit.ops {
14237                let key =
14238                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14239                        .map_err(|_| "sync.local_corrupt".to_owned())?;
14240                if initial
14241                    .get(&key)
14242                    .and_then(Value::as_object)
14243                    .is_some_and(|values| matches(&operation.table, values))
14244                {
14245                    doomed.push(commit.clone());
14246                    break;
14247                }
14248            }
14249        }
14250        for commit in &doomed {
14251            self.conn
14252                .execute(
14253                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
14254                    rusqlite::params![commit.client_commit_id],
14255                )
14256                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14257        }
14258        for commit in &doomed {
14259            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))?;
14260        }
14261        self.conflicts.retain(|record| {
14262            !doomed
14263                .iter()
14264                .any(|commit| commit.client_commit_id == record.client_commit_id)
14265        });
14266        self.rejections.retain(|record| {
14267            !doomed
14268                .iter()
14269                .any(|commit| commit.client_commit_id == record.client_commit_id)
14270        });
14271        self.failed_commits.retain(|(commit, _)| {
14272            !doomed
14273                .iter()
14274                .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
14275        });
14276        for commit in &doomed {
14277            for operation in &commit.ops {
14278                self.overlay_dirty.table(&operation.table);
14279            }
14280        }
14281        Ok(doomed)
14282    }
14283
14284    /// Record one commit of local mutations. The returned commit ID is the
14285    /// §2.3 idempotency key. Failures are structured so callers read
14286    /// `code`/`message`/`details` instead of parsing a string prefix.
14287    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, ClientError> {
14288        let prior = self.storage_failure.borrow_mut().take();
14289        let outcome = (|| -> Result<String, String> {
14290            if mutations.is_empty() {
14291                return Err(
14292                    "sync.invalid_request: a commit must contain at least one operation (§6.1)"
14293                        .to_owned(),
14294                );
14295            }
14296            let mut ops = Vec::with_capacity(mutations.len());
14297            for mutation in mutations {
14298                match mutation {
14299                    Mutation::Patch {
14300                        table,
14301                        values,
14302                        base_version,
14303                    } => {
14304                        let schema_table = self
14305                            .schema
14306                            .table(&table)
14307                            .ok_or_else(|| "sync.unknown_table".to_owned())?;
14308                        let values = normalize_values_casing(schema_table, values)?;
14309                        let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
14310                        ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
14311                    }
14312                    Mutation::Upsert {
14313                        table,
14314                        values,
14315                        base_version,
14316                    } => {
14317                        let schema_table = self.schema.table(&table).ok_or_else(|| {
14318                            format!("sync.unknown_table: unknown table {table:?}")
14319                        })?;
14320                        // §5: value keys are accepted in snake_case AND the
14321                        // generated row types' camelCase; normalize to SQL truth
14322                        // before the pk lookup / codec see them.
14323                        let values = normalize_values_casing(schema_table, values)?;
14324                        let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
14325                        // §6.7 `mutate` marks every column present: fill missing
14326                        // nullable columns with NULL before the payload is built.
14327                        let values = full_row_values(schema_table, values)?;
14328                        // §5.11: validate the payload encodes with the current
14329                        // codec. A key-selection or unknown-key failure is a
14330                        // durable rejection at the push seam (§10.3), never an
14331                        // author-time error. Full-row `mutate` presents every
14332                        // column, so no stored fallback is needed.
14333                        self.validate_author_encode(schema_table, &row_id, &values)?;
14334                        ops.push(OutboxOp {
14335                            upsert: true,
14336                            table,
14337                            row_id,
14338                            base_version,
14339                            values: Some(values),
14340                        });
14341                    }
14342                    Mutation::Delete {
14343                        table,
14344                        row_id,
14345                        base_version,
14346                    } => {
14347                        if self.schema.table(&table).is_none() {
14348                            return Err(format!("sync.unknown_table: unknown table {table:?}"));
14349                        }
14350                        ops.push(OutboxOp {
14351                            upsert: false,
14352                            table,
14353                            row_id,
14354                            base_version,
14355                            values: None,
14356                        });
14357                    }
14358                }
14359            }
14360            self.record_outbox_commit(ops)
14361        })();
14362        self.finish_authoring(prior, outcome)
14363    }
14364
14365    /// Scope the retained `storage_failure` diagnostic to this call so an
14366    /// earlier operation's failure cannot classify a new one, and surface the
14367    /// call's own classified failure with its details. A fresh classified
14368    /// failure replaces the retained diagnostic; otherwise the earlier one is
14369    /// kept for the next sync.
14370    fn finish_authoring(
14371        &self,
14372        prior: Option<QueryReadFailure>,
14373        outcome: Result<String, String>,
14374    ) -> Result<String, ClientError> {
14375        let fresh = self.storage_failure.borrow_mut().take();
14376        *self.storage_failure.borrow_mut() = fresh.clone().or(prior);
14377        match outcome {
14378            Ok(id) => Ok(id),
14379            Err(message) => Err(fresh
14380                .map(ClientError::from)
14381                .unwrap_or_else(|| ClientError::from(message))),
14382        }
14383    }
14384
14385    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
14386        let commit = OutboxCommit {
14387            client_commit_id: uuid::Uuid::new_v4().to_string(),
14388            ops,
14389        };
14390        self.begin_observation("syncular_mutation")?;
14391        let mut batch = ChangeAccumulator::default();
14392        for op in &commit.ops {
14393            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
14394            if let Some(values) = &op.values {
14395                if let Some(table) = self.schema.table(&op.table) {
14396                    for scope in &table.scope_variables {
14397                        if let Some(Value::String(value)) = values.get(&scope.column) {
14398                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
14399                            precise = true;
14400                        }
14401                    }
14402                }
14403            }
14404            if !precise {
14405                batch.table(&op.table);
14406            }
14407        }
14408        if let Err(error) = self.persist_outbox_insert(&commit) {
14409            self.rollback_observation("syncular_mutation");
14410            return Err(error);
14411        }
14412        let was_dirty = self.overlay_dirty.snapshot();
14413        // A clean overlay contains the FIFO fold of every previous commit, so
14414        // the new commit applies over it. §7.1: an operation that does not
14415        // apply (a secondary unique collision) fails the whole commit; the
14416        // savepoint rollback removes the outbox entry and every visible write.
14417        if let Err(error) = self.rebuild_overlay_if_dirty() {
14418            self.rollback_observation("syncular_mutation");
14419            self.overlay_dirty.restore(was_dirty.clone());
14420            return Err(error);
14421        }
14422        if let Some(error) = commit
14423            .ops
14424            .iter()
14425            .find_map(|op| self.apply_outbox_op(op).err())
14426        {
14427            self.rollback_observation("syncular_mutation");
14428            self.overlay_dirty.restore(was_dirty.clone());
14429            return Err(error);
14430        }
14431        let id = commit.client_commit_id.clone();
14432        self.outbox.push(commit);
14433        batch.status = true;
14434        if let Err(error) = self.finish_observation("syncular_mutation", batch) {
14435            self.rollback_observation("syncular_mutation");
14436            self.outbox.pop();
14437            self.overlay_dirty.restore(was_dirty.clone());
14438            return Err(error);
14439        }
14440        Ok(id)
14441    }
14442
14443    /// Record one §6.1 sparse upsert: the primary key plus the supplied
14444    /// non-scope columns are present; every other column is absent and stays
14445    /// untouched on the server and in the local overlay. An absent local base
14446    /// refuses the whole batch with sync.row_missing before enqueueing (§7.1).
14447    /// Failures are structured so callers read `code`/`message`/`details`.
14448    pub fn patch(
14449        &mut self,
14450        table: &str,
14451        row_id: &str,
14452        partial: Map<String, Value>,
14453        base_version: Option<i64>,
14454    ) -> Result<String, ClientError> {
14455        let prior = self.storage_failure.borrow_mut().take();
14456        let outcome = self.perform_patch(table, row_id, partial, base_version);
14457        self.finish_authoring(prior, outcome)
14458    }
14459
14460    /// The unscoped patch body. Public authoring entry points scope
14461    /// `storage_failure` around the whole operation, including any pre-read.
14462    fn perform_patch(
14463        &mut self,
14464        table: &str,
14465        row_id: &str,
14466        partial: Map<String, Value>,
14467        base_version: Option<i64>,
14468    ) -> Result<String, String> {
14469        let operation = self.prepare_patch(table, row_id, partial, base_version)?;
14470        self.record_outbox_commit(vec![operation])
14471    }
14472
14473    fn prepare_patch(
14474        &self,
14475        table: &str,
14476        row_id: &str,
14477        partial: Map<String, Value>,
14478        base_version: Option<i64>,
14479    ) -> Result<OutboxOp, String> {
14480        let schema_table = self
14481            .schema
14482            .table(table)
14483            .ok_or_else(|| format!("sync.unknown_table: unknown table {table:?}"))?;
14484        let partial = normalize_values_casing(schema_table, partial)?;
14485        if let Some(pk) = partial.get(&schema_table.primary_key) {
14486            if render_row_id_json(Some(pk)).ok().as_deref() != Some(row_id) {
14487                return Err(format!(
14488                    "sync.invalid_request: table {table:?}: patch cannot change the primary key"
14489                ));
14490            }
14491        }
14492        // Existence reads only the key. Locked encrypted values stay opaque;
14493        // decoding the complete row here would reject a valid plain patch.
14494        let exists: bool = self
14495            .conn
14496            .query_row(
14497                &format!(
14498                    "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
14499                    visible_table(table),
14500                    row_id_predicate(schema_table)
14501                ),
14502                rusqlite::params![row_id],
14503                |row| row.get(0),
14504            )
14505            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14506        if !exists {
14507            return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
14508        }
14509        // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
14510        // server accepts a present scope column whose value equals the
14511        // stored row and applies it as a no-op, so a decoded envelope
14512        // round-trips. The local row is the only value the client can prove
14513        // equality against; when the row is absent locally the client cannot
14514        // prove equality against the server's stored row and fails closed.
14515        // A primary key that is also a scope column needs no stored-row
14516        // read: the primary key in a sparse payload is by construction the
14517        // row id being patched (§6.1), so its value is proven equal already.
14518        for scope in &schema_table.scope_variables {
14519            if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
14520                continue;
14521            }
14522            let Some(column) = schema_table
14523                .columns
14524                .iter()
14525                .find(|column| column.name == scope.column)
14526            else {
14527                continue;
14528            };
14529            let stored = self
14530                .conn
14531                .query_row(
14532                    &format!(
14533                        "SELECT {} FROM {} WHERE {}",
14534                        quote_ident(&scope.column),
14535                        visible_table(&schema_table.name),
14536                        row_id_predicate(schema_table)
14537                    ),
14538                    rusqlite::params![row_id],
14539                    |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
14540                )
14541                .optional()
14542                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14543            // Decode the supplied value through the same column-type path the
14544            // rest of the patch encoding uses, so a value that only equals
14545            // the stored one in another number representation still compares
14546            // equal.
14547            let incoming = json_to_column_value(column, partial.get(&scope.column))
14548                .ok()
14549                .map(|value| column_value_to_json(&value));
14550            // A stored blob never equals a patch value, matching the server's
14551            // scope-column comparison (§3.4): bytes are unstorable as scope
14552            // values.
14553            let equal = stored.as_ref().is_some_and(|stored| {
14554                stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
14555            });
14556            if !equal {
14557                return Err(format!(
14558                    "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
14559                    scope.column
14560                ));
14561            }
14562        }
14563        let mut values = partial;
14564        // Presence-set semantics: a proven-equal scope column is dropped, so
14565        // the patch leaves the stored value untouched (§6.2).
14566        for scope in &schema_table.scope_variables {
14567            values.remove(&scope.column);
14568        }
14569        let pk_value = match schema_table.columns[schema_table.pk_index].ty {
14570            // §2.4: the rowId of an integer key is its canonical decimal form.
14571            ColumnType::Integer => match row_id.parse::<i64>() {
14572                Ok(v)
14573                    if v.to_string() == row_id
14574                        && (-ssp2::primitives::I64_SAFE_MAX..=ssp2::primitives::I64_SAFE_MAX)
14575                            .contains(&v) =>
14576                {
14577                    Value::from(v)
14578                }
14579                _ => {
14580                    return Err(format!(
14581                    "sync.invalid_request: table {table:?}: invalid integer primary key {row_id:?}"
14582                ))
14583                }
14584            },
14585            ColumnType::Boolean => match row_id {
14586                "true" => Value::Bool(true),
14587                "false" => Value::Bool(false),
14588                _ => {
14589                    return Err(format!(
14590                    "sync.invalid_request: table {table:?}: invalid boolean primary key {row_id:?}"
14591                ))
14592                }
14593            },
14594            _ => Value::from(row_id.to_owned()),
14595        };
14596        values.insert(schema_table.primary_key.clone(), pk_value);
14597        // §5.11: structural validation only; an absent key-id selector is
14598        // resolved at the push seam from the stored local row, and a
14599        // key-selection or unknown-key failure becomes a durable
14600        // `client.encrypt_failed` rejection there (§10.3), never an
14601        // author-time error.
14602        self.validate_author_encode(schema_table, row_id, &values)?;
14603        Ok(OutboxOp {
14604            upsert: true,
14605            table: table.to_owned(),
14606            row_id: row_id.to_owned(),
14607            base_version,
14608            values: Some(values),
14609        })
14610    }
14611
14612    /// §5.11 key-id fallback: the stored local row's selector value as a
14613    /// positional row (only the selector slot filled). Returns None when no
14614    /// selector is configured, the row is locally absent, or the stored
14615    /// value is not a usable key id.
14616    fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
14617        let selector = self.encryption.key_id_columns.get(&table.name)?;
14618        let index = table
14619            .columns
14620            .iter()
14621            .position(|column| &column.name == selector)?;
14622        let sql = format!(
14623            "SELECT {} FROM {} WHERE {}",
14624            quote_ident(selector),
14625            visible_table(&table.name),
14626            row_id_predicate(table)
14627        );
14628        let key: Option<String> = self
14629            .conn
14630            .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
14631            .ok()?;
14632        let key = key.filter(|key| !key.is_empty())?;
14633        let mut fallback: Row = vec![None; table.columns.len()];
14634        fallback[index] = Some(ColumnValue::String(key));
14635        Some(fallback)
14636    }
14637
14638    /// §5.11/§10.3 author-time encode check: a structural failure (a
14639    /// non-nullable NULL, a value the codec rejects) is an author-time
14640    /// error; a key-selection or unknown-key failure is deferred to the push
14641    /// seam, where it becomes a durable `client.encrypt_failed` rejection.
14642    fn validate_author_encode(
14643        &self,
14644        table: &TableSchema,
14645        row_id: &str,
14646        values: &Map<String, Value>,
14647    ) -> Result<(), String> {
14648        match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
14649            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
14650            result => result.map(|_| ()),
14651        }
14652    }
14653
14654    /// §5.11/§10.3: drop every pending commit whose push encode fails with
14655    /// `client.encrypt_failed` (an unresolvable key id or an unknown key) and
14656    /// raise one durable rejection per commit. `sync()` never aborts for an
14657    /// encode failure. Mirrors §7.4.4's `drop_incompatible_outbox`, including
14658    /// undoing the dropped commit's purely-optimistic rows.
14659    /// §7.1/§7.4.4: drop pending commits whose values the current codec refuses
14660    /// (an envelope or a type an earlier version accepted). `with_encryption`
14661    /// additionally drops commits whose §5.11 encryption cannot resolve a key;
14662    /// the startup boundary leaves those to the send seam, where hosts configure
14663    /// keys after open.
14664    /// Recovery runs as one observation. An enclosing observation (the guarded
14665    /// schema reset) passes its `batch`, so the rejection, outcome, and table
14666    /// domains join that change event instead of publishing a second one.
14667    fn drop_unencodable_outbox(
14668        &mut self,
14669        with_encryption: bool,
14670        batch: Option<&mut ChangeAccumulator>,
14671    ) -> Result<bool, String> {
14672        let failures = self
14673            .outbox
14674            .iter()
14675            .filter_map(|commit| {
14676                commit
14677                    .ops
14678                    .iter()
14679                    .find_map(|op| {
14680                        let values = op.values.as_ref().filter(|_| op.upsert)?;
14681                        let table = self.schema.table(&op.table)?;
14682                        let fallback = self.stored_key_fallback(table, &op.row_id);
14683                        match encode_sparse_row_json(
14684                            table,
14685                            &op.row_id,
14686                            values,
14687                            &self.encryption,
14688                            fallback.as_ref(),
14689                        ) {
14690                            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => {
14691                                with_encryption.then_some((error, ENCRYPT_FAILED_CODE))
14692                            }
14693                            // §7.1/§7.4.4: a value the current codec refuses was
14694                            // persisted by a version that accepted it; it leaves
14695                            // through the incompatible-outbox path, so the FIFO
14696                            // queue never wedges.
14697                            Err(error) if error.starts_with(INVALID_REQUEST_CODE) => Some((
14698                                LEGACY_VALUE_REFUSAL_MESSAGE.to_owned(),
14699                                OUTBOX_INCOMPATIBLE_CODE,
14700                            )),
14701                            _ => None,
14702                        }
14703                    })
14704                    .map(|(error, code)| (commit.clone(), error, code))
14705            })
14706            .collect::<Vec<_>>();
14707        if failures.is_empty() {
14708            return Ok(false);
14709        }
14710        // §7.4.4: one observation covers the durable outcome writes, the outbox
14711        // removal, the in-memory drop, and the overlay rebuild, so a storage fault
14712        // leaves the replica and the client unchanged, a successful recovery
14713        // publishes one batch with the rejection, outcome, and table domains, and
14714        // an ordinary open with nothing to recover publishes nothing.
14715        let prior_outbox = self.outbox.clone();
14716        let prior_rejections = self.rejections.clone();
14717        // §5.11 retention and a self-published observation mutate two more
14718        // in-memory fields before this transaction ends, so the rollback below
14719        // restores them with the rest.
14720        let prior_failed_commits = self.failed_commits.clone();
14721        let prior_changes = self.change_queue.len();
14722        let prior_intents = self.sync_intent_queue.len();
14723        let prior_last_change = self.last_change.clone();
14724        let prior_overlay_dirty = self.overlay_dirty.snapshot();
14725        let owns_observation = batch.is_none();
14726        let owns_transaction = self.conn.is_autocommit();
14727        let mut domains = ChangeAccumulator::default();
14728        if owns_observation {
14729            self.begin_observation("syncular_value_recovery")?;
14730        }
14731        let dropped = (|| -> Result<(), String> {
14732            for (commit, message, code) in failures {
14733                if self.retain_failed_commits && code == ENCRYPT_FAILED_CODE {
14734                    self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
14735                }
14736                let representable = commit.ops.iter().all(Self::outbox_op_is_journalable);
14737                let rejection = RejectionRecord {
14738                    client_commit_id: commit.client_commit_id.clone(),
14739                    op_index: 0,
14740                    code: code.to_owned(),
14741                    message,
14742                    retryable: false,
14743                    details: (code == OUTBOX_INCOMPATIBLE_CODE).then(|| RejectionDetails {
14744                        field_paths: None,
14745                        reason: Some(INVALID_STORED_VALUES_REASON.to_owned()),
14746                        required_action: None,
14747                        references: None,
14748                    }),
14749                    operation: representable
14750                        .then(|| commit.ops.first())
14751                        .flatten()
14752                        .map(CommitOperation::from),
14753                };
14754                self.persist_commit_outcome(
14755                    &commit.client_commit_id,
14756                    CommitOutcomeStatus::Rejected,
14757                    &[CommitOperationOutcome::Error {
14758                        rejection: rejection.clone(),
14759                    }],
14760                    representable.then_some(commit.ops.as_slice()),
14761                )?;
14762                self.delete_outbox_persisted(&commit.client_commit_id)?;
14763                self.outbox
14764                    .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
14765                self.rejections.push(rejection);
14766                for operation in &commit.ops {
14767                    domains.table(operation.table.as_str());
14768                }
14769            }
14770            domains.status = true;
14771            domains.rejections = true;
14772            domains.outcomes = true;
14773            self.prune_commit_outcomes()?;
14774            // The visible projection is re-derived from the surviving base + outbox.
14775            self.overlay_dirty.set(true);
14776            self.rebuild_overlay()?;
14777            Ok(())
14778        })();
14779        let result = match batch {
14780            Some(batch) => {
14781                if dropped.is_ok() {
14782                    for table in domains.tables.keys() {
14783                        batch.table(table.as_str());
14784                    }
14785                    batch.status |= domains.status;
14786                    batch.rejections |= domains.rejections;
14787                    batch.outcomes |= domains.outcomes;
14788                }
14789                dropped
14790            }
14791            None => {
14792                dropped.and_then(|()| self.finish_observation("syncular_value_recovery", domains))
14793            }
14794        };
14795        if let Err(error) = result {
14796            if owns_observation {
14797                if owns_transaction {
14798                    if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
14799                        let mut failure = self.storage_failure.borrow_mut();
14800                        let failure =
14801                            failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
14802                        failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
14803                    }
14804                } else {
14805                    self.rollback_observation("syncular_value_recovery");
14806                }
14807            }
14808            self.outbox = prior_outbox;
14809            self.rejections = prior_rejections;
14810            self.failed_commits = prior_failed_commits;
14811            self.change_queue.truncate(prior_changes);
14812            self.sync_intent_queue.truncate(prior_intents);
14813            self.last_change = prior_last_change;
14814            self.overlay_dirty.restore(prior_overlay_dirty);
14815            return Err(error);
14816        }
14817        Ok(true)
14818    }
14819
14820    pub fn pending_commit_ids(&self) -> Vec<String> {
14821        self.outbox
14822            .iter()
14823            .map(|c| c.client_commit_id.clone())
14824            .collect()
14825    }
14826
14827    /// §6.1: the sparse push payload bytes of every pending upsert operation,
14828    /// FIFO — exactly what the next push round would put on the wire. Used by
14829    /// conformance B.21(h) to pin cross-core byte identity.
14830    pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
14831        let mut payloads = Vec::new();
14832        for commit in &self.outbox {
14833            for op in &commit.ops {
14834                let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
14835                    continue;
14836                };
14837                let Some(table) = self.schema.table(&op.table) else {
14838                    continue;
14839                };
14840                let fallback = self.stored_key_fallback(table, &op.row_id);
14841                if let Ok(payload) = encode_sparse_row_json(
14842                    table,
14843                    &op.row_id,
14844                    values,
14845                    &self.encryption,
14846                    fallback.as_ref(),
14847                ) {
14848                    payloads.push(payload);
14849                }
14850            }
14851        }
14852        payloads
14853    }
14854
14855    pub fn conflicts(&self) -> &[ConflictRecord] {
14856        &self.conflicts
14857    }
14858
14859    pub fn rejections(&self) -> &[RejectionRecord] {
14860        &self.rejections
14861    }
14862
14863    pub fn resolve_commit_outcome(
14864        &mut self,
14865        input: ResolveCommitOutcomeInput,
14866    ) -> Result<CommitOutcome, String> {
14867        let current = self
14868            .commit_outcome(&input.client_commit_id)?
14869            .ok_or_else(|| {
14870                format!(
14871                    "sync.outcome_not_found: no durable outcome exists for {:?}",
14872                    input.client_commit_id
14873                )
14874            })?;
14875        if current.resolution != CommitOutcomeResolution::Active {
14876            return Ok(current);
14877        }
14878        if input.resolution == CommitOutcomeResolution::Active {
14879            return Err("sync.invalid_request: resolution must leave active state".to_owned());
14880        }
14881        match input.resolution {
14882            CommitOutcomeResolution::Superseded => {
14883                let replacement = input
14884                    .replacement_client_commit_id
14885                    .as_deref()
14886                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
14887                    .ok_or_else(|| {
14888                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
14889                            .to_owned()
14890                    })?;
14891                let _ = replacement;
14892            }
14893            _ if input.replacement_client_commit_id.is_some() => {
14894                return Err(
14895                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
14896                        .to_owned(),
14897                );
14898            }
14899            _ => {}
14900        }
14901        let allowed = match current.status {
14902            CommitOutcomeStatus::Conflict => matches!(
14903                input.resolution,
14904                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
14905            ),
14906            CommitOutcomeStatus::Rejected => {
14907                matches!(
14908                    input.resolution,
14909                    CommitOutcomeResolution::Superseded
14910                        | CommitOutcomeResolution::ResolvedKeepServer
14911                )
14912            }
14913            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
14914                input.resolution == CommitOutcomeResolution::Dismissed
14915            }
14916        };
14917        if !allowed {
14918            return Err(format!(
14919                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
14920                input.resolution, current.status
14921            ));
14922        }
14923
14924        self.begin_observation("syncular_outcome_resolution")?;
14925        let previous_failed = self.failed_commits.clone();
14926        let previous_conflicts = self.conflicts.clone();
14927        let previous_rejections = self.rejections.clone();
14928        let result = (|| {
14929            self.conn
14930                .execute(
14931                    "UPDATE _syncular_commit_outcomes
14932                        SET resolution = ?1, resolved_at_ms = ?2,
14933                            replacement_client_commit_id = ?3
14934                      WHERE client_commit_id = ?4 AND resolution = 'active'",
14935                    rusqlite::params![
14936                        Self::outcome_resolution_name(input.resolution),
14937                        self.clock_now_ms(),
14938                        input.replacement_client_commit_id,
14939                        input.client_commit_id
14940                    ],
14941                )
14942                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14943            let resolved = self
14944                .commit_outcome(&current.client_commit_id)?
14945                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
14946            self.conn
14947                .execute(
14948                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
14949                    rusqlite::params![input.client_commit_id],
14950                )
14951                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14952            for (commit, _) in &self.failed_commits {
14953                if commit.client_commit_id == input.client_commit_id {
14954                    for operation in &commit.ops {
14955                        self.overlay_dirty.table(&operation.table);
14956                    }
14957                }
14958            }
14959            self.failed_commits
14960                .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
14961            self.prune_commit_outcomes()?;
14962            Ok(resolved)
14963        })();
14964        let resolved = match result {
14965            Ok(outcome) => outcome,
14966            Err(error) => {
14967                self.rollback_observation("syncular_outcome_resolution");
14968                self.failed_commits = previous_failed;
14969                self.conflicts = previous_conflicts;
14970                self.rejections = previous_rejections;
14971                return Err(error);
14972            }
14973        };
14974        self.conflicts
14975            .retain(|record| record.client_commit_id != current.client_commit_id);
14976        self.rejections
14977            .retain(|record| record.client_commit_id != current.client_commit_id);
14978        let mut batch = ChangeAccumulator {
14979            conflicts: current.status == CommitOutcomeStatus::Conflict,
14980            rejections: current.status == CommitOutcomeStatus::Rejected,
14981            outcomes: true,
14982            ..ChangeAccumulator::default()
14983        };
14984        if let Some(operations) = &current.operations {
14985            for operation in operations {
14986                batch.table(&operation.table);
14987            }
14988        }
14989        if let Err(error) = self
14990            .rebuild_overlay_if_dirty()
14991            .and_then(|()| self.finish_observation("syncular_outcome_resolution", batch))
14992        {
14993            self.rollback_observation("syncular_outcome_resolution");
14994            self.failed_commits = previous_failed;
14995            self.conflicts = previous_conflicts;
14996            self.rejections = previous_rejections;
14997            return Err(error);
14998        }
14999        Ok(resolved)
15000    }
15001
15002    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
15003        self.schema_floor.as_ref()
15004    }
15005
15006    /// §7.3.5: the client's opaque auth-lease state, if any.
15007    pub fn lease_state(&self) -> Option<&LeaseState> {
15008        self.lease_state.as_ref()
15009    }
15010
15011    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
15012    /// the two lease codes set it; other errors leave leaseState untouched.
15013    fn record_lease_error(&mut self, code: &str) {
15014        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
15015            return;
15016        }
15017        let mut next = self.lease_state.clone().unwrap_or_default();
15018        next.error_code = Some(code.to_owned());
15019        self.set_lease_state(Some(next));
15020    }
15021
15022    fn set_lease_state(&mut self, next: Option<LeaseState>) {
15023        if self.lease_state == next {
15024            return;
15025        }
15026        if self.begin_observation("syncular_lease").is_err() {
15027            return;
15028        }
15029        self.lease_state = next;
15030        if let Some(lease) = &self.lease_state {
15031            if let Ok(json) = serde_json::to_string(lease) {
15032                self.set_meta(LEASE_STATE_KEY, &json);
15033            }
15034        } else {
15035            self.delete_meta(LEASE_STATE_KEY);
15036        }
15037        let batch = ChangeAccumulator {
15038            status: true,
15039            ..ChangeAccumulator::default()
15040        };
15041        if self.finish_observation("syncular_lease", batch).is_err() {
15042            self.rollback_observation("syncular_lease");
15043        }
15044    }
15045
15046    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
15047        if self.schema_floor == next {
15048            return;
15049        }
15050        if self.begin_observation("syncular_schema_floor").is_err() {
15051            return;
15052        }
15053        self.schema_floor = next;
15054        self.stopped = self.schema_floor.is_some();
15055        if let Some(floor) = &self.schema_floor {
15056            if let Ok(json) = serde_json::to_string(floor) {
15057                self.set_meta(SCHEMA_FLOOR_KEY, &json);
15058            }
15059        } else {
15060            self.delete_meta(SCHEMA_FLOOR_KEY);
15061        }
15062        let batch = ChangeAccumulator {
15063            status: true,
15064            ..ChangeAccumulator::default()
15065        };
15066        if self
15067            .finish_observation("syncular_schema_floor", batch)
15068            .is_err()
15069        {
15070            self.rollback_observation("syncular_schema_floor");
15071        }
15072    }
15073
15074    fn set_upgrading(&mut self, value: bool) {
15075        if self.upgrading == value {
15076            return;
15077        }
15078        if self.begin_observation("syncular_upgrading").is_err() {
15079            return;
15080        }
15081        self.upgrading = value;
15082        let batch = ChangeAccumulator {
15083            status: true,
15084            ..ChangeAccumulator::default()
15085        };
15086        if self
15087            .finish_observation("syncular_upgrading", batch)
15088            .is_err()
15089        {
15090            self.rollback_observation("syncular_upgrading");
15091        }
15092    }
15093
15094    pub fn sync_needed(&self) -> bool {
15095        self.sync_needed
15096    }
15097
15098    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
15099        let sub = self.subs.iter().find(|s| s.id == id)?;
15100        Some(SubscriptionStateView {
15101            id: sub.id.clone(),
15102            table: sub.table.clone(),
15103            status: sub.state.name().to_owned(),
15104            cursor: sub.cursor,
15105            has_resume_token: sub.bootstrap_state.is_some(),
15106            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
15107            reason_code: sub.reason_code.clone(),
15108        })
15109    }
15110
15111    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
15112        let schema_table = self
15113            .schema
15114            .table(table)
15115            .ok_or_else(|| format!("unknown table {table:?}"))?;
15116        let sql = format!(
15117            "SELECT * FROM {} ORDER BY {} ASC",
15118            visible_table(table),
15119            quote_ident(&schema_table.primary_key)
15120        );
15121        let mut stmt = self
15122            .conn
15123            .prepare(&sql)
15124            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15125        let mut rows = stmt
15126            .query([])
15127            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15128        let mut out = Vec::new();
15129        while let Some(row) = rows
15130            .next()
15131            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
15132        {
15133            let mut values = Map::new();
15134            for (i, column) in schema_table.columns.iter().enumerate() {
15135                let value = row
15136                    .get_ref(i)
15137                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15138                values.insert(column.name.clone(), sql_ref_to_json(column, value));
15139            }
15140            let version: i64 = row
15141                .get(schema_table.columns.len())
15142                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15143            let row_id = match values.get(&schema_table.primary_key) {
15144                Some(Value::String(s)) => s.clone(),
15145                Some(Value::Number(n)) => n.to_string(),
15146                Some(Value::Bool(b)) => b.to_string(),
15147                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
15148            };
15149            out.push(RowState {
15150                row_id,
15151                version,
15152                values,
15153            });
15154        }
15155        Ok(out)
15156    }
15157
15158    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
15159    //
15160    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
15161    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
15162    // the op with `yrs`, re-encodes the whole doc state, and records a
15163    // baseVersion-less sparse upsert carrying only the primary key and the
15164    // crdt column (§5.10.3, §6.6). No local merge — merging is server-side;
15165    // the overlay re-materializes the edit immediately (optimistic apply,
15166    // §7.1) and the server-merged bytes arrive on the next pull, idempotently.
15167    // Byte-compatible with `@syncular/crdt-yjs`.
15168
15169    /// The current stored value of a `crdt` column for one row — the visible
15170    /// (optimistic) bytes, or `None` when the row is absent or the column is
15171    /// NULL (the empty document, §5.10.1). Errors if the column is not a
15172    /// `crdt` column (guards the app against a typo'd column name).
15173    #[cfg(feature = "crdt-yjs")]
15174    fn crdt_column_bytes(
15175        &self,
15176        table: &str,
15177        row_id: &str,
15178        column: &str,
15179    ) -> Result<Option<Vec<u8>>, String> {
15180        let schema_table = self
15181            .schema
15182            .table(table)
15183            .ok_or_else(|| format!("unknown table {table:?}"))?;
15184        let col = schema_table
15185            .columns
15186            .iter()
15187            .find(|c| c.name == column)
15188            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
15189        if col.ty != ColumnType::Crdt {
15190            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
15191        }
15192        let sql = format!(
15193            "SELECT {} FROM {} WHERE {}",
15194            quote_ident(column),
15195            visible_table(table),
15196            row_id_predicate(schema_table)
15197        );
15198        let bytes: Option<Vec<u8>> = self
15199            .conn
15200            .prepare_cached(&sql)
15201            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
15202            .query_row(rusqlite::params![row_id], |row| {
15203                row.get::<_, Option<Vec<u8>>>(0)
15204            })
15205            .map_err(|e| match e {
15206                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
15207                other => Self::sqlite_failure(&self.storage_failure, other),
15208            })?;
15209        Ok(bytes)
15210    }
15211
15212    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
15213    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
15214    /// An absent row / NULL column is the empty document (empty string).
15215    #[cfg(feature = "crdt-yjs")]
15216    pub fn crdt_text(
15217        &self,
15218        table: &str,
15219        row_id: &str,
15220        column: &str,
15221        name: &str,
15222    ) -> Result<String, String> {
15223        let bytes = self
15224            .crdt_column_bytes(table, row_id, column)?
15225            .unwrap_or_default();
15226        crate::crdt::text(&bytes, name)
15227    }
15228
15229    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and
15230    /// record the resulting crdt-only sparse operation. Returns the enqueued
15231    /// `clientCommitId`.
15232    #[cfg(feature = "crdt-yjs")]
15233    pub fn crdt_insert_text(
15234        &mut self,
15235        table: &str,
15236        row_id: &str,
15237        column: &str,
15238        name: &str,
15239        index: u32,
15240        value: &str,
15241    ) -> Result<String, ClientError> {
15242        let prior = self.storage_failure.borrow_mut().take();
15243        let outcome = (|| -> Result<String, String> {
15244            let current = self
15245                .crdt_column_bytes(table, row_id, column)?
15246                .unwrap_or_default();
15247            let update = crate::crdt::insert_text(&current, name, index, value)?;
15248            self.perform_crdt_push_update(table, row_id, column, &update)
15249        })();
15250        self.finish_authoring(prior, outcome)
15251    }
15252
15253    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and
15254    /// record the resulting crdt-only sparse operation. Returns the enqueued
15255    /// `clientCommitId`.
15256    #[cfg(feature = "crdt-yjs")]
15257    pub fn crdt_delete_text(
15258        &mut self,
15259        table: &str,
15260        row_id: &str,
15261        column: &str,
15262        name: &str,
15263        index: u32,
15264        len: u32,
15265    ) -> Result<String, ClientError> {
15266        let prior = self.storage_failure.borrow_mut().take();
15267        let outcome = (|| -> Result<String, String> {
15268            let current = self
15269                .crdt_column_bytes(table, row_id, column)?
15270                .unwrap_or_default();
15271            let update = crate::crdt::delete_text(&current, name, index, len)?;
15272            self.perform_crdt_push_update(table, row_id, column, &update)
15273        })();
15274        self.finish_authoring(prior, outcome)
15275    }
15276
15277    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
15278    /// `crdt` column's current state and record the resulting crdt-only sparse
15279    /// operation. The app authored the update with its own `yrs` model.
15280    /// Returns the enqueued `clientCommitId`.
15281    #[cfg(feature = "crdt-yjs")]
15282    pub fn crdt_apply_update(
15283        &mut self,
15284        table: &str,
15285        row_id: &str,
15286        column: &str,
15287        update: &[u8],
15288    ) -> Result<String, ClientError> {
15289        let prior = self.storage_failure.borrow_mut().take();
15290        let outcome = (|| -> Result<String, String> {
15291            let current = self
15292                .crdt_column_bytes(table, row_id, column)?
15293                .unwrap_or_default();
15294            let next = crate::crdt::apply_update(&current, update)?;
15295            self.perform_crdt_push_update(table, row_id, column, &next)
15296        })();
15297        self.finish_authoring(prior, outcome)
15298    }
15299
15300    /// Shared tail of the crdt edit methods: record a §6.1 sparse crdt-only
15301    /// upsert — the primary key plus the crdt column, no `baseVersion` — and
15302    /// enqueue it. The server merges the crdt column and leaves every other
15303    /// column untouched (§5.10.3), so a baseVersion-less crdt edit cannot
15304    /// clobber a concurrent edit to another column. A locally absent row
15305    /// records the same partial operation; the server answers per §6.2.
15306    #[cfg(feature = "crdt-yjs")]
15307    fn perform_crdt_push_update(
15308        &mut self,
15309        table: &str,
15310        row_id: &str,
15311        column: &str,
15312        crdt_bytes: &[u8],
15313    ) -> Result<String, String> {
15314        let mut bytes_obj = Map::new();
15315        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
15316        let mut partial = Map::new();
15317        partial.insert(column.to_owned(), Value::Object(bytes_obj));
15318        self.perform_patch(table, row_id, partial, None)
15319    }
15320
15321    /// Run an arbitrary read-only SQL query against the local database and
15322    /// return each row as a `column-name → JSON value` map. This is the seam
15323    /// the React `useSyncQuery` live-query API needs (it takes app-authored
15324    /// SQL over the visible tables/views, not a fixed table read like
15325    /// [`read_rows`]).
15326    ///
15327    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
15328    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
15329    /// same envelope the command surface uses everywhere else. Output BLOB
15330    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
15331    ///
15332    /// The result column typing is dynamic (SQLite's stored affinity), because
15333    /// arbitrary SQL can alias, join, and compute — there is no schema column
15334    /// to consult per output cell, unlike [`read_rows`].
15335    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
15336        query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
15337    }
15338
15339    /// Repository benchmark and conformance access, present only with `bench-internals`.
15340    #[cfg(feature = "bench-internals")]
15341    #[doc(hidden)]
15342    pub fn benchmark_connection(&mut self) -> &mut Connection {
15343        &mut self.conn
15344    }
15345
15346    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
15347    #[cfg(feature = "bench-internals")]
15348    #[doc(hidden)]
15349    pub fn benchmark_phases(
15350        &self,
15351        enabled: Option<bool>,
15352        reset: bool,
15353    ) -> Result<Option<Value>, String> {
15354        self.benchmark_phases.configure(enabled, reset)?;
15355        Ok(self.benchmark_phases.snapshot())
15356    }
15357
15358    /// One serialized, read-only authority observation, including durable coverage.
15359    pub fn authority_snapshot(&self) -> Result<Value, String> {
15360        validate_authority_reads(&self.schema, &self.limits.authority_reads)?;
15361        if self.limits.authority_reads.is_empty() {
15362            return Err("client.authority_read_forbidden: no authority reads were declared at client creation".into());
15363        }
15364        self.conn
15365            .execute_batch("SAVEPOINT authority_snapshot")
15366            .map_err(|e| e.to_string())?;
15367        let result = (|| {
15368            let revision = self
15369                .get_meta(LOCAL_REVISION_KEY)
15370                .ok_or("sync.local_corrupt: authority revision is missing")?;
15371            let parsed_revision = revision
15372                .parse::<u64>()
15373                .map_err(|_| "sync.local_corrupt: authority revision is invalid")?;
15374            if parsed_revision.to_string() != revision {
15375                return Err("sync.local_corrupt: authority revision is invalid".into());
15376            }
15377            let mut tables = Vec::new();
15378            for read in &self.limits.authority_reads {
15379                let table = self
15380                    .schema
15381                    .table(&read.table)
15382                    .ok_or("client.authority_read_forbidden: authority table is not declared")?;
15383                let scalar = |column: &str, value: &Value| -> Result<String, String> {
15384                    let ty = table
15385                        .columns
15386                        .iter()
15387                        .find(|c| c.name == column)
15388                        .expect("validated authority column")
15389                        .ty;
15390                    if ty == ColumnType::Boolean {
15391                        if let Some(value) = value.as_bool() {
15392                            return Ok(value.to_string());
15393                        }
15394                        return match value.as_i64() {
15395                            Some(0) => Ok("false".into()),
15396                            Some(1) => Ok("true".into()),
15397                            _ => Err("sync.local_corrupt: authority boolean is invalid".into()),
15398                        };
15399                    }
15400                    match value {
15401                        Value::String(s) => Ok(s.clone()),
15402                        Value::Number(n) => Ok(n.to_string()),
15403                        Value::Object(o) if ty == ColumnType::Integer && o.len() == 1 => o
15404                            .get("$bigint")
15405                            .and_then(Value::as_str)
15406                            .map(str::to_owned)
15407                            .ok_or("sync.local_corrupt: authority integer is invalid".into()),
15408                        _ => {
15409                            Err("sync.local_corrupt: authority scalar is missing or invalid".into())
15410                        }
15411                    }
15412                };
15413                let matches = |row: &QueryRow| -> Result<bool, String> {
15414                    for (variable, values) in &read.scopes {
15415                        let column = table
15416                            .scope_column(variable)
15417                            .expect("validated authority scope");
15418                        let value = row
15419                            .get(column)
15420                            .ok_or("sync.local_corrupt: authority scope column is missing")?;
15421                        if value.is_null() || !values.contains(&scalar(column, value)?) {
15422                            return Ok(false);
15423                        }
15424                    }
15425                    Ok(true)
15426                };
15427                let selected = read
15428                    .columns
15429                    .iter()
15430                    .map(|c| quote_ident(c))
15431                    .collect::<Vec<_>>()
15432                    .join(",");
15433                let mut intent = BTreeSet::new();
15434                let mut relevant = BTreeSet::new();
15435                for row in query_connection(&self.conn,
15436                    "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=?",
15437                    &[Value::String(read.table.clone())]).map_err(|e| e.to_string())? {
15438                    let id = row.get("id").and_then(Value::as_str).ok_or("sync.local_corrupt: authority intent id is invalid")?.to_owned();
15439                    intent.insert(id.clone());
15440                    if let Some(raw) = row.get("values_json").and_then(Value::as_str) {
15441                        let values: QueryRow = serde_json::from_str(raw).map_err(|_| "sync.local_corrupt: authority intent values are invalid")?;
15442                        if read.scopes.keys().all(|v| values.contains_key(table.scope_column(v).expect("validated scope"))) && matches(&values)? {relevant.insert(id);}
15443                    }
15444                }
15445                let mut rows = BTreeMap::new();
15446                for full in [base_table(&read.table), visible_table(&read.table)] {
15447                    for mut values in query_connection(
15448                        &self.conn,
15449                        &format!(
15450                            "SELECT {selected},_syncular_version AS authority_version FROM {full}"
15451                        ),
15452                        &[],
15453                    )
15454                    .map_err(|e| e.to_string())?
15455                    {
15456                        if !matches(&values)? {
15457                            continue;
15458                        }
15459                        let id = scalar(
15460                            &table.primary_key,
15461                            values
15462                                .get(&table.primary_key)
15463                                .expect("selected primary key"),
15464                        )?;
15465                        relevant.insert(id.clone());
15466                        if full == base_table(&read.table) {
15467                            let version = values
15468                                .remove("authority_version")
15469                                .expect("selected version");
15470                            let version = version
15471                                .as_i64()
15472                                .ok_or("sync.local_corrupt: authority server version is invalid")?;
15473                            if version >= 0 {
15474                                rows.insert(id.clone(), json!({"values": values, "version": version, "hasLocalIntent": intent.contains(&id)}));
15475                            }
15476                        }
15477                    }
15478                }
15479                intent.retain(|id| relevant.contains(id));
15480                let rows = rows.into_values().collect::<Vec<_>>();
15481                let mut persisted = Vec::new();
15482                for row in query_connection(
15483                    &self.conn,
15484                    "SELECT state_json FROM _syncular_subscriptions WHERE tbl=? ORDER BY rowid",
15485                    &[Value::String(read.table.clone())],
15486                )
15487                .map_err(|e| e.message)?
15488                {
15489                    let state: Value = serde_json::from_str(
15490                        row.get("state_json")
15491                            .and_then(Value::as_str)
15492                            .ok_or("sync.local_corrupt: authority subscription state is missing")?,
15493                    )
15494                    .map_err(|_| "sync.local_corrupt: authority subscription state is invalid")?;
15495                    let effective = state.get("effectiveScopes").and_then(Value::as_object);
15496                    let status = state
15497                        .get("status")
15498                        .and_then(Value::as_str)
15499                        .ok_or("sync.local_corrupt: authority subscription status is missing")?;
15500                    let cursor = state
15501                        .get("cursor")
15502                        .and_then(Value::as_i64)
15503                        .ok_or("sync.local_corrupt: authority subscription cursor is missing")?;
15504                    let complete = effective.is_some_and(|s| s.len() == read.scopes.len())
15505                        && status == "active"
15506                        && cursor >= 0
15507                        && state.get("bootstrapState").is_none_or(Value::is_null)
15508                        && state.get("params").is_none_or(Value::is_null);
15509                    let exposed = effective.map(|s| {
15510                        read.scopes
15511                            .keys()
15512                            .map(|v| (v.clone(), s.get(v).cloned().unwrap_or(json!([]))))
15513                            .collect::<Map<_, _>>()
15514                    });
15515                    let requested = read
15516                        .scopes
15517                        .keys()
15518                        .map(|v| {
15519                            (
15520                                v.clone(),
15521                                state
15522                                    .get("requested")
15523                                    .and_then(|s| s.get(v))
15524                                    .cloned()
15525                                    .unwrap_or(json!([])),
15526                            )
15527                        })
15528                        .collect::<Map<_, _>>();
15529                    persisted.push(json!({"requestedScopes": requested, "status": status, "cursor": cursor, "effectiveScopes": exposed, "complete": complete}));
15530                }
15531                fn covered(selectors: &[(&String, &Vec<String>)], candidates: &[&Value]) -> bool {
15532                    match selectors.split_first() {
15533                        None => !candidates.is_empty(),
15534                        Some(((variable, values), rest)) => values.iter().all(|value| {
15535                            covered(
15536                                rest,
15537                                &candidates
15538                                    .iter()
15539                                    .copied()
15540                                    .filter(|p| {
15541                                        p["effectiveScopes"][variable.as_str()]
15542                                            .as_array()
15543                                            .is_some_and(|held| {
15544                                                held.contains(&Value::String(value.clone()))
15545                                                    || held.contains(&Value::String("*".into()))
15546                                            })
15547                                    })
15548                                    .collect::<Vec<_>>(),
15549                            )
15550                        }),
15551                    }
15552                }
15553                let coverage = if covered(
15554                    &read.scopes.iter().collect::<Vec<_>>(),
15555                    &persisted
15556                        .iter()
15557                        .filter(|p| p["complete"] == true)
15558                        .collect::<Vec<_>>(),
15559                ) {
15560                    "complete"
15561                } else if persisted.iter().any(|p| {
15562                    p["status"] == "active"
15563                        && read.scopes.iter().all(|(v, values)| {
15564                            values.iter().any(|value| {
15565                                p["requestedScopes"][v].as_array().is_some_and(|held| {
15566                                    held.contains(&Value::String(value.clone()))
15567                                        || held.contains(&Value::String("*".into()))
15568                                })
15569                            })
15570                        })
15571                }) {
15572                    "pending"
15573                } else {
15574                    "missing"
15575                };
15576                tables.push(json!({"table": read.table, "rows": rows, "localIntentRowIds": intent, "scopes": read.scopes, "coverage": coverage, "persisted": persisted}));
15577            }
15578            Ok(
15579                json!({"revision": revision, "complete": tables.iter().all(|t| t["coverage"] == "complete"), "tables": tables}),
15580            )
15581        })();
15582        match result {
15583            Ok(snapshot) => {
15584                self.conn
15585                    .execute_batch("RELEASE authority_snapshot")
15586                    .map_err(|e| e.to_string())?;
15587                Ok(snapshot)
15588            }
15589            Err(error) => {
15590                self.conn
15591                    .execute_batch("ROLLBACK TO authority_snapshot; RELEASE authority_snapshot")
15592                    .map_err(|e| format!("{error}; rollback failed: {e}"))?;
15593                Err(error)
15594            }
15595        }
15596    }
15597
15598    /// Rows, coverage, and local revision from one SQLite read snapshot.
15599    /// §7.5 atomic snapshot read. An `owner` records a failure in
15600    /// `diagnostics_snapshot().query_failures` until its next successful read.
15601    pub fn query_snapshot(
15602        &mut self,
15603        sql: &str,
15604        params: &[QueryValue],
15605        coverage: &[WindowCoverage],
15606        owner: Option<QueryOwner<'_>>,
15607    ) -> Result<QuerySnapshot, String> {
15608        let result = snapshot_connection(&self.conn, sql, params, coverage);
15609        if let Some(owner) = owner {
15610            self.record_query_read(owner, result.as_ref().err());
15611        }
15612        result.map_err(|failure| failure.to_string())
15613    }
15614
15615    /// §7.6: record the outcome of one owned snapshot read, including reads a
15616    /// host ran on a separate connection. A success removes the owner's entry;
15617    /// an identical failure leaves it unchanged; a changed failure moves last.
15618    pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
15619        let position = self
15620            .query_failures
15621            .iter()
15622            .position(|entry| entry.id == owner.id);
15623        let Some(failure) = failure else {
15624            if let Some(index) = position {
15625                self.query_failures.remove(index);
15626            }
15627            return;
15628        };
15629        let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
15630            .into_iter()
15631            .map(str::to_owned)
15632            .collect();
15633        let code = failure.code.unwrap_or("client.query_failed").to_owned();
15634        if position.is_some_and(|index| {
15635            let entry = &self.query_failures[index];
15636            entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
15637        }) {
15638            return;
15639        }
15640        if let Some(index) = position {
15641            self.query_failures.remove(index);
15642        }
15643        self.query_failures.push(DiagnosticQueryFailure {
15644            id: owner.id.to_owned(),
15645            tables,
15646            code,
15647            sqlite_code: failure.sqlite_code,
15648            at_ms: self.clock_now_ms(),
15649        });
15650        if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
15651            self.query_failures.remove(0);
15652        }
15653    }
15654
15655    // -- request building ---------------------------------------------------------
15656
15657    fn build_request(&self, url_capable: bool) -> Result<(Message, RequestMeta), Box<SyncOutcome>> {
15658        #[cfg(feature = "bench-internals")]
15659        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
15660        let log_epoch = self.get_meta(LOG_EPOCH_KEY);
15661        let max_commits = self.limits.max_push_commits_per_request;
15662        let max_ops = self
15663            .limits
15664            .max_push_operations_per_request
15665            .unwrap_or(PUSH_OPS_PER_REQUEST);
15666        let byte_cap = self.limits.max_push_request_bytes;
15667        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
15668        // bare URL — capability negotiation, decided per transport.
15669        let accept = self.limits.accept.unwrap_or(if url_capable {
15670            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
15671        } else {
15672            DEFAULT_ACCEPT
15673        });
15674        let header = Frame::ReqHeader {
15675            client_id: self.client_id.clone(),
15676            schema_version: self.schema.version,
15677            log_epoch: log_epoch.clone(),
15678        };
15679        let pull = Frame::PullHeader {
15680            limit_commits: self.limits.limit_commits.unwrap_or(0),
15681            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
15682            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
15683            accept,
15684        };
15685        let mut fresh = Vec::new();
15686        let mut sub_frames = Vec::new();
15687        for sub in &self.subs {
15688            if sub.state != SubState::Active {
15689                continue;
15690            }
15691            let mut scopes = sub.requested.clone();
15692            sort_scope_map(&mut scopes);
15693            sub_frames.push(Frame::Subscription {
15694                id: sub.id.clone(),
15695                table: sub.table.clone(),
15696                scopes,
15697                params: sub.params.clone().map(RawJson),
15698                cursor: sub.cursor,
15699                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
15700            });
15701            fresh.push((
15702                sub.id.clone(),
15703                sub.cursor < 0 && sub.bootstrap_state.is_none(),
15704            ));
15705        }
15706        // The fixed frames (order-independent size) and the header+pull probe
15707        // both encode canonically, so the byte budget is the exact wire size.
15708        let fixed_bytes = match byte_cap {
15709            Some(_) => {
15710                let mut frames = vec![header.clone(), pull.clone()];
15711                frames.extend(sub_frames.iter().cloned());
15712                encode_message(&Message {
15713                    wire_version: WIRE_VERSION,
15714                    msg_kind: MsgKind::Request,
15715                    frames,
15716                })
15717                .len()
15718            }
15719            None => 0,
15720        };
15721        if let Some(cap) = byte_cap {
15722            if fixed_bytes > cap {
15723                return Err(Self::push_capacity_outcome("bytes", cap, fixed_bytes, None));
15724            }
15725        }
15726        let base_bytes = match byte_cap {
15727            Some(_) => encode_message(&Message {
15728                wire_version: WIRE_VERSION,
15729                msg_kind: MsgKind::Request,
15730                frames: vec![header.clone(), pull.clone()],
15731            })
15732            .len(),
15733            None => 0,
15734        };
15735        let total_outbox = if log_epoch.is_some() {
15736            self.outbox.len()
15737        } else {
15738            0
15739        };
15740        let mut push_frames: Vec<Frame> = Vec::new();
15741        let mut pushed_ids = Vec::new();
15742        let mut ops_in_request = 0usize;
15743        let mut bytes_in_request = 0usize;
15744        let mut deferred_commits = 0usize;
15745        #[cfg(feature = "bench-internals")]
15746        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
15747        for (index, commit) in self.outbox.iter().take(total_outbox).enumerate() {
15748            // §6.1/§7.1: the commit-count and operation caps are hard. A
15749            // first commit over the operation cap is a typed capacity error
15750            // and stays queued; a later one defers with the complete suffix.
15751            if max_commits.is_some_and(|cap| push_frames.len() >= cap) {
15752                deferred_commits = total_outbox - index;
15753                break;
15754            }
15755            if ops_in_request + commit.ops.len() > max_ops {
15756                if push_frames.is_empty() {
15757                    return Err(Self::push_capacity_outcome(
15758                        "operations",
15759                        max_ops,
15760                        commit.ops.len(),
15761                        Some(commit.client_commit_id.as_str()),
15762                    ));
15763                }
15764                deferred_commits = total_outbox - index;
15765                break;
15766            }
15767            let operations = commit
15768                .ops
15769                .iter()
15770                .map(|op| {
15771                    let payload = op.values.as_ref().and_then(|values| {
15772                        let table = self.schema.table(&op.table)?;
15773                        // §0: outbox entries encode at send time with the
15774                        // current codec (validated at mutate()). §6.1: the
15775                        // values map's keys are the sparse presence set.
15776                        // §5.11: encrypted columns are encrypted here, with
15777                        // an absent key-id selector read from the stored row.
15778                        // `sync_inner` runs `drop_unencodable_outbox` first,
15779                        // so an encrypt failure cannot reach this encode; the
15780                        // author seam validated every other failure.
15781                        let fallback = self.stored_key_fallback(table, &op.row_id);
15782                        encode_sparse_row_json(
15783                            table,
15784                            &op.row_id,
15785                            values,
15786                            &self.encryption,
15787                            fallback.as_ref(),
15788                        )
15789                        .ok()
15790                    });
15791                    ssp2::model::Operation {
15792                        table: op.table.clone(),
15793                        row_id: op.row_id.clone(),
15794                        op: if op.upsert { Op::Upsert } else { Op::Delete },
15795                        base_version: op.base_version,
15796                        payload,
15797                    }
15798                })
15799                .collect();
15800            let frame = Frame::PushCommit {
15801                client_commit_id: commit.client_commit_id.clone(),
15802                operations,
15803            };
15804            let frame_bytes = match byte_cap {
15805                Some(_) => {
15806                    encode_message(&Message {
15807                        wire_version: WIRE_VERSION,
15808                        msg_kind: MsgKind::Request,
15809                        frames: vec![header.clone(), frame.clone(), pull.clone()],
15810                    })
15811                    .len()
15812                        - base_bytes
15813                }
15814                None => 0,
15815            };
15816            // The head is always encoded so the caller can report the full
15817            // projected request size; a later commit defers once it no longer
15818            // fits.
15819            if let Some(cap) = byte_cap {
15820                if !push_frames.is_empty() && fixed_bytes + bytes_in_request + frame_bytes > cap {
15821                    deferred_commits = total_outbox - index;
15822                    break;
15823                }
15824            }
15825            push_frames.push(frame);
15826            pushed_ids.push(commit.client_commit_id.clone());
15827            ops_in_request += commit.ops.len();
15828            bytes_in_request += frame_bytes;
15829        }
15830        #[cfg(feature = "bench-internals")]
15831        drop(outbox_phase);
15832        if let Some(cap) = byte_cap {
15833            if fixed_bytes + bytes_in_request > cap {
15834                // The head alone does not fit this budget; the commit stays
15835                // queued and no transport round is attempted.
15836                let blocked = if push_frames.is_empty() {
15837                    None
15838                } else {
15839                    self.outbox.first().map(|c| c.client_commit_id.as_str())
15840                };
15841                return Err(Self::push_capacity_outcome(
15842                    "bytes",
15843                    cap,
15844                    fixed_bytes + bytes_in_request,
15845                    blocked,
15846                ));
15847            }
15848        }
15849        let mut frames = vec![header];
15850        frames.extend(push_frames);
15851        frames.push(pull);
15852        frames.extend(sub_frames);
15853        let message = Message {
15854            wire_version: WIRE_VERSION,
15855            msg_kind: MsgKind::Request,
15856            frames,
15857        };
15858        Ok((
15859            message,
15860            RequestMeta {
15861                pushed_ids,
15862                fresh,
15863                accept,
15864                deferred_commits,
15865            },
15866        ))
15867    }
15868
15869    /// §7.1: a configured push capacity limit cannot carry this request.
15870    fn push_capacity_outcome(
15871        kind: &str,
15872        limit: usize,
15873        size: usize,
15874        client_commit_id: Option<&str>,
15875    ) -> Box<SyncOutcome> {
15876        let mut details = Map::new();
15877        details.insert("kind".to_owned(), Value::from(kind));
15878        details.insert("limit".to_owned(), Value::from(limit as u64));
15879        details.insert("size".to_owned(), Value::from(size as u64));
15880        if let Some(id) = client_commit_id {
15881            details.insert("clientCommitId".to_owned(), Value::from(id));
15882        }
15883        Box::new(SyncOutcome::Failed {
15884            error_code: "client.push_request_too_large".into(),
15885            message: "the push request exceeds a configured capacity limit".into(),
15886            details: Some(Value::Object(details)),
15887        })
15888    }
15889
15890    // -- sync -------------------------------------------------------------------
15891
15892    /// Cloneable observation handle; no client lock is needed to read it.
15893    pub fn progress(&self) -> ProgressObserver {
15894        self.progress.clone()
15895    }
15896
15897    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
15898        let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
15899            Ok(prepared) => prepared,
15900            Err(outcome) => return *outcome,
15901        };
15902        // The round's network deadline rides `PreparedSyncRound` and is scoped
15903        // to each exchange, so uploads, continuations, the main request, and
15904        // every segment fetch share one monotonic anchor without leaving
15905        // deadline state on a reused transport.
15906        let mut next = prepared;
15907        loop {
15908            match self.apply_sync_round(next.exchange(transport)) {
15909                crate::AppliedSyncRound::Continue(prepared) => next = prepared,
15910                crate::AppliedSyncRound::Complete {
15911                    outcome, controls, ..
15912                } => {
15913                    if self.transport_enabled {
15914                        for text in controls {
15915                            let _ = transport.realtime_send(&text);
15916                        }
15917                    } else {
15918                        self.disconnect_realtime(transport);
15919                    }
15920                    return outcome;
15921                }
15922            }
15923        }
15924    }
15925
15926    fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
15927        if let SyncOutcome::Failed {
15928            error_code,
15929            message,
15930            details,
15931        } = &mut outcome
15932        {
15933            if let Some(failure) = self.storage_failure.borrow().as_ref() {
15934                let code = failure
15935                    .code
15936                    .expect("retained storage failures are classified");
15937                *error_code = code.to_owned();
15938                *message = match code {
15939                    "client.storage_busy" => "local SQLite storage is busy",
15940                    "client.storage_full" => "local SQLite storage is full",
15941                    "client.storage_io" => "local SQLite storage I/O failed",
15942                    "client.storage_corrupt" => "local SQLite storage is corrupt",
15943                    _ => unreachable!("unrecognized classified storage failure"),
15944                }
15945                .to_owned();
15946                *details = failure.details();
15947            }
15948        }
15949        let retry_delay_ms = self.round_retry_delay_ms;
15950        self.progress.update(|p| match &outcome {
15951            SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report)
15952                if report.failed.is_empty() =>
15953            {
15954                p.state = ProgressState::Complete
15955            }
15956            SyncOutcome::Ok(_) | SyncOutcome::BudgetExhausted(_) => {
15957                p.state = ProgressState::Failed;
15958                p.error_code = Some("sync.scope_revoked".into());
15959            }
15960            SyncOutcome::Failed { error_code, .. } => {
15961                p.state = ProgressState::Failed;
15962                p.error_code = Some(Self::diagnostic_code(error_code));
15963                p.retry_delay_ms = retry_delay_ms;
15964            }
15965            SyncOutcome::RealtimeUnavailable { .. } => {
15966                p.state = ProgressState::Failed;
15967                p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
15968                p.retry_delay_ms = retry_delay_ms;
15969            }
15970        });
15971        let completed_at_ms = self.clock_now_ms();
15972        self.last_round = Some(match &outcome {
15973            SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report) => DiagnosticLastRound {
15974                status: "succeeded".to_owned(),
15975                started_at_ms,
15976                completed_at_ms,
15977                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
15978                counters: Some(DiagnosticRoundCounters {
15979                    pushed: report.pushed,
15980                    applied: report.applied.len(),
15981                    rejected: report.rejected.len(),
15982                    retryable: report.retryable.len(),
15983                    conflicts: report.conflicts,
15984                    commits_applied: report.commits_applied,
15985                    segment_rows_applied: report.segment_rows_applied,
15986                    bootstrapping: report.bootstrapping.len(),
15987                    resets: report.resets.len(),
15988                    revoked: report.revoked.len(),
15989                    failed: report.failed.len(),
15990                    deferred_commits: report.deferred_commits,
15991                }),
15992                error_code: None,
15993            },
15994            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
15995                status: "failed".to_owned(),
15996                started_at_ms,
15997                completed_at_ms,
15998                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
15999                counters: None,
16000                error_code: Some(Self::diagnostic_code(error_code)),
16001            },
16002            SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
16003                status: "failed".to_owned(),
16004                started_at_ms,
16005                completed_at_ms,
16006                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
16007                counters: None,
16008                error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
16009            },
16010        });
16011        outcome
16012    }
16013
16014    /// Capture one request on the client owner. Mutations made after this
16015    /// call remain in the outbox for a later round; no network I/O occurs here.
16016    pub fn prepare_sync_round(
16017        &mut self,
16018        url_capable: bool,
16019    ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
16020        if !self.transport_enabled {
16021            return Err(Box::new(SyncOutcome::Failed {
16022                error_code: "sync.offline".into(),
16023                message: "transport gate is closed".into(),
16024                details: None,
16025            }));
16026        }
16027        if self.active_round.is_some() {
16028            return Err(Box::new(SyncOutcome::Failed {
16029                error_code: "client.round_in_progress".into(),
16030                message: "a sync round is already in flight".into(),
16031                details: None,
16032            }));
16033        }
16034        self.storage_failure.borrow_mut().take();
16035        self.progress.start();
16036        self.round_retry_delay_ms = None;
16037        let started_at_ms = self.clock_now_ms();
16038        let prepared = (|| {
16039            if self.stopped {
16040                return Err(Box::new(SyncOutcome::Ok(SyncReport {
16041                    schema_floor: self.schema_floor.clone(),
16042                    ..SyncReport::default()
16043                })));
16044            }
16045            if self.security_preflight {
16046                return Err(Box::new(SyncOutcome::Failed {
16047                    error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
16048                    message: "security preflight must complete before syncing".into(),
16049                    details: None,
16050                }));
16051            }
16052            self.set_sync_needed(false, false);
16053            self.drain_pending_evictions()
16054                .and_then(|_| self.drop_incompatible_outbox())
16055                .and_then(|_| self.drop_unencodable_outbox(true, None))
16056                .map_err(|message| {
16057                    Box::new(SyncOutcome::Failed {
16058                        error_code: "storage.failed".into(),
16059                        message,
16060                        details: None,
16061                    })
16062                })?;
16063            let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
16064                self.pending_blob_uploads().map_err(|e| {
16065                    Box::new(SyncOutcome::Failed {
16066                        error_code: e.code,
16067                        message: e.message,
16068                        details: e.details,
16069                    })
16070                })?
16071            } else {
16072                Vec::new()
16073            };
16074            if self.realtime_state != RealtimeState::Connected
16075                && self.realtime_policy == RealtimePolicy::Required
16076            {
16077                let retry_delay_ms = self.schedule_background_retry();
16078                self.realtime_retry_delay_ms = Some(retry_delay_ms);
16079                return Err(Box::new(SyncOutcome::RealtimeUnavailable {
16080                    state: self.realtime_state,
16081                    reason_code: self.realtime_reason_code.clone(),
16082                    retry_delay_ms,
16083                }));
16084            }
16085            let (message, meta) = self.build_request(url_capable)?;
16086            let id = uuid::Uuid::new_v4();
16087            self.active_round = Some(id);
16088            Ok(crate::PreparedSyncRound {
16089                id,
16090                started_at_ms,
16091                message,
16092                meta,
16093                uploads: uploads.into(),
16094                realtime: self.realtime_state == RealtimeState::Connected,
16095                scopes: self
16096                    .subs
16097                    .iter()
16098                    .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
16099                    .collect(),
16100                #[cfg(feature = "bench-internals")]
16101                benchmark_phases: self.benchmark_phases.clone(),
16102                progress: self.progress.clone(),
16103                fixed_now: self.now_ms,
16104                round_deadline_at: None,
16105            })
16106        })();
16107        prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
16108    }
16109
16110    /// Discard the reply of an old authorization/subscription context. Its
16111    /// submitted commits stay durable and can be retried with the same ids.
16112    pub fn cancel_sync_round(&mut self) {
16113        self.active_round = None;
16114    }
16115
16116    /// Apply captured network results on the owner, preserving all normal
16117    /// version, schema, content-address and cursor checks. No network I/O.
16118    pub fn apply_sync_round(
16119        &mut self,
16120        completed: crate::CompletedSyncRound,
16121    ) -> crate::AppliedSyncRound {
16122        let prepared = completed.prepared;
16123        if self.active_round != Some(prepared.id) || self.security_preflight {
16124            return crate::AppliedSyncRound::Complete {
16125                outcome: SyncOutcome::Failed {
16126                    error_code: "client.round_cancelled".into(),
16127                    message: "sync round belongs to a released client context".into(),
16128                    details: None,
16129                },
16130                controls: Vec::new(),
16131                more: false,
16132                bootstrap_advanced: false,
16133            };
16134        }
16135        self.storage_failure.borrow_mut().take();
16136        let (response, mut downloads, transport_failed) = match completed.exchange {
16137            crate::round::ExchangeResult::Upload { id, result } => {
16138                let result = result.and_then(|()| {
16139                    self.conn
16140                        .execute(
16141                            "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
16142                            rusqlite::params![id],
16143                        )
16144                        .map(|_| ())
16145                        .map_err(|error| {
16146                            TransportError::new(
16147                                "client.failed",
16148                                Self::sqlite_failure(&self.storage_failure, error),
16149                            )
16150                        })
16151                });
16152                match result {
16153                    Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
16154                    Err(TransportError {
16155                        code,
16156                        message,
16157                        details,
16158                    }) => {
16159                        self.active_round = None;
16160                        if Self::retryable_failure_code(&code) {
16161                            self.schedule_background_retry();
16162                        }
16163                        let outcome = self.finish_sync_round(
16164                            prepared.started_at_ms,
16165                            SyncOutcome::Failed {
16166                                error_code: code,
16167                                message,
16168                                details,
16169                            },
16170                        );
16171                        return crate::AppliedSyncRound::Complete {
16172                            outcome,
16173                            controls: Vec::new(),
16174                            more: false,
16175                            bootstrap_advanced: false,
16176                        };
16177                    }
16178                }
16179            }
16180            crate::round::ExchangeResult::Reply {
16181                response,
16182                downloads,
16183                transport_failed,
16184            } => (response, downloads, transport_failed),
16185        };
16186        self.active_round = None;
16187        let before = self
16188            .subs
16189            .iter()
16190            .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
16191            .collect::<Vec<_>>();
16192        let outcome = (|| {
16193            let response = match response {
16194                Ok(response) => response,
16195                Err(TransportError {
16196                    code,
16197                    message,
16198                    details,
16199                }) => {
16200                    if transport_failed {
16201                        if prepared.realtime {
16202                            self.set_realtime_state(RealtimeState::Lost, Some(&code));
16203                        }
16204                        self.record_lease_error(&code);
16205                    }
16206                    if Self::retryable_failure_code(&code) {
16207                        let delay = self.schedule_background_retry();
16208                        if prepared.realtime && transport_failed {
16209                            self.realtime_retry_delay_ms = Some(delay);
16210                        }
16211                    }
16212                    return SyncOutcome::Failed {
16213                        error_code: code,
16214                        message,
16215                        details,
16216                    };
16217                }
16218            };
16219            let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
16220            if let SyncOutcome::Ok(report) = &mut outcome {
16221                report.deferred_commits = prepared.meta.deferred_commits;
16222            }
16223            match &outcome {
16224                SyncOutcome::Ok(_) => self.reset_background_retry(),
16225                SyncOutcome::Failed { error_code, .. }
16226                    if Self::retryable_failure_code(error_code) =>
16227                {
16228                    self.schedule_background_retry();
16229                }
16230                _ => {}
16231            }
16232            if prepared.meta.deferred_commits > 0 {
16233                self.set_sync_needed(true, true);
16234            }
16235            self.previous_version_lifetime_check();
16236            outcome
16237        })();
16238        let more = self.transport_enabled
16239            && matches!(&outcome, SyncOutcome::Ok(report)
16240                if report.schema_floor.is_none()
16241                    && (!report.bootstrapping.is_empty()
16242                        || report.commits_applied > 0
16243                        || report.segment_rows_applied > 0
16244                        || !report.resets.is_empty()
16245                        || report.deferred_commits > 0
16246                        || self.sync_needed));
16247        let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
16248            && before
16249                != self
16250                    .subs
16251                    .iter()
16252                    .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
16253                    .collect::<Vec<_>>();
16254        crate::AppliedSyncRound::Complete {
16255            outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
16256            controls: if self.transport_enabled {
16257                downloads.controls
16258            } else {
16259                Vec::new()
16260            },
16261            more,
16262            bootstrap_advanced,
16263        }
16264    }
16265
16266    pub fn sync_until_idle(
16267        &mut self,
16268        transport: &mut dyn Transport,
16269        max_rounds: Option<u32>,
16270    ) -> SyncOutcome {
16271        // §7.7: an explicit budget is a strict positive round count. Zero is a
16272        // caller error, not an exhaustion.
16273        if max_rounds == Some(0) {
16274            return SyncOutcome::Failed {
16275                details: None,
16276                error_code: "sync.invalid_request".into(),
16277                message: "maxRounds must be a positive integer".into(),
16278            };
16279        }
16280        let rounds = max_rounds.unwrap_or(20);
16281        let mut aggregate = SyncReport::default();
16282        let mut spent = 0;
16283        while spent < rounds {
16284            spent += 1;
16285            let before = self
16286                .subs
16287                .iter()
16288                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
16289                .collect::<Vec<_>>();
16290            match self.sync(transport) {
16291                SyncOutcome::Failed {
16292                    error_code,
16293                    message,
16294                    details,
16295                } => {
16296                    return SyncOutcome::Failed {
16297                        details,
16298                        error_code,
16299                        message,
16300                    };
16301                }
16302                outcome @ SyncOutcome::RealtimeUnavailable { .. } => return outcome,
16303                SyncOutcome::BudgetExhausted(_) => {
16304                    unreachable!("sync() never exhausts a round budget")
16305                }
16306                SyncOutcome::Ok(report) => {
16307                    if max_rounds.is_none()
16308                        && report.segment_rows_applied > 0
16309                        && !report.bootstrapping.is_empty()
16310                        && before
16311                            != self
16312                                .subs
16313                                .iter()
16314                                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
16315                                .collect::<Vec<_>>()
16316                    {
16317                        spent = 0;
16318                    }
16319                    // TS parity: a disabled transport or a schema floor ends
16320                    // the loop. The latest round still merges first.
16321                    let stop = !self.transport_enabled() || report.schema_floor.is_some();
16322                    aggregate.merge(&report);
16323                    if stop {
16324                        return SyncOutcome::Ok(aggregate);
16325                    }
16326                    // §4.5: pull again whenever the response contained
16327                    // commits or segments; resets re-bootstrap; a pending
16328                    // resume token continues paging (§4.7); deferred outbox
16329                    // commits and a raised sync-needed signal push on the next
16330                    // round.
16331                    let more = !report.bootstrapping.is_empty()
16332                        || report.commits_applied > 0
16333                        || report.segment_rows_applied > 0
16334                        || !report.resets.is_empty()
16335                        || report.deferred_commits > 0
16336                        || self.sync_needed;
16337                    if !more {
16338                        return SyncOutcome::Ok(aggregate);
16339                    }
16340                }
16341            }
16342        }
16343        // §7.7: partial success. The aggregate report is retained and the run
16344        // is explicitly not idle.
16345        SyncOutcome::BudgetExhausted(aggregate)
16346    }
16347
16348    fn process_response(
16349        &mut self,
16350        transport: &mut dyn Transport,
16351        response: Message,
16352        meta: &RequestMeta,
16353    ) -> SyncOutcome {
16354        #[cfg(feature = "bench-internals")]
16355        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
16356        let mut report = SyncReport {
16357            pushed: meta.pushed_ids.len() as u32,
16358            ..SyncReport::default()
16359        };
16360        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
16361            HashMap::new();
16362        let pushed_ids = meta
16363            .pushed_ids
16364            .iter()
16365            .map(String::as_str)
16366            .collect::<HashSet<_>>();
16367        let mut last_final_push_result_id: Option<String> = None;
16368        for frame in &response.frames {
16369            match frame {
16370                Frame::PushResultDetails {
16371                    client_commit_id,
16372                    entries,
16373                } => {
16374                    let details = rejection_details_by_commit
16375                        .entry(client_commit_id.clone())
16376                        .or_default();
16377                    for entry in entries {
16378                        let parsed = match RejectionDetails::parse(&entry.details.0) {
16379                            Ok(value) => value,
16380                            Err(message) => {
16381                                return SyncOutcome::Failed {
16382                                    details: None,
16383                                    error_code: "sync.invalid_request".to_owned(),
16384                                    message,
16385                                };
16386                            }
16387                        };
16388                        details.insert(entry.op_index, parsed);
16389                    }
16390                }
16391                Frame::PushResult {
16392                    client_commit_id,
16393                    status,
16394                    results,
16395                    ..
16396                } if pushed_ids.contains(client_commit_id.as_str())
16397                    && Self::push_result_is_final(*status, results) =>
16398                {
16399                    last_final_push_result_id = Some(client_commit_id.clone());
16400                }
16401                _ => {}
16402            }
16403        }
16404        let mut frames = response.frames.into_iter().peekable();
16405        if response.wire_version < 2 {
16406            return SyncOutcome::Failed {
16407                details: None,
16408                error_code: "client.invalid_host_response".to_owned(),
16409                message: "server response does not carry wire version 2 log-epoch state".to_owned(),
16410            };
16411        }
16412        match frames.next() {
16413            Some(Frame::RespHeader {
16414                required_schema_version,
16415                latest_schema_version,
16416                log_epoch,
16417                reset_required,
16418            }) => {
16419                let Some(log_epoch) = log_epoch else {
16420                    return SyncOutcome::Failed {
16421                        details: None,
16422                        error_code: "client.invalid_host_response".to_owned(),
16423                        message: "response header omits logEpoch".to_owned(),
16424                    };
16425                };
16426                let Some(reset_required) = reset_required else {
16427                    return SyncOutcome::Failed {
16428                        details: None,
16429                        error_code: "client.invalid_host_response".to_owned(),
16430                        message: "response header omits resetRequired".to_owned(),
16431                    };
16432                };
16433                if let Some(required) = required_schema_version {
16434                    // §1.6 schema-floor response: nothing else is processed;
16435                    // stop syncing and surface the upgrade requirement.
16436                    let floor = SchemaFloor {
16437                        required_schema_version: Some(required),
16438                        latest_schema_version,
16439                    };
16440                    self.set_schema_floor(Some(floor.clone()));
16441                    report.schema_floor = Some(floor);
16442                    return SyncOutcome::Ok(report);
16443                }
16444                if reset_required {
16445                    if frames.next().is_some() {
16446                        return SyncOutcome::Failed {
16447                            details: None,
16448                            error_code: "client.invalid_host_response".to_owned(),
16449                            message: "log-epoch reset response contains body frames".to_owned(),
16450                        };
16451                    }
16452                    match self.run_log_epoch_reset(&log_epoch) {
16453                        Ok(resets) => {
16454                            report.resets = resets;
16455                            return SyncOutcome::Ok(report);
16456                        }
16457                        Err(message) => {
16458                            return SyncOutcome::Failed {
16459                                details: None,
16460                                error_code: "sync.local_corrupt".to_owned(),
16461                                message,
16462                            };
16463                        }
16464                    }
16465                }
16466                if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
16467                    return SyncOutcome::Failed {
16468                        details: None,
16469                        error_code: "client.invalid_host_response".to_owned(),
16470                        message: "server changed logEpoch without requiring a reset".to_owned(),
16471                    };
16472                }
16473            }
16474            _ => {
16475                return SyncOutcome::Failed {
16476                    details: None,
16477                    error_code: "sync.invalid_request".to_owned(),
16478                    message: "response does not start with RESP_HEADER".to_owned(),
16479                };
16480            }
16481        }
16482
16483        if let Err(message) = self.begin_coalesced_apply() {
16484            return SyncOutcome::Failed {
16485                details: None,
16486                error_code: "storage.failed".into(),
16487                message,
16488            };
16489        }
16490        let mut failure: Option<(String, String)> = None;
16491        while let Some(frame) = frames.next() {
16492            match frame {
16493                frame @ Frame::PushResult { .. } => {
16494                    let successful = matches!(
16495                        &frame,
16496                        Frame::PushResult {
16497                            status: PushStatus::Applied | PushStatus::Cached,
16498                            ..
16499                        }
16500                    );
16501                    let mut results = vec![frame];
16502                    if successful {
16503                        while matches!(
16504                            frames.peek(),
16505                            Some(Frame::PushResult {
16506                                status: PushStatus::Applied | PushStatus::Cached,
16507                                ..
16508                            })
16509                        ) {
16510                            if let Some(next) = frames.next() {
16511                                results.push(next);
16512                            }
16513                        }
16514                    }
16515                    if self
16516                        .handle_push_results(
16517                            &results,
16518                            &pushed_ids,
16519                            &rejection_details_by_commit,
16520                            &mut report,
16521                            last_final_push_result_id.as_deref(),
16522                        )
16523                        .is_err()
16524                    {
16525                        failure = Some((
16526                            "client.outcome_persistence_failed".to_owned(),
16527                            "local commit outcome could not be persisted".to_owned(),
16528                        ));
16529                        break;
16530                    }
16531                }
16532                Frame::PushResultDetails { .. } => {}
16533                Frame::SubStart {
16534                    id,
16535                    status,
16536                    reason_code,
16537                    effective_scopes,
16538                    bootstrap: _,
16539                } => {
16540                    let mut body = Vec::new();
16541                    let mut sub_end: Option<(i64, Option<String>)> = None;
16542                    for inner in frames.by_ref() {
16543                        match inner {
16544                            Frame::SubEnd {
16545                                next_cursor,
16546                                bootstrap_state,
16547                            } => {
16548                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
16549                                break;
16550                            }
16551                            Frame::Unknown { .. } => {}
16552                            other => body.push(other),
16553                        }
16554                    }
16555                    if let Err(SectionError::Abort(code, message)) = self.process_section(
16556                        transport,
16557                        &id,
16558                        status,
16559                        &reason_code,
16560                        effective_scopes,
16561                        body,
16562                        sub_end,
16563                        meta,
16564                        &mut report,
16565                    ) {
16566                        failure = Some((code, message));
16567                        break;
16568                    }
16569                }
16570                Frame::Lease {
16571                    lease_id,
16572                    expires_at_ms,
16573                } => {
16574                    // §7.3.5: persist the opaque lease; a fresh lease clears
16575                    // any prior lease error (the outage/revocation is over).
16576                    self.set_lease_state(Some(LeaseState {
16577                        lease_id: Some(lease_id),
16578                        expires_at_ms: Some(expires_at_ms),
16579                        error_code: None,
16580                    }));
16581                }
16582                Frame::Error { code, message, .. } => {
16583                    // §1.6: the whole request failed; already-completed
16584                    // subscriptions keep their applied data and cursors.
16585                    failure = Some((code, message));
16586                    break;
16587                }
16588                Frame::Unknown { .. } => {}
16589                _ => {
16590                    failure = Some((
16591                        "sync.invalid_request".to_owned(),
16592                        "unexpected frame in response".to_owned(),
16593                    ));
16594                    break;
16595                }
16596            }
16597        }
16598
16599        let applied = match failure {
16600            Some(failure) => Err(failure),
16601            // Local apply boundaries already reconciled their affected overlay.
16602            // Refuse round success if any remaining dirty work fails to
16603            // reconcile. §4.8 E1: the push half may have drained commits that
16604            // pinned rows of a shrunk window unit, so retry deferred evictions.
16605            None => self
16606                .rebuild_overlay_if_dirty()
16607                .and_then(|()| self.drain_pending_evictions())
16608                .map_err(|message| ("storage.failed".to_owned(), message)),
16609        };
16610        // §1.4 rule 4: frames completed before a failure stay committed; a
16611        // failed COMMIT of the coalesced transaction fails the round.
16612        if let Err(message) = self.finish_coalesced_apply() {
16613            return SyncOutcome::Failed {
16614                details: None,
16615                error_code: "storage.failed".into(),
16616                message,
16617            };
16618        }
16619        if let Err((error_code, message)) = applied {
16620            return SyncOutcome::Failed {
16621                details: None,
16622                error_code,
16623                message,
16624            };
16625        }
16626        self.ack_after_pull(transport);
16627        // §7.4.5: the reset is over once the first post-reset pull round
16628        // leaves no subscription mid-bootstrap — the tables are rebuilt.
16629        if self.upgrading && report.bootstrapping.is_empty() {
16630            self.set_upgrading(false);
16631        }
16632        SyncOutcome::Ok(report)
16633    }
16634
16635    // -- push results (§6.3, §7.2) ------------------------------------------------
16636
16637    fn handle_push_results(
16638        &mut self,
16639        frames: &[Frame],
16640        pushed_ids: &HashSet<&str>,
16641        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
16642        report: &mut SyncReport,
16643        last_final_push_result_id: Option<&str>,
16644    ) -> Result<(), String> {
16645        self.begin_observation("syncular_push_results")?;
16646        let previous_failed = self.failed_commits.clone();
16647        let previous_sync_needed = self.sync_needed;
16648        let conflict_count = self.conflicts.len();
16649        let rejection_count = self.rejections.len();
16650        let applied_count = report.applied.len();
16651        let rejected_count = report.rejected.len();
16652        let retryable_count = report.retryable.len();
16653        let was_dirty = self.overlay_dirty.snapshot();
16654        let mut removed = Vec::new();
16655        let mut batch = ChangeAccumulator::default();
16656        let persisted = (|| {
16657            for frame in frames {
16658                let Frame::PushResult {
16659                    client_commit_id,
16660                    status,
16661                    results,
16662                    commit_seq,
16663                } = frame
16664                else {
16665                    return Err("unexpected acknowledgement frame".to_owned());
16666                };
16667                if !pushed_ids.contains(client_commit_id.as_str()) {
16668                    continue;
16669                }
16670                let Some(index) = self
16671                    .outbox
16672                    .iter()
16673                    .position(|commit| commit.client_commit_id == *client_commit_id)
16674                else {
16675                    continue;
16676                };
16677                let status = *status;
16678                let rejection_details = rejection_details_by_commit.get(client_commit_id);
16679                let operations = self.outbox[index].ops.clone();
16680                match status {
16681                    PushStatus::Applied | PushStatus::Cached => {
16682                        // §7.2: a lost ack replays as `cached` — proceed as if the
16683                        // ack had arrived.
16684                        let sequence =
16685                            commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
16686                        let affected: HashSet<_> = operations
16687                            .iter()
16688                            .map(|op| (op.table.as_str(), op.row_id.as_str()))
16689                            .collect();
16690                        let prior = self.acknowledged_operations(Some(&affected), None)?;
16691                        for (op_index, operation) in operations.iter().enumerate() {
16692                            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))
16693                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16694                            if delivered {
16695                                continue;
16696                            }
16697                            let table = self
16698                                .schema
16699                                .table(&operation.table)
16700                                .ok_or_else(|| "sync.unknown_table".to_owned())?;
16701                            let mut intended: Map<String, Value> = self
16702                                .stored_row(table, &operation.row_id, true)?
16703                                .map(|(row, _)| {
16704                                    table
16705                                        .columns
16706                                        .iter()
16707                                        .zip(&row)
16708                                        .map(|(column, value)| {
16709                                            (column.name.clone(), column_value_to_json(value))
16710                                        })
16711                                        .collect()
16712                                })
16713                                .unwrap_or_default();
16714                            let mut scope_image = intended.clone();
16715                            for op in prior
16716                                .iter()
16717                                .chain(
16718                                    self.failed_commits
16719                                        .iter()
16720                                        .flat_map(|(commit, _)| &commit.ops),
16721                                )
16722                                .chain(
16723                                    self.outbox
16724                                        .iter()
16725                                        .take(index)
16726                                        .flat_map(|commit| &commit.ops),
16727                                )
16728                                .chain(operations.iter().take(op_index + 1))
16729                            {
16730                                if op.table != operation.table || op.row_id != operation.row_id {
16731                                    continue;
16732                                }
16733                                if let Some(values) = &op.values {
16734                                    intended.extend(values.clone());
16735                                } else {
16736                                    scope_image = intended.clone();
16737                                    intended.clear();
16738                                }
16739                            }
16740                            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)",
16741                                rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
16742                                    serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
16743                                    serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
16744                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16745                        }
16746                        let journal_results = results
16747                            .iter()
16748                            .map(|result| {
16749                                let op_index = match result {
16750                                    OpResult::Applied { op_index }
16751                                    | OpResult::Conflict { op_index, .. }
16752                                    | OpResult::Error { op_index, .. } => *op_index,
16753                                };
16754                                CommitOperationOutcome::Applied { op_index }
16755                            })
16756                            .collect::<Vec<_>>();
16757                        let outcome_status = if status == PushStatus::Applied {
16758                            CommitOutcomeStatus::Applied
16759                        } else {
16760                            CommitOutcomeStatus::Cached
16761                        };
16762                        let persisted = self
16763                            .persist_commit_outcome(
16764                                client_commit_id,
16765                                outcome_status,
16766                                &journal_results,
16767                                None,
16768                            )
16769                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
16770                        persisted?;
16771                        for operation in &operations {
16772                            self.overlay_dirty.table(&operation.table);
16773                        }
16774                        self.sync_needed = true;
16775                        batch.status = true;
16776                        batch.outcomes = true;
16777                    }
16778                    PushStatus::Rejected => {
16779                        if results.iter().any(|result| {
16780                            matches!(
16781                                result,
16782                                OpResult::Error {
16783                                    code,
16784                                    retryable: true,
16785                                    ..
16786                                } if code == "sync.idempotency_cache_miss"
16787                            )
16788                        }) {
16789                            // §6.3/§7.2: a serving failure, not an outcome — keep the
16790                            // exact commit queued for an identical retry.
16791                            report.retryable.push(client_commit_id.to_owned());
16792                            continue;
16793                        }
16794
16795                        let mut journal_results = Vec::with_capacity(results.len());
16796                        let mut conflicts = Vec::new();
16797                        let mut rejections = Vec::new();
16798                        for result in results {
16799                            match result {
16800                                OpResult::Applied { op_index } => {
16801                                    journal_results.push(CommitOperationOutcome::Applied {
16802                                        op_index: *op_index,
16803                                    });
16804                                }
16805                                OpResult::Conflict {
16806                                    op_index,
16807                                    code,
16808                                    message,
16809                                    server_version,
16810                                    server_row,
16811                                    conflict_columns: conflict_columns_bitmap,
16812                                } => {
16813                                    let operation = operations
16814                                        .get(*op_index as usize)
16815                                        .map(CommitOperation::from);
16816                                    let (table, row_id) = operation
16817                                        .as_ref()
16818                                        .map(|op| (op.table.clone(), op.row_id.clone()))
16819                                        .unwrap_or_default();
16820                                    let server_row_json = self
16821                                        .schema
16822                                        .table(&table)
16823                                        .and_then(|t| {
16824                                            decode_row_bytes(t, server_row, &self.encryption)
16825                                                .ok()
16826                                                .map(|row| (t, row))
16827                                        })
16828                                        .map(|(t, row)| {
16829                                            let mut map = Map::new();
16830                                            for (i, column) in t.columns.iter().enumerate() {
16831                                                map.insert(
16832                                                    column.name.clone(),
16833                                                    column_value_to_json(
16834                                                        row.get(i).unwrap_or(&None),
16835                                                    ),
16836                                                );
16837                                            }
16838                                            map
16839                                        })
16840                                        .unwrap_or_default();
16841                                    // §6.3: decode the presence-layout bitmap
16842                                    // into the marked column names.
16843                                    let conflict_columns = self
16844                                        .schema
16845                                        .table(&table)
16846                                        .map(|t| {
16847                                            t.columns
16848                                                .iter()
16849                                                .enumerate()
16850                                                .filter(|(index, _)| {
16851                                                    conflict_columns_bitmap
16852                                                        .get(index / 8)
16853                                                        .is_some_and(|byte| {
16854                                                            byte & (1 << (index % 8)) != 0
16855                                                        })
16856                                                })
16857                                                .map(|(_, column)| column.name.clone())
16858                                                .collect::<Vec<_>>()
16859                                        })
16860                                        .unwrap_or_default();
16861                                    let conflict = ConflictRecord {
16862                                        client_commit_id: client_commit_id.to_owned(),
16863                                        op_index: *op_index,
16864                                        table,
16865                                        row_id,
16866                                        code: code.clone(),
16867                                        message: message.clone(),
16868                                        server_version: *server_version,
16869                                        server_row: server_row_json,
16870                                        conflict_columns,
16871                                        operation,
16872                                    };
16873                                    journal_results.push(CommitOperationOutcome::Conflict {
16874                                        conflict: conflict.clone(),
16875                                    });
16876                                    conflicts.push(conflict);
16877                                }
16878                                OpResult::Error {
16879                                    op_index,
16880                                    code,
16881                                    message,
16882                                    retryable,
16883                                } => {
16884                                    let rejection = RejectionRecord {
16885                                        client_commit_id: client_commit_id.to_owned(),
16886                                        op_index: *op_index,
16887                                        code: code.clone(),
16888                                        message: message.clone(),
16889                                        retryable: *retryable,
16890                                        details: rejection_details
16891                                            .and_then(|details| details.get(op_index))
16892                                            .cloned(),
16893                                        operation: operations
16894                                            .get(*op_index as usize)
16895                                            .map(CommitOperation::from),
16896                                    };
16897                                    journal_results.push(CommitOperationOutcome::Error {
16898                                        rejection: rejection.clone(),
16899                                    });
16900                                    rejections.push(rejection);
16901                                }
16902                            }
16903                        }
16904                        if self.retain_failed_commits {
16905                            self.retain_failed_commit(client_commit_id, &operations)?;
16906                        }
16907                        let outcome_status = if conflicts.is_empty() {
16908                            CommitOutcomeStatus::Rejected
16909                        } else {
16910                            CommitOutcomeStatus::Conflict
16911                        };
16912                        let persisted = self
16913                            .persist_commit_outcome(
16914                                client_commit_id,
16915                                outcome_status,
16916                                &journal_results,
16917                                Some(&operations),
16918                            )
16919                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
16920                        persisted?;
16921                        batch.conflicts = !conflicts.is_empty();
16922                        batch.rejections = !rejections.is_empty();
16923                        batch.status = true;
16924                        batch.outcomes = true;
16925                        self.conflicts.extend(conflicts);
16926                        self.rejections.extend(rejections);
16927                        for operation in &operations {
16928                            self.overlay_dirty.table(&operation.table);
16929                        }
16930                    }
16931                }
16932                removed.push((index, self.outbox.remove(index)));
16933                if batch.status {
16934                    for operation in &operations {
16935                        if !self.record_row_scopes(
16936                            &mut batch,
16937                            &operation.table,
16938                            &operation.row_id,
16939                            false,
16940                        ) {
16941                            batch.table(&operation.table);
16942                        }
16943                    }
16944                }
16945                match status {
16946                    PushStatus::Applied | PushStatus::Cached => {
16947                        report.applied.push(client_commit_id.to_owned())
16948                    }
16949                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
16950                }
16951            }
16952            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)) {
16953                self.prune_commit_outcomes()?;
16954            }
16955            self.rebuild_overlay_if_dirty()?;
16956            self.finish_observation("syncular_push_results", batch)
16957        })();
16958        if let Err(error) = persisted {
16959            self.rollback_observation("syncular_push_results");
16960            self.failed_commits = previous_failed;
16961            self.sync_needed = previous_sync_needed;
16962            for (index, commit) in removed.into_iter().rev() {
16963                self.outbox.insert(index, commit);
16964            }
16965            self.conflicts.truncate(conflict_count);
16966            self.rejections.truncate(rejection_count);
16967            self.overlay_dirty.restore(was_dirty.clone());
16968            report.applied.truncate(applied_count);
16969            report.rejected.truncate(rejected_count);
16970            report.retryable.truncate(retryable_count);
16971            return Err(error);
16972        }
16973        if !previous_sync_needed && self.sync_needed {
16974            self.sync_intent_queue.push_back(SyncIntent::Interactive);
16975        }
16976        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
16977        Ok(())
16978    }
16979
16980    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
16981        status != PushStatus::Rejected
16982            || !results.iter().any(|result| {
16983                matches!(
16984                    result,
16985                    OpResult::Error {
16986                        code,
16987                        retryable: true,
16988                        ..
16989                    } if code == "sync.idempotency_cache_miss"
16990                )
16991            })
16992    }
16993
16994    // -- subscription sections ------------------------------------------------------
16995
16996    #[allow(clippy::too_many_arguments)]
16997    fn process_section(
16998        &mut self,
16999        transport: &mut dyn Transport,
17000        id: &str,
17001        status: SubStatus,
17002        reason_code: &str,
17003        effective_scopes: Vec<(String, Vec<String>)>,
17004        body: Vec<Frame>,
17005        sub_end: Option<(i64, Option<String>)>,
17006        meta: &RequestMeta,
17007        report: &mut SyncReport,
17008    ) -> Result<(), SectionError> {
17009        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
17010            return Ok(()); // unknown echo: ignore
17011        };
17012        if status != SubStatus::Active && sub_end.is_none() {
17013            return Err(SectionError::Abort(
17014                "sync.invalid_request".into(),
17015                "subscription section without SUB_END".into(),
17016            ));
17017        }
17018        match status {
17019            SubStatus::Revoked => {
17020                self.begin_observation("syncular_revocation")
17021                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
17022                let previous = self.subs[sub_index].clone();
17023                let was_dirty = self.overlay_dirty.snapshot();
17024                let previous_outbox = self.outbox.clone();
17025                let previous_failed = self.failed_commits.clone();
17026                let previous_rejections = self.rejections.clone();
17027                let previous_conflicts = self.conflicts.clone();
17028                let revoked_count = report.revoked.len();
17029                let failed_count = report.failed.len();
17030                let result = (|| {
17031                    let mut batch = ChangeAccumulator::default();
17032                    let registered = self.window_unit_by_sub_id(id);
17033                    // §3.3: stop pulling, purge exactly the last effective grant.
17034                    let (table, effective) = {
17035                        let sub = &self.subs[sub_index];
17036                        (sub.table.clone(), sub.effective.clone().unwrap_or_default())
17037                    };
17038                    let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
17039                        SectionError::Abort(
17040                            "sync.unknown_table".to_owned(),
17041                            "revoked scope references an unknown table".to_owned(),
17042                        )
17043                    })?;
17044                    let dropped = self
17045                        .drop_failed_commits(|name, values| {
17046                            name == table
17047                                && !effective.is_empty()
17048                                && effective.iter().all(|(variable, allowed)| {
17049                                    scoped_table
17050                                        .scope_column(variable)
17051                                        .and_then(|column| values.get(column))
17052                                        .and_then(Value::as_str)
17053                                        .is_some_and(|value| {
17054                                            allowed.iter().any(|allowed| allowed == value)
17055                                        })
17056                                })
17057                        })
17058                        .map_err(|message| {
17059                            SectionError::Abort("storage.failed".to_owned(), message)
17060                        })?;
17061                    for commit in dropped {
17062                        batch.conflicts = true;
17063                        batch.rejections = true;
17064                        batch.outcomes = true;
17065                        for operation in commit.ops {
17066                            batch.table(&operation.table);
17067                        }
17068                    }
17069                    let purged = self.purge_scope_rows(&table, &effective);
17070                    match purged {
17071                        Ok(()) => {
17072                            self.record_scope_map(&mut batch, &table, &effective);
17073                            let sub = &mut self.subs[sub_index];
17074                            sub.state = SubState::Revoked;
17075                            sub.reason_code = Some(if reason_code.is_empty() {
17076                                "sync.scope_revoked".to_owned()
17077                            } else {
17078                                reason_code.to_owned()
17079                            });
17080                            report.revoked.push(id.to_owned());
17081                            let doomed_effective = effective;
17082                            let sub_table = table;
17083                            self.persist_sub(&self.subs[sub_index].clone())
17084                                .map_err(|message| {
17085                                    SectionError::Abort("storage.failed".into(), message)
17086                                })?;
17087                            let dropped = self
17088                                .drop_doomed_outbox(&sub_table, &doomed_effective)
17089                                .map_err(|message| {
17090                                    SectionError::Abort("storage.failed".to_owned(), message)
17091                                })?;
17092                            if dropped {
17093                                batch.status = true;
17094                                batch.rejections = true;
17095                                batch.outcomes = true;
17096                            }
17097                            // §5.9.7 B2: revocation deletes now-unauthorized blob
17098                            // bodies (evicted ≠ revoked).
17099                            self.delete_unreferenced_cached_blobs().map_err(|message| {
17100                                SectionError::Abort("storage.failed".into(), message)
17101                            })?;
17102                        }
17103                        Err(()) => {
17104                            // §3.3 fail closed: no local mapping — never clear by
17105                            // approximation; fatal configuration error.
17106                            let sub = &mut self.subs[sub_index];
17107                            sub.state = SubState::Failed;
17108                            sub.reason_code = Some("sync.scope_revoked".to_owned());
17109                            report.failed.push(id.to_owned());
17110                            self.persist_sub(&self.subs[sub_index].clone())
17111                                .map_err(|message| {
17112                                    SectionError::Abort("storage.failed".into(), message)
17113                                })?;
17114                        }
17115                    }
17116                    if let Some((base_key, unit)) = registered {
17117                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
17118                    }
17119                    self.rebuild_overlay_if_dirty()
17120                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17121                    self.finish_observation("syncular_revocation", batch)
17122                        .map_err(|message| {
17123                            SectionError::Abort("storage.failed".to_owned(), message)
17124                        })?;
17125                    Ok(())
17126                })();
17127                if result.is_err() {
17128                    self.rollback_observation("syncular_revocation");
17129                    self.subs[sub_index] = previous;
17130                    self.overlay_dirty.restore(was_dirty.clone());
17131                    self.outbox = previous_outbox;
17132                    self.failed_commits = previous_failed;
17133                    self.rejections = previous_rejections;
17134                    self.conflicts = previous_conflicts;
17135                    report.revoked.truncate(revoked_count);
17136                    report.failed.truncate(failed_count);
17137                }
17138                result
17139            }
17140            SubStatus::Reset => {
17141                self.begin_observation("syncular_reset")
17142                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
17143                let previous = self.subs[sub_index].clone();
17144                let was_dirty = self.overlay_dirty.snapshot();
17145                let reset_count = report.resets.len();
17146                let result = (|| {
17147                    let mut batch = ChangeAccumulator::default();
17148                    let registered = self.window_unit_by_sub_id(id);
17149                    // §4.6: discard cursor + bootstrap state, keep local rows —
17150                    // reset is a staleness signal, not a purge signal.
17151                    let sub = &mut self.subs[sub_index];
17152                    sub.cursor = -1;
17153                    sub.bootstrap_state = None;
17154                    sub.reason_code = Some(if reason_code.is_empty() {
17155                        "sync.cursor_expired".to_owned()
17156                    } else {
17157                        reason_code.to_owned()
17158                    });
17159                    report.resets.push(id.to_owned());
17160                    self.persist_sub(&self.subs[sub_index].clone())
17161                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17162                    if let Some((base_key, unit)) = registered {
17163                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
17164                    }
17165                    self.finish_observation("syncular_reset", batch)
17166                        .map_err(|message| {
17167                            SectionError::Abort("storage.failed".to_owned(), message)
17168                        })?;
17169                    Ok(())
17170                })();
17171                if result.is_err() {
17172                    self.rollback_observation("syncular_reset");
17173                    self.subs[sub_index] = previous;
17174                    self.overlay_dirty.restore(was_dirty.clone());
17175                    report.resets.truncate(reset_count);
17176                }
17177                result
17178            }
17179            SubStatus::Active => {
17180                let fresh = meta
17181                    .fresh
17182                    .iter()
17183                    .find(|(fid, _)| fid == id)
17184                    .map(|(_, fresh)| *fresh)
17185                    .unwrap_or(false);
17186                let previous = self.subs[sub_index].clone();
17187                self.subs[sub_index].effective = Some(effective_scopes);
17188                let outcome = self
17189                    .apply_section_body(transport, sub_index, body, fresh, meta, report)
17190                    .and_then(|()| {
17191                        let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
17192                            SectionError::Abort(
17193                                "sync.invalid_request".into(),
17194                                "subscription section without SUB_END".into(),
17195                            )
17196                        })?;
17197                        self.apply_sub_end(sub_index, cursor, bootstrap_state)
17198                            .map_err(|message| {
17199                                SectionError::Abort("storage.failed".into(), message)
17200                            })
17201                    });
17202                match outcome {
17203                    Ok(()) => {
17204                        if self.subs[sub_index].bootstrap_state.is_some() {
17205                            report.bootstrapping.push(id.to_owned());
17206                        }
17207                        Ok(())
17208                    }
17209                    Err(SectionError::FailClosed) => {
17210                        self.subs[sub_index] = previous.clone();
17211                        self.begin_observation("syncular_section_failure")
17212                            .map_err(|message| {
17213                                SectionError::Abort("storage.failed".into(), message)
17214                            })?;
17215                        let persisted = (|| {
17216                            let mut batch = ChangeAccumulator::default();
17217                            if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
17218                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
17219                            }
17220                            self.subs[sub_index].state = SubState::Failed;
17221                            self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
17222                            self.persist_sub(&self.subs[sub_index])?;
17223                            self.finish_observation("syncular_section_failure", batch)
17224                        })();
17225                        if let Err(message) = persisted {
17226                            self.rollback_observation("syncular_section_failure");
17227                            self.subs[sub_index] = previous;
17228                            return Err(SectionError::Abort("storage.failed".into(), message));
17229                        }
17230                        report.failed.push(id.to_owned());
17231                        Ok(())
17232                    }
17233                    Err(error) => {
17234                        // Frame/block transactions already completed. Preserve their
17235                        // rows and revisions, but leave this subscription cursor unchanged.
17236                        self.subs[sub_index] = previous;
17237                        Err(error)
17238                    }
17239                }
17240            }
17241        }
17242    }
17243
17244    fn apply_sub_end(
17245        &mut self,
17246        sub_index: usize,
17247        cursor: i64,
17248        bootstrap_state: Option<String>,
17249    ) -> Result<(), String> {
17250        #[cfg(feature = "bench-internals")]
17251        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
17252        let previous = self.subs[sub_index].clone();
17253        self.begin_observation("syncular_sub_end")?;
17254        let persisted = (|| {
17255            let mut batch = ChangeAccumulator::default();
17256            let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
17257                && cursor >= 0
17258                && bootstrap_state.is_none();
17259            if completed {
17260                let table = self
17261                    .schema
17262                    .table(&previous.table)
17263                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
17264                let effective = previous.effective.as_deref().unwrap_or(&[]);
17265                let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
17266                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17267                let rows = statement
17268                    .query_map(rusqlite::params![previous.table, cursor], |row| {
17269                        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
17270                    })
17271                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17272                let mut covered = HashSet::new();
17273                for row in rows {
17274                    let (id, intent) =
17275                        row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17276                    let values: Map<String, Value> = serde_json::from_str(&intent)
17277                        .map_err(|_| "sync.local_corrupt".to_owned())?;
17278                    if !effective.is_empty()
17279                        && effective.iter().all(|(variable, allowed)| {
17280                            table
17281                                .scope_column(variable)
17282                                .and_then(|column| values.get(column))
17283                                .is_some_and(|value| {
17284                                    allowed.contains(
17285                                        &value
17286                                            .as_str()
17287                                            .map_or_else(|| value.to_string(), str::to_owned),
17288                                    )
17289                                })
17290                        })
17291                    {
17292                        covered.insert(id);
17293                    }
17294                }
17295                drop(statement);
17296                for id in covered {
17297                    self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
17298                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17299                    self.overlay_dirty.table(&previous.table);
17300                    batch.table(&previous.table);
17301                }
17302                self.rebuild_overlay_if_dirty()?;
17303                if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
17304                    batch.window(&base_key, &previous.table, &unit);
17305                }
17306            }
17307            let sub = &mut self.subs[sub_index];
17308            sub.cursor = cursor;
17309            sub.bootstrap_state = bootstrap_state;
17310            // §4.6: a completed (or continuing) active end clears the reset /
17311            // revocation reason, matching the TS core's active-state persist.
17312            sub.reason_code = None;
17313            sub.synced_once = true;
17314            self.persist_sub(&self.subs[sub_index])?;
17315            self.finish_observation("syncular_sub_end", batch)
17316        })();
17317        if persisted.is_err() {
17318            self.rollback_observation("syncular_sub_end");
17319            self.subs[sub_index] = previous;
17320        }
17321        persisted
17322    }
17323
17324    fn apply_section_body(
17325        &mut self,
17326        transport: &mut dyn Transport,
17327        sub_index: usize,
17328        body: Vec<Frame>,
17329        fresh: bool,
17330        meta: &RequestMeta,
17331        report: &mut SyncReport,
17332    ) -> Result<(), SectionError> {
17333        let mut saw_segment = false;
17334        for frame in body {
17335            match frame {
17336                Frame::Commit {
17337                    tables,
17338                    changes,
17339                    commit_seq,
17340                    ..
17341                } => {
17342                    self.apply_commit_frame(&tables, &changes, Some(commit_seq))
17343                        .map_err(|(c, m)| SectionError::Abort(c, m))?;
17344                    report.commits_applied += 1;
17345                }
17346                Frame::SegmentInline { payload } => {
17347                    let segment = decode_rows_segment(&payload)
17348                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
17349                    self.progress.update(|p| {
17350                        p.phase = ProgressPhase::Import;
17351                        p.subscription_id = Some(self.subs[sub_index].id.clone());
17352                        p.table = Some(self.subs[sub_index].table.clone());
17353                        p.segment_id = None;
17354                        p.bytes_received = payload.len() as u64;
17355                        p.bytes_total = Some(payload.len() as u64);
17356                        p.rows_processed = 0;
17357                        p.rows_total =
17358                            Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
17359                    });
17360                    let first = !saw_segment;
17361                    saw_segment = true;
17362                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
17363                    report.segment_rows_applied += applied;
17364                }
17365                Frame::SegmentRef {
17366                    segment_id,
17367                    byte_length,
17368                    media_type,
17369                    table,
17370                    row_count,
17371                    as_of_commit_seq,
17372                    scope_digest,
17373                    row_cursor,
17374                    next_row_cursor,
17375                    url,
17376                    url_expires_at_ms,
17377                    ..
17378                } => {
17379                    // §4.2: reject a descriptor whose mediaType was not
17380                    // advertised — never skip or guess.
17381                    let advertised = match media_type {
17382                        MediaType::Rows => {
17383                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
17384                                || meta.accept & ACCEPT_INLINE_ROWS != 0
17385                        }
17386                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
17387                    };
17388                    if !advertised {
17389                        return Err(SectionError::Abort(
17390                            "sync.invalid_request".to_owned(),
17391                            format!(
17392                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
17393                                media_type.name()
17394                            ),
17395                        ));
17396                    }
17397                    self.progress.update(|p| {
17398                        p.phase = ProgressPhase::Download;
17399                        p.subscription_id = Some(self.subs[sub_index].id.clone());
17400                        p.table = Some(table.clone());
17401                        p.segment_id = Some(segment_id.clone());
17402                        p.bytes_received = 0;
17403                        p.bytes_total = u64::try_from(byte_length).ok();
17404                        p.rows_processed = 0;
17405                        p.rows_total = u64::try_from(row_count).ok();
17406                    });
17407                    let observer = self.progress.clone();
17408                    let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
17409                    let bytes = if let Some(url) = url {
17410                        // §5.4: a url-carrying descriptor MUST be fetched
17411                        // from that URL; failure invalidates the whole
17412                        // descriptor (no fall-through to §5.5 — re-pull
17413                        // recovers, §1.4 rule 5).
17414                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
17415                            return Err(SectionError::Abort(
17416                                "sync.invalid_request".to_owned(),
17417                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
17418                                    .to_owned(),
17419                            ));
17420                        }
17421                        // §5.4: MUST NOT start a fetch at/past expiry.
17422                        if url_expires_at_ms.is_some_and(|exp| {
17423                            exp <= transport
17424                                .segment_fetch_started_at(&url)
17425                                .unwrap_or_else(|| self.clock_now_ms())
17426                        }) {
17427                            return Err(SectionError::Abort(
17428                                "sync.segment_expired".to_owned(),
17429                                format!(
17430                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
17431                                ),
17432                            ));
17433                        }
17434                        transport
17435                            .fetch_url(&url, &mut on_progress)
17436                            .map_err(|e| SectionError::Abort(e.code, e.message))?
17437                    } else {
17438                        let requested_scopes_json =
17439                            canonical_scope_json(&self.subs[sub_index].requested);
17440                        transport
17441                            .download_segment(
17442                                &SegmentRequest {
17443                                    segment_id: segment_id.clone(),
17444                                    table,
17445                                    requested_scopes_json,
17446                                },
17447                                &mut on_progress,
17448                            )
17449                            .map_err(|e| SectionError::Abort(e.code, e.message))?
17450                    };
17451                    // §5.1: verify the content address before applying.
17452                    on_progress(bytes.len() as u64);
17453                    let digest = Sha256::digest(&bytes);
17454                    let expected = segment_id
17455                        .strip_prefix("sha256:")
17456                        .unwrap_or(segment_id.as_str());
17457                    if bytes_to_hex(&digest) != expected {
17458                        // §5.1: the client discards the segment and re-pulls.
17459                        self.schedule_background_retry();
17460                        return Err(SectionError::Abort(
17461                            "sync.invalid_request".to_owned(),
17462                            "segment bytes do not match the content address (§5.1)".to_owned(),
17463                        ));
17464                    }
17465                    self.progress.update(|p| p.phase = ProgressPhase::Import);
17466                    if media_type == MediaType::Sqlite {
17467                        // §5.3: images are whole-table — a paged sqlite
17468                        // descriptor is invalid.
17469                        if row_cursor.is_some() || next_row_cursor.is_some() {
17470                            return Err(SectionError::Abort(
17471                                "sync.invalid_request".to_owned(),
17472                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
17473                                    .to_owned(),
17474                            ));
17475                        }
17476                        let first = !saw_segment;
17477                        saw_segment = true;
17478                        let applied = self.apply_sqlite_segment(
17479                            sub_index,
17480                            &bytes,
17481                            fresh && first,
17482                            row_count,
17483                            as_of_commit_seq,
17484                            &scope_digest,
17485                        )?;
17486                        report.segment_rows_applied += applied;
17487                    } else {
17488                        let segment = decode_rows_segment(&bytes).map_err(|e| {
17489                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
17490                        })?;
17491                        let first = row_cursor.is_none();
17492                        saw_segment = true;
17493                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
17494                        report.segment_rows_applied += applied;
17495                    }
17496                }
17497                Frame::Unknown { .. } => {}
17498                Frame::Error { code, message, .. } => {
17499                    return Err(SectionError::Abort(code, message))
17500                }
17501                _ => {
17502                    return Err(SectionError::Abort(
17503                        "sync.invalid_request".to_owned(),
17504                        "unexpected frame inside a subscription section".to_owned(),
17505                    ));
17506                }
17507            }
17508        }
17509        Ok(())
17510    }
17511
17512    // Both pull and realtime frames use the same durable observation boundary.
17513    fn apply_commit_frame(
17514        &mut self,
17515        tables: &[String],
17516        changes: &[ssp2::model::Change],
17517        commit_seq: Option<i64>,
17518    ) -> Result<(), (String, String)> {
17519        #[cfg(feature = "bench-internals")]
17520        let _phase = self.benchmark_phases.start(Phase::CommitApply);
17521        let was_dirty = self.overlay_dirty.snapshot();
17522        self.coalesce_unit(changes.len())
17523            .and_then(|()| self.begin_observation("syncular_commit"))
17524            .map_err(|message| ("storage.failed".into(), message))?;
17525        let applied = (|| {
17526            let mut batch = ChangeAccumulator::default();
17527            self.record_commit_changes(&mut batch, tables, changes);
17528            if let Some(sequence) = commit_seq {
17529                for change in changes {
17530                    let table = tables.get(change.table_index as usize).ok_or_else(|| {
17531                        (
17532                            "sync.invalid_request".to_owned(),
17533                            "change tableIndex out of range".to_owned(),
17534                        )
17535                    })?;
17536                    if self
17537                        .outbox
17538                        .iter()
17539                        .flat_map(|commit| &commit.ops)
17540                        .any(|op| op.table == *table && op.row_id == change.row_id)
17541                    {
17542                        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])
17543                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
17544                    }
17545                    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])
17546                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
17547                }
17548            }
17549            self.apply_commit_changes(tables, changes)?;
17550            self.rebuild_overlay_if_dirty()
17551                .map_err(|message| ("storage.failed".into(), message))?;
17552            self.finish_observation("syncular_commit", batch)
17553                .map_err(|message| ("storage.failed".into(), message))
17554        })();
17555        if applied.is_err() {
17556            self.rollback_observation("syncular_commit");
17557            self.overlay_dirty.restore(was_dirty.clone());
17558        }
17559        applied
17560    }
17561
17562    fn apply_commit_changes(
17563        &mut self,
17564        tables: &[String],
17565        changes: &[ssp2::model::Change],
17566    ) -> Result<(), (String, String)> {
17567        // A clean overlay can reconcile changed primary keys independently
17568        // when their tables have no secondary unique constraints. Such a
17569        // constraint can change whether another row's pending write succeeds,
17570        // so those frames still require the complete FIFO replay below.
17571        let mirror_visible = !self.overlay_dirty.get()
17572            && self.failed_commits.is_empty()
17573            && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
17574                || changes.iter().all(|change| {
17575                    tables
17576                        .get(change.table_index as usize)
17577                        .and_then(|name| self.schema.table(name))
17578                        .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
17579                }));
17580        for change in changes {
17581            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
17582                (
17583                    "sync.invalid_request".to_owned(),
17584                    "change tableIndex out of range".to_owned(),
17585                )
17586            })?;
17587            let table = self.schema.table(table_name).ok_or_else(|| {
17588                (
17589                    "sync.schema_mismatch".to_owned(),
17590                    format!("change targets unknown table {table_name:?}"),
17591                )
17592            })?;
17593            match change.op {
17594                Op::Upsert => {
17595                    let payload = change.row.as_ref().ok_or_else(|| {
17596                        (
17597                            "sync.invalid_request".to_owned(),
17598                            "upsert change without row payload".to_owned(),
17599                        )
17600                    })?;
17601                    // §5.11: decrypt encrypted columns on apply.
17602                    let row = {
17603                        #[cfg(feature = "bench-internals")]
17604                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
17605                        decode_row_bytes(table, payload, &self.encryption)
17606                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?
17607                    };
17608                    let version = change.row_version.unwrap_or(0);
17609                    let table_name = table.name.clone();
17610                    self.write_base_row(&table_name, &row, version)
17611                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
17612                    if mirror_visible {
17613                        self.write_row(&visible_table(&table_name), &table_name, &row, version)
17614                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
17615                    }
17616                }
17617                Op::Delete => {
17618                    self.delete_base_row(table_name, &change.row_id)
17619                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
17620                    if mirror_visible {
17621                        let sql = format!(
17622                            "DELETE FROM {} WHERE {}",
17623                            visible_table(table_name),
17624                            row_id_predicate(table)
17625                        );
17626                        self.conn
17627                            .prepare_cached(&sql)
17628                            .and_then(|mut statement| {
17629                                statement.execute(rusqlite::params![change.row_id])
17630                            })
17631                            .map_err(|error| {
17632                                ("sync.invalid_request".to_owned(), error.to_string())
17633                            })?;
17634                    }
17635                }
17636            }
17637        }
17638        if mirror_visible {
17639            {
17640                let changed_rows: HashSet<(&str, &str)> = changes
17641                    .iter()
17642                    .map(|change| {
17643                        (
17644                            tables[change.table_index as usize].as_str(),
17645                            change.row_id.as_str(),
17646                        )
17647                    })
17648                    .collect();
17649                let acknowledged = self
17650                    .acknowledged_operations(Some(&changed_rows), None)
17651                    .map_err(|error| ("storage.failed".to_owned(), error))?;
17652                self.apply_outbox_ops(&acknowledged)
17653                    .map_err(|message| ("storage.failed".into(), message))?;
17654                self.apply_outbox_ops(
17655                    self.outbox
17656                        .iter()
17657                        .flat_map(|commit| &commit.ops)
17658                        .filter(|op| {
17659                            changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
17660                        }),
17661                )
17662                .map_err(|message| ("storage.failed".into(), message))?;
17663            }
17664            self.overlay_dirty.set(false);
17665        }
17666        Ok(())
17667    }
17668
17669    /// §5.6 segment application: validate against the generated schema,
17670    /// clear the grant on a fresh bootstrap's first page (fail closed
17671    /// without a mapping), then replace-or-upsert each row with its
17672    /// segment-carried server version (§5.2).
17673    fn apply_segment(
17674        &mut self,
17675        sub_index: usize,
17676        segment: &RowsSegment,
17677        first_fresh_page: bool,
17678    ) -> Result<u32, SectionError> {
17679        let (sub_table, effective) = {
17680            let sub = &self.subs[sub_index];
17681            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
17682        };
17683        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
17684            SectionError::Abort(
17685                "sync.schema_mismatch".to_owned(),
17686                format!("subscription table {sub_table:?} is not in the client schema"),
17687            )
17688        })?;
17689        // §5.2: the column table validates against the generated schema —
17690        // order, names, types, nullability; mismatch is fatal. §5.11: the
17691        // server sends the WIRE types (bytes for an encrypted column), so
17692        // validate against wire_columns.
17693        let matches = segment.table == table.name
17694            && segment.schema_version == self.schema.version
17695            && segment.columns.len() == table.wire_columns.len()
17696            && segment
17697                .columns
17698                .iter()
17699                .zip(table.wire_columns.iter())
17700                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
17701        if !matches {
17702            return Err(SectionError::Abort(
17703                "sync.schema_mismatch".to_owned(),
17704                "segment column table does not match the generated schema (§5.2)".to_owned(),
17705            ));
17706        }
17707        let mut applied = 0u32;
17708        let empty = Vec::new();
17709        let blocks = if segment.blocks.is_empty() {
17710            std::slice::from_ref(&empty)
17711        } else {
17712            &segment.blocks
17713        };
17714        for (index, block) in blocks.iter().enumerate() {
17715            let clear = first_fresh_page && index == 0;
17716            if !clear && block.is_empty() {
17717                continue;
17718            }
17719            let was_dirty = self.overlay_dirty.snapshot();
17720            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
17721                && self
17722                    .outbox
17723                    .iter()
17724                    .flat_map(|commit| &commit.ops)
17725                    .any(|op| op.table == table.name);
17726            let mirror_visible = !reconcile_pending
17727                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
17728            self.coalesce_unit(block.len())
17729                .and_then(|()| self.begin_observation("syncular_segment_block"))
17730                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17731            let outcome = (|| {
17732                self.rebuild_overlay_if_dirty()
17733                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17734                let mut changed_keys = Vec::new();
17735                let mut batch = ChangeAccumulator::default();
17736                if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
17737                    batch.table(&table.name);
17738                }
17739                if clear {
17740                    self.purge_scope_rows(&table.name, &effective)
17741                        .map_err(|()| SectionError::FailClosed)?;
17742                }
17743                for (row_index, row) in block.iter().enumerate() {
17744                    let decrypted;
17745                    let values = if table.has_encrypted_columns() {
17746                        let mut values = row.values.clone();
17747                        crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
17748                            .map_err(|message| {
17749                            SectionError::Abort("client.decrypt_failed".into(), message)
17750                        })?;
17751                        decrypted = values;
17752                        &decrypted
17753                    } else {
17754                        &row.values
17755                    };
17756                    self.write_base_row(&table.name, values, row.server_version)
17757                        .map_err(|message| {
17758                            SectionError::Abort("sync.invalid_request".into(), message)
17759                        })?;
17760                    if reconcile_pending {
17761                        changed_keys.push(
17762                            owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
17763                                |message| SectionError::Abort("storage.failed".into(), message),
17764                            )?,
17765                        );
17766                    }
17767                    if mirror_visible {
17768                        self.write_row(
17769                            &visible_table(&table.name),
17770                            &table.name,
17771                            values,
17772                            row.server_version,
17773                        )
17774                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17775                    }
17776                    let processed = u64::from(applied) + row_index as u64 + 1;
17777                    if processed.is_multiple_of(1024) {
17778                        self.progress.update(|p| p.rows_processed = processed);
17779                    }
17780                }
17781                if reconcile_pending {
17782                    self.reconcile_imported_rows(&table, &mut changed_keys)
17783                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17784                }
17785                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
17786                    self.apply_outbox_ops(
17787                        self.outbox
17788                            .iter()
17789                            .flat_map(|commit| &commit.ops)
17790                            .filter(|op| op.table == table.name),
17791                    )
17792                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17793                    self.overlay_dirty.set(false);
17794                } else {
17795                    self.rebuild_overlay_if_dirty()
17796                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17797                }
17798                self.finish_observation("syncular_segment_block", batch)
17799                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
17800            })();
17801            if let Err(error) = outcome {
17802                self.rollback_observation("syncular_segment_block");
17803                self.overlay_dirty.restore(was_dirty.clone());
17804                return Err(error);
17805            }
17806            applied += block.len() as u32;
17807            self.progress
17808                .update(|p| p.rows_processed = u64::from(applied));
17809            std::thread::yield_now();
17810        }
17811        self.progress
17812            .update(|p| p.rows_processed = u64::from(applied));
17813        Ok(applied)
17814    }
17815
17816    /// §5.3 sqlite-image application: validate the in-file metadata
17817    /// against the descriptor, validate column names/order against the
17818    /// generated schema, run the §5.6 first-page clear when fresh, then
17819    /// replace-or-upsert every image row with its `_syncular_version`.
17820    /// Mechanics: the image lands in a temp file read through a second
17821    /// rusqlite connection, equivalent to ATTACH + INSERT…SELECT.
17822    /// Each image chunk commits its writes and revision before readers continue.
17823    fn apply_sqlite_segment(
17824        &mut self,
17825        sub_index: usize,
17826        bytes: &[u8],
17827        first_fresh_page: bool,
17828        row_count: i64,
17829        as_of_commit_seq: i64,
17830        scope_digest: &str,
17831    ) -> Result<u32, SectionError> {
17832        let invalid = |detail: &str| {
17833            SectionError::Abort(
17834                "sync.invalid_request".to_owned(),
17835                format!("sqlite segment rejected: {detail} (§5.3)"),
17836            )
17837        };
17838        let (sub_table, effective) = {
17839            let sub = &self.subs[sub_index];
17840            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
17841        };
17842        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
17843            SectionError::Abort(
17844                "sync.schema_mismatch".to_owned(),
17845                format!("subscription table {sub_table:?} is not in the client schema"),
17846            )
17847        })?;
17848
17849        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
17850        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
17851        let img = match rusqlite::Connection::open_with_flags(
17852            &path,
17853            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
17854        ) {
17855            Ok(conn) => conn,
17856            Err(_) => {
17857                let _ = std::fs::remove_file(&path);
17858                return Err(invalid("bytes do not open as a SQLite database"));
17859            }
17860        };
17861        let outcome = self.apply_sqlite_image(
17862            &img,
17863            &table,
17864            first_fresh_page,
17865            &effective,
17866            row_count,
17867            as_of_commit_seq,
17868            scope_digest,
17869        );
17870        drop(img);
17871        let _ = std::fs::remove_file(&path);
17872        outcome
17873    }
17874
17875    #[allow(clippy::too_many_arguments)]
17876    fn apply_sqlite_image(
17877        &mut self,
17878        img: &rusqlite::Connection,
17879        table: &crate::schema::TableSchema,
17880        first_fresh_page: bool,
17881        effective: &[(String, Vec<String>)],
17882        row_count: i64,
17883        as_of_commit_seq: i64,
17884        scope_digest: &str,
17885    ) -> Result<u32, SectionError> {
17886        let invalid = |detail: String| {
17887            SectionError::Abort(
17888                "sync.invalid_request".to_owned(),
17889                format!("sqlite segment rejected: {detail} (§5.3)"),
17890            )
17891        };
17892
17893        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
17894        //    one row).
17895        type MetaRow = (i64, String, i64, i64, String, i64, i64);
17896        let meta: MetaRow = img
17897            .query_row(
17898                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
17899                        \"scopeDigest\", \"rowCount\",
17900                        (SELECT count(*) FROM _syncular_segment)
17901                 FROM _syncular_segment",
17902                [],
17903                |row| {
17904                    Ok((
17905                        row.get(0)?,
17906                        row.get(1)?,
17907                        row.get(2)?,
17908                        row.get(3)?,
17909                        row.get(4)?,
17910                        row.get(5)?,
17911                        row.get(6)?,
17912                    ))
17913                },
17914            )
17915            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
17916        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
17917        if meta_count != 1 {
17918            return Err(invalid(format!(
17919                "_syncular_segment must contain exactly one row, found {meta_count}"
17920            )));
17921        }
17922        if format != 1 {
17923            return Err(invalid(format!("format {format}")));
17924        }
17925        if meta_table != table.name {
17926            return Err(invalid(format!("image table {meta_table:?}")));
17927        }
17928        if schema_version != i64::from(self.schema.version) {
17929            return Err(invalid(format!("schemaVersion {schema_version}")));
17930        }
17931        if pin != as_of_commit_seq {
17932            return Err(invalid(format!("asOfCommitSeq {pin}")));
17933        }
17934        if digest != scope_digest {
17935            return Err(invalid("scopeDigest mismatch".to_owned()));
17936        }
17937        if meta_rows != row_count {
17938            return Err(invalid(format!("rowCount {meta_rows}")));
17939        }
17940
17941        // 2. Column names and order vs the generated schema (§5.3 rule 3).
17942        let mut names: Vec<String> = Vec::new();
17943        let mut primary_keys: Vec<String> = Vec::new();
17944        {
17945            let mut stmt = img
17946                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
17947                .map_err(|_| invalid("image data table missing".to_owned()))?;
17948            let mut rows = stmt
17949                .query([])
17950                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
17951            while let Some(row) = rows
17952                .next()
17953                .map_err(|_| invalid("image data table unreadable".to_owned()))?
17954            {
17955                if row
17956                    .get::<_, i64>(5)
17957                    .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
17958                    > 0
17959                {
17960                    primary_keys.push(row.get(1).map_err(|error| {
17961                        invalid(Self::sqlite_failure(&self.storage_failure, error))
17962                    })?);
17963                }
17964                names.push(
17965                    row.get::<_, String>(1)
17966                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
17967                );
17968            }
17969        }
17970        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
17971        expected.push("_syncular_version");
17972        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
17973            return Err(SectionError::Abort(
17974                "sync.schema_mismatch".to_owned(),
17975                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
17976            ));
17977        }
17978
17979        if primary_keys != vec![table.primary_key.clone()] {
17980            return Err(invalid(
17981                "image primary key does not match generated schema".into(),
17982            ));
17983        }
17984        let actual_count: i64 = img
17985            .query_row(
17986                &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
17987                [],
17988                |row| row.get(0),
17989            )
17990            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
17991        if actual_count != row_count {
17992            return Err(invalid("image row count does not match descriptor".into()));
17993        }
17994        let insert = self.insert_row_sql(&base_table(&table.name), table);
17995        let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
17996        let column_list = names
17997            .iter()
17998            .map(|n| quote_ident(n))
17999            .collect::<Vec<_>>()
18000            .join(", ");
18001        let mut stmt = img
18002            .prepare(&format!(
18003                "SELECT {column_list} FROM {} ORDER BY {}",
18004                quote_ident(&table.name),
18005                quote_ident(&table.primary_key)
18006            ))
18007            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
18008        let mut rows = stmt
18009            .query([])
18010            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
18011        let mut applied = 0u32;
18012        loop {
18013            let clear = applied == 0 && first_fresh_page;
18014            let was_dirty = self.overlay_dirty.snapshot();
18015            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
18016                && self
18017                    .outbox
18018                    .iter()
18019                    .flat_map(|commit| &commit.ops)
18020                    .any(|op| op.table == table.name);
18021            let mirror_visible = !reconcile_pending
18022                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
18023            // A chunk holds up to 1,024 rows (§5.3).
18024            self.coalesce_unit(1024)
18025                .and_then(|()| self.begin_observation("syncular_image_chunk"))
18026                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18027            let result = (|| {
18028                self.rebuild_overlay_if_dirty()
18029                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18030                let mut changed_keys = Vec::new();
18031                let mut batch = ChangeAccumulator::default();
18032                if clear {
18033                    if self.scoped_rows_exist(&table.name, effective) {
18034                        batch.table(&table.name);
18035                    }
18036                    self.purge_scope_rows(&table.name, effective)
18037                        .map_err(|()| SectionError::FailClosed)?;
18038                }
18039                let mut processed = 0u32;
18040                {
18041                    let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
18042                        invalid(Self::sqlite_failure(&self.storage_failure, error))
18043                    })?;
18044                    let mut mirror = if mirror_visible {
18045                        Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
18046                            invalid(Self::sqlite_failure(&self.storage_failure, error))
18047                        })?)
18048                    } else {
18049                        None
18050                    };
18051                    for _ in 0..1024 {
18052                        let Some(row) = rows.next().map_err(|error| {
18053                            invalid(Self::sqlite_failure(&self.storage_failure, error))
18054                        })?
18055                        else {
18056                            break;
18057                        };
18058                        for (i, column) in table.columns.iter().enumerate() {
18059                            let cell = row.get_ref(i).map_err(|error| {
18060                                invalid(Self::sqlite_failure(&self.storage_failure, error))
18061                            })?;
18062                            let param = image_cell_param(column, cell).map_err(invalid)?;
18063                            ins.raw_bind_parameter(i + 1, &param).map_err(|error| {
18064                                invalid(Self::sqlite_failure(&self.storage_failure, error))
18065                            })?;
18066                            if let Some(mirror) = &mut mirror {
18067                                mirror.raw_bind_parameter(i + 1, &param).map_err(|error| {
18068                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
18069                                })?;
18070                            }
18071                        }
18072                        let version_index = table.columns.len();
18073                        let version: i64 = row.get(version_index).map_err(|error| {
18074                            invalid(Self::sqlite_failure(&self.storage_failure, error))
18075                        })?;
18076                        if version < 1 {
18077                            return Err(invalid("image row version must be positive".into()));
18078                        }
18079                        ins.raw_bind_parameter(version_index + 1, version)
18080                            .map_err(|error| {
18081                                invalid(Self::sqlite_failure(&self.storage_failure, error))
18082                            })?;
18083                        ins.raw_execute().map_err(|error| {
18084                            invalid(Self::sqlite_failure(&self.storage_failure, error))
18085                        })?;
18086                        if let Some(mirror) = &mut mirror {
18087                            mirror
18088                                .raw_bind_parameter(version_index + 1, version)
18089                                .map_err(|error| {
18090                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
18091                                })?;
18092                            mirror.raw_execute().map_err(|error| {
18093                                invalid(Self::sqlite_failure(&self.storage_failure, error))
18094                            })?;
18095                        }
18096                        if reconcile_pending {
18097                            changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
18098                                |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
18099                            )?);
18100                        }
18101                        processed += 1;
18102                    }
18103                }
18104                if processed > 0 {
18105                    batch.table(&table.name);
18106                    self.overlay_dirty.table(&table.name);
18107                }
18108                if reconcile_pending {
18109                    self.reconcile_imported_rows(table, &mut changed_keys)
18110                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18111                }
18112                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
18113                    self.apply_outbox_ops(
18114                        self.outbox
18115                            .iter()
18116                            .flat_map(|commit| &commit.ops)
18117                            .filter(|op| op.table == table.name),
18118                    )
18119                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18120                    self.overlay_dirty.set(false);
18121                } else {
18122                    self.rebuild_overlay_if_dirty()
18123                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18124                }
18125                self.finish_observation("syncular_image_chunk", batch)
18126                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18127                Ok(processed)
18128            })();
18129            let processed = match result {
18130                Ok(processed) => processed,
18131                Err(error) => {
18132                    self.rollback_observation("syncular_image_chunk");
18133                    self.overlay_dirty.restore(was_dirty.clone());
18134                    return Err(error);
18135                }
18136            };
18137            applied += processed;
18138            self.progress
18139                .update(|p| p.rows_processed = u64::from(applied));
18140            std::thread::yield_now();
18141            if i64::from(applied) == actual_count {
18142                break;
18143            }
18144        }
18145        Ok(applied)
18146    }
18147
18148    /// Restore only imported and pending row identities before FIFO replay. All pending
18149    /// identities participate: a server row can free a unique value for a different edit.
18150    /// The caller owns the chunk transaction and publishes its revision after replay.
18151    fn reconcile_imported_rows(
18152        &self,
18153        table: &TableSchema,
18154        keys: &mut Vec<SqlValue>,
18155    ) -> Result<(), String> {
18156        let visible = visible_table(&table.name);
18157        let base = base_table(&table.name);
18158        let pk = quote_ident(&table.primary_key);
18159        let predicate = row_id_predicate(table);
18160        let mut lookup = self.conn.prepare_cached(&format!(
18161            "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
18162        )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18163        let mut deleted = HashSet::new();
18164        for op in self
18165            .outbox
18166            .iter()
18167            .flat_map(|commit| &commit.ops)
18168            .filter(|op| op.table == table.name)
18169        {
18170            if op.upsert {
18171                if let Some(values) = &op.values {
18172                    let key = json_to_column_value(
18173                        &table.columns[table.pk_index],
18174                        values.get(&table.primary_key),
18175                    )?;
18176                    keys.push(owned_sql_value(RowParam::Cell(&key))?);
18177                }
18178            } else if deleted.insert(&op.row_id) {
18179                keys.extend(
18180                    lookup
18181                        .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
18182                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
18183                        .collect::<Result<Vec<_>, _>>()
18184                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
18185                );
18186            }
18187        }
18188        // Delete the complete affected set before restoring any base value, so
18189        // old optimistic unique values cannot collide with the new server rows.
18190        for chunk in keys.chunks(400) {
18191            let holes = vec!["?"; chunk.len()].join(",");
18192            self.conn
18193                .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
18194                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
18195                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18196        }
18197        let updates = table
18198            .columns
18199            .iter()
18200            .map(|column| column.name.as_str())
18201            .chain(std::iter::once("_syncular_version"))
18202            .filter(|name| *name != table.primary_key)
18203            .map(|name| {
18204                let name = quote_ident(name);
18205                format!("{name}=excluded.{name}")
18206            })
18207            .collect::<Vec<_>>()
18208            .join(",");
18209        for chunk in keys.chunks(400) {
18210            let holes = vec!["?"; chunk.len()].join(",");
18211            self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
18212                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
18213                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18214        }
18215        Ok(())
18216    }
18217
18218    // -- application-authorized local purge ---------------------------------------
18219
18220    fn compile_local_data_purge(
18221        &self,
18222        input: &LocalDataPurgeInput,
18223    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
18224        let invalid = |message: String| format!("sync.invalid_request: {message}");
18225        if input.purge_id.is_empty()
18226            || input.purge_id.len() > 128
18227            || !is_local_operation_code_like(&input.purge_id)
18228        {
18229            return Err(invalid(
18230                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
18231            ));
18232        }
18233        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
18234            return Err(invalid(format!(
18235                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
18236            )));
18237        }
18238
18239        let mut deduplicated: BTreeMap<
18240            String,
18241            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
18242        > = BTreeMap::new();
18243        for target in &input.targets {
18244            let table = self.schema.table(&target.table).ok_or_else(|| {
18245                invalid(format!(
18246                    "local purge names unknown table {:?}",
18247                    target.table
18248                ))
18249            })?;
18250            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
18251                return Err(invalid(format!(
18252                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
18253                    target.table
18254                )));
18255            }
18256            let mut selectors = Vec::with_capacity(target.selectors.len());
18257            let mut canonical_selectors = BTreeMap::new();
18258            for (column_name, raw_values) in &target.selectors {
18259                let Some((column_index, column)) = table
18260                    .columns
18261                    .iter()
18262                    .enumerate()
18263                    .find(|(_, column)| column.name == *column_name)
18264                else {
18265                    return Err(invalid(format!(
18266                        "local purge target {:?} names unknown column {:?}",
18267                        target.table, column_name
18268                    )));
18269                };
18270                let encrypted = table
18271                    .encrypted_columns
18272                    .iter()
18273                    .any(|candidate| candidate.index == column_index);
18274                if column.ty != ColumnType::String || encrypted {
18275                    return Err(invalid(format!(
18276                        "local purge selector {:?}.{:?} must be a plaintext string column",
18277                        target.table, column_name
18278                    )));
18279                }
18280                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
18281                    return Err(invalid(format!(
18282                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
18283                        target.table, column_name
18284                    )));
18285                }
18286                let mut values = raw_values.clone();
18287                values.sort();
18288                values.dedup();
18289                if values.iter().any(|value| {
18290                    value.is_empty()
18291                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
18292                        || !is_local_operation_code_like(value)
18293                }) {
18294                    return Err(invalid(format!(
18295                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
18296                    )));
18297                }
18298                selectors.push((column_name.clone(), values.clone()));
18299                canonical_selectors.insert(column_name.clone(), values);
18300            }
18301            let canonical = LocalDataPurgeTarget {
18302                table: target.table.clone(),
18303                selectors: canonical_selectors,
18304            };
18305            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
18306            deduplicated.insert(
18307                key,
18308                (
18309                    CompiledLocalDataPurgeTarget {
18310                        table: target.table.clone(),
18311                        selectors,
18312                    },
18313                    canonical,
18314                ),
18315            );
18316        }
18317        let targets = deduplicated
18318            .values()
18319            .map(|(compiled, _)| compiled.clone())
18320            .collect::<Vec<_>>();
18321        let canonical = deduplicated
18322            .values()
18323            .map(|(_, target)| target.clone())
18324            .collect::<Vec<_>>();
18325        let canonical_plan =
18326            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
18327        Ok((targets, canonical_plan))
18328    }
18329
18330    fn local_purge_base_row_ids(
18331        &self,
18332        targets: &[CompiledLocalDataPurgeTarget],
18333    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
18334        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
18335        for target in targets {
18336            let table = self
18337                .schema
18338                .table(&target.table)
18339                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
18340            let mut clauses = Vec::with_capacity(target.selectors.len());
18341            let mut params = Vec::new();
18342            for (column, values) in &target.selectors {
18343                clauses.push(format!(
18344                    "{} IN ({})",
18345                    quote_ident(column),
18346                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
18347                ));
18348                params.extend(values.iter().cloned().map(SqlValue::Text));
18349            }
18350            let sql = format!(
18351                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
18352                quote_ident(&table.primary_key),
18353                base_table(&target.table),
18354                clauses.join(" AND ")
18355            );
18356            let mut statement = self
18357                .conn
18358                .prepare(&sql)
18359                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18360            let rows = statement
18361                .query_map(rusqlite::params_from_iter(params), |row| {
18362                    let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
18363                    render_row_id_json(Some(&value))
18364                        .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
18365                })
18366                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18367            let ids = by_table.entry(target.table.clone()).or_default();
18368            for row in rows {
18369                ids.insert(
18370                    row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
18371                );
18372            }
18373        }
18374        Ok(by_table)
18375    }
18376
18377    fn local_purge_values_match(
18378        target: &CompiledLocalDataPurgeTarget,
18379        values: &Map<String, Value>,
18380    ) -> bool {
18381        target.selectors.iter().all(|(column, allowed)| {
18382            values
18383                .get(column)
18384                .and_then(Value::as_str)
18385                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
18386        })
18387    }
18388
18389    /// Apply one host-authorized local security purge. The host MUST gate the
18390    /// corresponding subscriptions first; this primitive owns local SQLite
18391    /// cleanup and never grants/revokes server authority by itself.
18392    pub fn purge_local_data(
18393        &mut self,
18394        input: &LocalDataPurgeInput,
18395    ) -> Result<LocalDataPurgeResult, String> {
18396        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
18397        let meta_key = format!("localPurge:{}", input.purge_id);
18398        let applied_plan = self
18399            .conn
18400            .query_row(
18401                "SELECT value FROM _syncular_meta WHERE key = ?1",
18402                rusqlite::params![meta_key],
18403                |row| row.get::<_, String>(0),
18404            )
18405            .optional()
18406            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18407        if let Some(applied_plan) = applied_plan {
18408            if applied_plan != canonical_plan {
18409                return Err(format!(
18410                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
18411                    input.purge_id
18412                ));
18413            }
18414            return Ok(LocalDataPurgeResult {
18415                already_applied: true,
18416                purged_rows: 0,
18417                dropped_commits: 0,
18418            });
18419        }
18420
18421        // RFC 0005 D7: the container file cannot be a purge target (its name is
18422        // not in the running schema), so purge drops it as a fixed step, before
18423        // any mirror row is touched and unconditionally.
18424        let _ = self.drop_previous_version();
18425
18426        let prior_outbox = self.outbox.clone();
18427        let prior_failed = self.failed_commits.clone();
18428        let prior_rejections = self.rejections.clone();
18429        let prior_conflicts = self.conflicts.clone();
18430        let prior_overlay_dirty = self.overlay_dirty.snapshot();
18431        self.begin_observation("syncular_local_purge")?;
18432        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
18433            let row_ids = self.local_purge_base_row_ids(&targets)?;
18434            let retained_dropped = self.drop_failed_commits(|table, values| {
18435                targets.iter().any(|target| {
18436                    target.table == table && Self::local_purge_values_match(target, values)
18437                })
18438            })?;
18439            let doomed = self
18440                .outbox
18441                .iter()
18442                .filter(|commit| {
18443                    commit.ops.iter().any(|operation| {
18444                        let matching_targets = targets
18445                            .iter()
18446                            .filter(|target| target.table == operation.table)
18447                            .collect::<Vec<_>>();
18448                        if matching_targets.is_empty() {
18449                            return false;
18450                        }
18451                        if row_ids
18452                            .get(&operation.table)
18453                            .is_some_and(|ids| ids.contains(&operation.row_id))
18454                        {
18455                            return true;
18456                        }
18457                        operation.values.as_ref().is_some_and(|values| {
18458                            matching_targets
18459                                .iter()
18460                                .any(|target| Self::local_purge_values_match(target, values))
18461                        })
18462                    })
18463                })
18464                .cloned()
18465                .collect::<Vec<_>>();
18466            let doomed_ids = doomed
18467                .iter()
18468                .map(|commit| commit.client_commit_id.clone())
18469                .collect::<BTreeSet<_>>();
18470            let mut batch = ChangeAccumulator::default();
18471            for commit in &retained_dropped {
18472                batch.conflicts = true;
18473                batch.rejections = true;
18474                batch.outcomes = true;
18475                for operation in &commit.ops {
18476                    batch.table(&operation.table);
18477                }
18478            }
18479            let mut rejections = Vec::new();
18480            for commit in &doomed {
18481                for operation in &commit.ops {
18482                    batch.table(&operation.table);
18483                }
18484                let results = commit
18485                    .ops
18486                    .iter()
18487                    .enumerate()
18488                    .map(|(op_index, operation)| {
18489                        let rejection = RejectionRecord {
18490                            client_commit_id: commit.client_commit_id.clone(),
18491                            op_index: op_index as i32,
18492                            code: "client.local_data_purged".to_owned(),
18493                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
18494                            retryable: false,
18495                            details: None,
18496                            operation: Some(CommitOperation::from(operation)),
18497                        };
18498                        rejections.push(rejection.clone());
18499                        CommitOperationOutcome::Error { rejection }
18500                    })
18501                    .collect::<Vec<_>>();
18502                self.persist_commit_outcome(
18503                    &commit.client_commit_id,
18504                    CommitOutcomeStatus::Rejected,
18505                    &results,
18506                    Some(&commit.ops),
18507                )?;
18508                self.delete_outbox_persisted(&commit.client_commit_id)?;
18509            }
18510            if !doomed.is_empty() {
18511                self.outbox
18512                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
18513                self.rejections.extend(rejections);
18514                self.prune_commit_outcomes()?;
18515                batch.status = true;
18516                batch.rejections = true;
18517                batch.outcomes = true;
18518            }
18519
18520            let mut purged_rows = 0usize;
18521            for (table, ids) in &row_ids {
18522                if ids.is_empty() {
18523                    continue;
18524                }
18525                batch.table(table);
18526                for row_id in ids {
18527                    self.delete_base_row(table, row_id)?;
18528                    purged_rows += 1;
18529                }
18530            }
18531            self.rebuild_overlay_if_dirty()?;
18532            self.delete_unreferenced_cached_blobs()?;
18533            self.conn
18534                .execute(
18535                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
18536                    rusqlite::params![meta_key, canonical_plan],
18537                )
18538                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18539            Ok((
18540                batch,
18541                LocalDataPurgeResult {
18542                    already_applied: false,
18543                    purged_rows,
18544                    dropped_commits: doomed.len(),
18545                },
18546            ))
18547        })();
18548        let (batch, result) = match applied {
18549            Ok(value) => value,
18550            Err(error) => {
18551                self.rollback_observation("syncular_local_purge");
18552                self.outbox = prior_outbox;
18553                self.failed_commits = prior_failed;
18554                self.rejections = prior_rejections;
18555                self.conflicts = prior_conflicts;
18556                self.overlay_dirty.restore(prior_overlay_dirty.clone());
18557                return Err(error);
18558            }
18559        };
18560        if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
18561            self.rollback_observation("syncular_local_purge");
18562            self.outbox = prior_outbox;
18563            self.failed_commits = prior_failed;
18564            self.rejections = prior_rejections;
18565            self.conflicts = prior_conflicts;
18566            self.overlay_dirty.restore(prior_overlay_dirty.clone());
18567            return Err(error);
18568        }
18569        self.cancel_sync_round();
18570        Ok(result)
18571    }
18572
18573    /// Application-authorized recovery of the replicated projection. Unlike a
18574    /// local security purge, this retains the entire outbox, device identity,
18575    /// lease, outcomes, subscription registrations, and protected bookkeeping.
18576    /// The projection reset, subscription rewind, optimistic replay, and
18577    /// idempotency marker share one savepoint for interruption safety.
18578    pub fn rebootstrap_local_data(
18579        &mut self,
18580        input: &LocalDataRebootstrapInput,
18581    ) -> Result<LocalDataRebootstrapResult, String> {
18582        if input.rebootstrap_id.is_empty()
18583            || input.rebootstrap_id.len() > 128
18584            || !is_local_operation_code_like(&input.rebootstrap_id)
18585        {
18586            return Err(
18587                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
18588                    .to_owned(),
18589            );
18590        }
18591        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
18592        if let Some(persisted) = self.get_meta_strict(&meta_key)? {
18593            let (retained_commits, reset_subscriptions) =
18594                decode_local_rebootstrap_receipt(&persisted)?;
18595            return Ok(LocalDataRebootstrapResult {
18596                already_applied: true,
18597                retained_commits,
18598                reset_subscriptions,
18599            });
18600        }
18601        if self.stopped || self.schema_floor.is_some() {
18602            return Err(
18603                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
18604                    .to_owned(),
18605            );
18606        }
18607
18608        let retained_commits = self.outbox.len();
18609        let reset_subscriptions = self.subs.len();
18610        let prior_subs = self.subs.clone();
18611        let prior_upgrading = self.upgrading;
18612        let prior_stopped = self.stopped;
18613        let prior_schema_floor = self.schema_floor.clone();
18614        let prior_overlay_dirty = self.overlay_dirty.snapshot();
18615        let prior_sync_needed = self.sync_needed;
18616        let prior_sync_intents = self.sync_intent_queue.clone();
18617        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
18618
18619        let prior_active_round = self.active_round;
18620        self.begin_observation("syncular_local_rebootstrap")?;
18621        let mut batch = ChangeAccumulator::default();
18622        let applied = (|| -> Result<(), String> {
18623            self.run_schema_reset_observed(&mut batch, false)?;
18624            self.conn
18625                .execute(
18626                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
18627                    rusqlite::params![meta_key, receipt],
18628                )
18629                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18630            self.sync_needed = true;
18631            self.sync_intent_queue.push_back(SyncIntent::Interactive);
18632            batch.status = true;
18633            Ok(())
18634        })();
18635        if let Err(error) = applied {
18636            self.rollback_observation("syncular_local_rebootstrap");
18637            self.active_round = prior_active_round;
18638            self.subs = prior_subs;
18639            self.upgrading = prior_upgrading;
18640            self.stopped = prior_stopped;
18641            self.schema_floor = prior_schema_floor;
18642            self.overlay_dirty.restore(prior_overlay_dirty.clone());
18643            self.sync_needed = prior_sync_needed;
18644            self.sync_intent_queue = prior_sync_intents;
18645            return Err(error);
18646        }
18647        if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
18648            self.rollback_observation("syncular_local_rebootstrap");
18649            self.active_round = prior_active_round;
18650            self.subs = prior_subs;
18651            self.upgrading = prior_upgrading;
18652            self.stopped = prior_stopped;
18653            self.schema_floor = prior_schema_floor;
18654            self.overlay_dirty.restore(prior_overlay_dirty.clone());
18655            self.sync_needed = prior_sync_needed;
18656            self.sync_intent_queue = prior_sync_intents;
18657            return Err(error);
18658        }
18659
18660        Ok(LocalDataRebootstrapResult {
18661            already_applied: false,
18662            retained_commits,
18663            reset_subscriptions,
18664        })
18665    }
18666
18667    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
18668
18669    /// Delete base rows matching the effective scopes; `Err(())` = no local
18670    /// scope-column mapping for a key (the fail-closed case).
18671    fn purge_scope_rows(
18672        &mut self,
18673        table_name: &str,
18674        effective: &[(String, Vec<String>)],
18675    ) -> Result<(), ()> {
18676        if effective.is_empty() {
18677            return Ok(());
18678        }
18679        let table = self.schema.table(table_name).ok_or(())?.clone();
18680        let mut clauses = Vec::new();
18681        let mut params: Vec<SqlValue> = Vec::new();
18682        for (variable, values) in effective {
18683            let column = table.scope_column(variable).ok_or(())?;
18684            let placeholders: Vec<String> = values
18685                .iter()
18686                .map(|v| {
18687                    params.push(SqlValue::Text(v.clone()));
18688                    "?".to_owned()
18689                })
18690                .collect();
18691            clauses.push(format!(
18692                "{} IN ({})",
18693                quote_ident(column),
18694                placeholders.join(", ")
18695            ));
18696        }
18697        let sql = format!(
18698            "DELETE FROM {} WHERE {}",
18699            base_table(table_name),
18700            clauses.join(" AND ")
18701        );
18702        self.overlay_dirty.table(table_name);
18703        self.conn
18704            .execute(&sql, rusqlite::params_from_iter(&params))
18705            .map_err(|_| ())?;
18706        self.conn
18707            .execute(
18708                &format!(
18709                    "DELETE FROM {} WHERE {}",
18710                    visible_table(table_name),
18711                    clauses.join(" AND ")
18712                ),
18713                rusqlite::params_from_iter(&params),
18714            )
18715            .map_err(|_| ())?;
18716        Ok(())
18717    }
18718
18719    /// §3.3: drop pending commits whose upserts provably land in the
18720    /// revoked effective scopes — whole-commit, never per-operation.
18721    fn drop_doomed_outbox(
18722        &mut self,
18723        table_name: &str,
18724        effective: &[(String, Vec<String>)],
18725    ) -> Result<bool, String> {
18726        if effective.is_empty() {
18727            return Ok(false);
18728        }
18729        let Some(table) = self.schema.table(table_name).cloned() else {
18730            return Ok(false);
18731        };
18732        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
18733        for (variable, values) in effective {
18734            match table.scope_column(variable) {
18735                Some(column) => mappings.push((column, values)),
18736                None => return Ok(false), // not provable without a mapping
18737            }
18738        }
18739        let doomed: Vec<OutboxCommit> = self
18740            .outbox
18741            .iter()
18742            .filter(|commit| {
18743                commit.ops.iter().any(|op| {
18744                    op.upsert
18745                        && op.table == table_name
18746                        && op.values.as_ref().is_some_and(|values| {
18747                            mappings
18748                                .iter()
18749                                .all(|(column, allowed)| match values.get(*column) {
18750                                    Some(Value::String(s)) => allowed.contains(s),
18751                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
18752                                    Some(Value::Bool(b)) => allowed.contains(&b.to_string()),
18753                                    _ => false,
18754                                })
18755                        })
18756                })
18757            })
18758            .cloned()
18759            .collect();
18760        if doomed.is_empty() {
18761            return Ok(false);
18762        }
18763        let mut rejections = Vec::new();
18764        for commit in &doomed {
18765            let results = commit
18766                .ops
18767                .iter()
18768                .enumerate()
18769                .map(|(op_index, operation)| {
18770                    let rejection = RejectionRecord {
18771                        client_commit_id: commit.client_commit_id.clone(),
18772                        op_index: op_index as i32,
18773                        code: "sync.scope_revoked".to_owned(),
18774                        message: "the commit was dropped because its effective scope was revoked"
18775                            .to_owned(),
18776                        retryable: false,
18777                        details: None,
18778                        operation: Some(CommitOperation::from(operation)),
18779                    };
18780                    rejections.push(rejection.clone());
18781                    CommitOperationOutcome::Error { rejection }
18782                })
18783                .collect::<Vec<_>>();
18784            self.persist_commit_outcome(
18785                &commit.client_commit_id,
18786                CommitOutcomeStatus::Rejected,
18787                &results,
18788                Some(&commit.ops),
18789            )?;
18790            self.delete_outbox_persisted(&commit.client_commit_id)?;
18791        }
18792        self.prune_commit_outcomes()?;
18793        let doomed_ids = doomed
18794            .iter()
18795            .map(|commit| commit.client_commit_id.as_str())
18796            .collect::<BTreeSet<_>>();
18797        self.outbox
18798            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
18799        self.rejections.extend(rejections);
18800        Ok(true)
18801    }
18802
18803    // -- blobs (§5.9) ----------------------------------------------------------------
18804
18805    /// §5.9.7: hash bytes into the content address, cache them, queue the
18806    /// upload (flushed before the next push, B4). Returns the canonical
18807    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
18808    /// column value.
18809    pub fn upload_blob(
18810        &mut self,
18811        bytes: &[u8],
18812        media_type: Option<String>,
18813        name: Option<String>,
18814    ) -> Result<Value, String> {
18815        let blob_id = blob_id_for(bytes);
18816        let now = self.clock_now_ms();
18817        let tx = self
18818            .conn
18819            .transaction()
18820            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18821        tx.execute(
18822            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
18823             ON CONFLICT(blob_id) DO NOTHING",
18824            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
18825        )
18826        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18827        tx.execute(
18828            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
18829            rusqlite::params![blob_id, media_type, now],
18830        )
18831        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18832        tx.commit()
18833            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18834        self.enforce_blob_cache_cap();
18835        let mut obj = Map::new();
18836        obj.insert("blobId".to_owned(), Value::from(blob_id));
18837        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
18838        if let Some(mt) = media_type {
18839            obj.insert("mediaType".to_owned(), Value::from(mt));
18840        }
18841        if let Some(n) = name {
18842            obj.insert("name".to_owned(), Value::from(n));
18843        }
18844        Ok(Value::Object(obj))
18845    }
18846
18847    /// §5.9.7: resolve an owned blob body without driver encoding. A
18848    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
18849    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
18850    pub fn fetch_blob_bytes(
18851        &mut self,
18852        transport: &mut dyn Transport,
18853        blob_id_or_ref: &str,
18854    ) -> Result<FetchedBlob, (String, String)> {
18855        let simple = |m: String| ("client.failed".to_owned(), m);
18856        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
18857            blob_id_or_ref.to_owned()
18858        } else {
18859            let value: Value = serde_json::from_str(blob_id_or_ref)
18860                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
18861            value
18862                .get("blobId")
18863                .and_then(Value::as_str)
18864                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
18865                .to_owned()
18866        };
18867        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
18868            return Ok(cached);
18869        }
18870        if !self.transport_enabled {
18871            return Err(("sync.offline".into(), "transport gate is closed".into()));
18872        }
18873        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
18874        // blob.not_found) verbatim so the harness can assert on it. The
18875        // authorized endpoint serves bytes inline OR (always-issue, presign
18876        // configured) a signed url the client fetches directly — no host auth,
18877        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
18878        #[cfg(feature = "bench-internals")]
18879        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
18880        let bytes = match transport
18881            .blob_download(&blob_id)
18882            .map_err(|e| (e.code, e.message))?
18883        {
18884            BlobDownload::Bytes(bytes) => bytes,
18885            BlobDownload::Url {
18886                url,
18887                url_expires_at_ms,
18888            } => {
18889                // §5.9.5: MUST NOT start a fetch at/past expiry.
18890                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
18891                    return Err((
18892                        "sync.segment_expired".to_owned(),
18893                        format!(
18894                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
18895                        ),
18896                    ));
18897                }
18898                transport
18899                    .fetch_blob_url(&url)
18900                    .map_err(|e| (e.code, e.message))?
18901            }
18902        };
18903        #[cfg(feature = "bench-internals")]
18904        drop(download_phase);
18905        #[cfg(feature = "bench-internals")]
18906        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
18907        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
18908        if blob_id_for(&bytes) != blob_id {
18909            return Err(simple(format!(
18910                "blob content address mismatch for {blob_id}"
18911            )));
18912        }
18913        #[cfg(feature = "bench-internals")]
18914        drop(validate_phase);
18915        #[cfg(feature = "bench-internals")]
18916        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
18917        let now = self.clock_now_ms();
18918        self.conn
18919            .execute(
18920                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
18921                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
18922            )
18923            .map_err(|e| simple(e.to_string()))?;
18924        self.conn
18925            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
18926            .map_err(|e| simple(e.to_string()))?;
18927        self.enforce_blob_cache_cap();
18928        #[cfg(feature = "bench-internals")]
18929        drop(insert_phase);
18930        self.get_cached_blob_bytes(&blob_id)
18931            .map_err(simple)?
18932            .ok_or_else(|| simple("blob cache write failed".to_owned()))
18933    }
18934
18935    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
18936        #[cfg(feature = "bench-internals")]
18937        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
18938        let mut stmt = self
18939            .conn
18940            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
18941            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18942        let mut rows = stmt
18943            .query(rusqlite::params![blob_id])
18944            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18945        if let Some(row) = rows
18946            .next()
18947            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
18948        {
18949            let bytes: Vec<u8> = row
18950                .get(0)
18951                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18952            let byte_length: i64 = row
18953                .get(1)
18954                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18955            let media_type: Option<String> = row
18956                .get(2)
18957                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18958            return Ok(Some(FetchedBlob {
18959                blob_id: blob_id.to_owned(),
18960                byte_length,
18961                bytes,
18962                media_type,
18963            }));
18964        }
18965        Ok(None)
18966    }
18967
18968    /// §5.9.7 B4: upload every queued blob before push.
18969    fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
18970        let pending: Vec<(String, Option<String>)> = {
18971            let mut stmt = self
18972                .conn
18973                .prepare(
18974                    "SELECT blob_id, media_type FROM (
18975                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
18976                      UNION ALL
18977                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
18978                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
18979                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
18980                    ) ORDER BY created_at_ms, blob_id",
18981                )
18982                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18983            let rows = stmt
18984                .query_map([], |row| {
18985                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
18986                })
18987                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18988            rows.collect::<Result<_, _>>()
18989                .map_err(|error| match error {
18990                    rusqlite::Error::InvalidColumnType(..)
18991                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
18992                        "sync.local_corrupt",
18993                        "Pending blob upload metadata is invalid",
18994                    ),
18995                    _ => TransportError::new("client.failed", error.to_string()),
18996                })?
18997        };
18998        let mut uploads = Vec::new();
18999        for (blob_id, media_type) in pending {
19000            let bytes = (|| {
19001                let stored: Option<(SqlValue, SqlValue)> = self
19002                    .conn
19003                    .query_row(
19004                        "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
19005                        rusqlite::params![blob_id],
19006                        |row| Ok((row.get(0)?, row.get(1)?)),
19007                    )
19008                    .optional()
19009                    .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19010                let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
19011                    return Err(TransportError::new(
19012                        "sync.local_corrupt",
19013                        "Pending blob upload body is missing or corrupt",
19014                    ));
19015                };
19016                if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
19017                    return Err(TransportError::new(
19018                        "sync.local_corrupt",
19019                        "Pending blob upload body is missing or corrupt",
19020                    ));
19021                }
19022                Ok(bytes)
19023            })();
19024            let failed = bytes.is_err();
19025            uploads.push(crate::round::PendingUpload {
19026                id: blob_id,
19027                bytes,
19028                media_type,
19029            });
19030            if failed {
19031                break;
19032            }
19033        }
19034        Ok(uploads)
19035    }
19036
19037    #[cfg(test)]
19038    fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
19039        for upload in self.pending_blob_uploads()? {
19040            self.upload_one(
19041                transport,
19042                &upload.id,
19043                &upload.bytes?,
19044                upload.media_type.as_deref(),
19045            )?;
19046            self.conn
19047                .execute(
19048                    "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
19049                    rusqlite::params![upload.id],
19050                )
19051                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19052        }
19053        Ok(())
19054    }
19055
19056    /// §5.9.3: upload one blob, preferring the presigned direct-to-storage
19057    /// grant when the transport supports it, else streaming through the direct
19058    /// host-authenticated endpoint (capability, not fallback). A `Url` grant
19059    /// PUTs direct with no host auth; on a grant PUT failure the client streams
19060    /// through the direct endpoint — a *different, host-authenticated
19061    /// capability*, not a fall-through of the grant's authority.
19062    #[cfg(test)]
19063    fn upload_one(
19064        &self,
19065        transport: &mut dyn Transport,
19066        blob_id: &str,
19067        bytes: &[u8],
19068        media_type: Option<&str>,
19069    ) -> Result<(), TransportError> {
19070        crate::round::upload_one(transport, blob_id, bytes, media_type, self.now_ms)
19071    }
19072
19073    fn visible_blob_ids_sql(&self) -> String {
19074        let mut selects = Vec::new();
19075        for table in &self.schema.tables {
19076            for column in table
19077                .columns
19078                .iter()
19079                .filter(|column| column.ty == ColumnType::BlobRef)
19080            {
19081                let identifier = quote_ident(&column.name);
19082                selects.push(format!(
19083                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
19084                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
19085                     AND json_type({identifier}, '$.blobId') = 'text'",
19086                    quote_ident(&table.name),
19087                ));
19088            }
19089        }
19090        if selects.is_empty() {
19091            "SELECT NULL AS blob_id WHERE 0".to_owned()
19092        } else {
19093            selects.join(" UNION ")
19094        }
19095    }
19096
19097    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
19098    /// staged uploads pin bodies directly; no derived refcount is stored.
19099    fn enforce_blob_cache_cap(&self) {
19100        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
19101            return;
19102        };
19103        let mut total: i64 = self
19104            .conn
19105            .query_row(
19106                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
19107                [],
19108                |row| row.get(0),
19109            )
19110            .unwrap_or(0);
19111        if total <= max_bytes {
19112            return;
19113        }
19114        let query = format!(
19115            "SELECT blob_id, byte_length FROM _syncular_blobs
19116             WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
19117               AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
19118               AND blob_id NOT IN ({})
19119             ORDER BY created_at_ms ASC, blob_id ASC",
19120            self.visible_blob_ids_sql(),
19121        );
19122        let candidates: Vec<(String, i64)> = {
19123            let Ok(mut stmt) = self.conn.prepare(&query) else {
19124                return;
19125            };
19126            let Ok(rows) = stmt.query_map([], |row| {
19127                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
19128            }) else {
19129                return;
19130            };
19131            rows.filter_map(Result::ok).collect()
19132        };
19133        for (blob_id, byte_length) in candidates {
19134            if total <= max_bytes {
19135                break;
19136            }
19137            let _ = self.conn.execute(
19138                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
19139                rusqlite::params![blob_id],
19140            );
19141            total -= byte_length;
19142        }
19143    }
19144
19145    fn delete_unreferenced_cached_blobs(&mut self) -> Result<(), String> {
19146        #[cfg(feature = "bench-internals")]
19147        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
19148        if !self.schema_has_blobs() {
19149            return Ok(());
19150        }
19151        self.rebuild_overlay_if_dirty()?;
19152        self.conn
19153            .execute(
19154                &format!(
19155                    "DELETE FROM _syncular_blobs
19156                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
19157                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
19158                   AND blob_id NOT IN ({})",
19159                    self.visible_blob_ids_sql(),
19160                ),
19161                [],
19162            )
19163            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19164        Ok(())
19165    }
19166
19167    // -- local row storage ----------------------------------------------------------
19168
19169    /// The cached per-table primary-key upsert SQL for `full_table` (see
19170    /// the `insert_sql` field: built once, reused per row).
19171    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
19172        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
19173            return sql.clone();
19174        }
19175        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
19176        columns.push(quote_ident("_syncular_version"));
19177        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
19178        let primary_key = quote_ident(&table.primary_key);
19179        let updates = columns
19180            .iter()
19181            .filter(|column| **column != primary_key)
19182            .map(|column| format!("{column}=excluded.{column}"))
19183            .collect::<Vec<_>>()
19184            .join(", ");
19185        let sql = format!(
19186            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
19187            columns.join(", "),
19188            placeholders.join(", ")
19189        );
19190        self.insert_sql
19191            .borrow_mut()
19192            .insert(full_table.to_owned(), sql.clone());
19193        sql
19194    }
19195
19196    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
19197        self.overlay_dirty.table(table_name);
19198        self.write_row(&base_table(table_name), table_name, row, version)
19199    }
19200
19201    fn write_row(
19202        &self,
19203        full_table: &str,
19204        table_name: &str,
19205        row: &Row,
19206        version: i64,
19207    ) -> Result<(), String> {
19208        #[cfg(feature = "bench-internals")]
19209        let _phase = self.benchmark_phases.start(Phase::RowWrite);
19210        let table = self
19211            .schema
19212            .table(table_name)
19213            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
19214        let sql = self.insert_row_sql(full_table, table);
19215        let mut stmt = self
19216            .conn
19217            .prepare_cached(&sql)
19218            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19219        let params = row
19220            .iter()
19221            .map(RowParam::Cell)
19222            .chain(std::iter::once(RowParam::Version(version)));
19223        stmt.execute(rusqlite::params_from_iter(params))
19224            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19225        Ok(())
19226    }
19227
19228    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
19229        let table = self
19230            .schema
19231            .table(table_name)
19232            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
19233        self.overlay_dirty.table(table_name);
19234        let sql = format!(
19235            "DELETE FROM {} WHERE {}",
19236            base_table(table_name),
19237            row_id_predicate(table)
19238        );
19239        let mut stmt = self
19240            .conn
19241            .prepare_cached(&sql)
19242            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19243        stmt.execute(rusqlite::params![row_id])
19244            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19245        Ok(())
19246    }
19247
19248    fn has_acknowledged_rows(&self) -> bool {
19249        self.conn
19250            .query_row(
19251                "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
19252                [],
19253                |row| row.get(0),
19254            )
19255            .expect("read acknowledged intent")
19256    }
19257
19258    fn acknowledged_operations(
19259        &self,
19260        rows: Option<&HashSet<(&str, &str)>>,
19261        tables: Option<&BTreeSet<&str>>,
19262    ) -> Result<Vec<OutboxOp>, String> {
19263        let keys = rows.map(|rows| {
19264            serde_json::to_string(
19265                &rows
19266                    .iter()
19267                    .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
19268                    .collect::<Vec<_>>(),
19269            )
19270            .expect("row keys")
19271        });
19272        let mut statement = self
19273            .conn
19274            .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")
19275            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19276        let rows = statement
19277            .query_map(
19278                rusqlite::params![
19279                    keys,
19280                    tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
19281                ],
19282                |row| {
19283                    #[cfg(test)]
19284                    self.acknowledged_replay_count
19285                        .set(self.acknowledged_replay_count.get() + 1);
19286                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
19287                },
19288            )
19289            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19290        rows.map(|row| {
19291            let (op, intent) =
19292                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19293            let op: CommitOperation =
19294                serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
19295            let table = self
19296                .schema
19297                .table(&op.table)
19298                .ok_or_else(|| "sync.unknown_table".to_owned())?;
19299            let values = if op.op == "upsert" && self.visible_row(table, &op.row_id)?.is_none() {
19300                Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
19301            } else {
19302                op.values
19303            };
19304            Ok(OutboxOp {
19305                table: op.table,
19306                row_id: op.row_id,
19307                upsert: op.op == "upsert",
19308                base_version: op.base_version,
19309                values,
19310            })
19311        })
19312        .collect()
19313    }
19314
19315    fn drop_acknowledged_rows(
19316        &self,
19317        matches: impl Fn(&str, &Map<String, Value>) -> bool,
19318    ) -> Result<Vec<OutboxCommit>, String> {
19319        let mut statement = self
19320            .conn
19321            .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
19322            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19323        let rows = statement
19324            .query_map([], |row| {
19325                Ok((
19326                    row.get::<_, String>(0)?,
19327                    row.get::<_, String>(1)?,
19328                    row.get::<_, String>(2)?,
19329                    row.get::<_, String>(3)?,
19330                ))
19331            })
19332            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19333        let mut doomed = HashSet::new();
19334        for row in rows {
19335            let (id, table, intent, row_id) =
19336                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19337            let values =
19338                serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
19339            let base_matches = self
19340                .schema
19341                .table(&table)
19342                .map(|schema| self.stored_row(schema, &row_id, true))
19343                .transpose()?
19344                .flatten()
19345                .is_some_and(|(base, _)| {
19346                    let schema = self.schema.table(&table).expect("acknowledged table");
19347                    let values = schema
19348                        .columns
19349                        .iter()
19350                        .zip(base.iter())
19351                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
19352                        .collect();
19353                    matches(&table, &values)
19354                });
19355            if matches(&table, &values) || base_matches {
19356                doomed.insert(id);
19357            }
19358        }
19359        let mut dropped = Vec::new();
19360        for id in doomed {
19361            let mut statement = self
19362                .conn
19363                .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
19364                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19365            let operations = statement
19366                .query_map([&id], |row| row.get::<_, String>(0))
19367                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19368            let mut ops = Vec::new();
19369            for operation in operations {
19370                let json = operation
19371                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19372                let op: CommitOperation =
19373                    serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
19374                self.overlay_dirty.table(&op.table);
19375                ops.push(OutboxOp {
19376                    table: op.table,
19377                    row_id: op.row_id,
19378                    upsert: op.op == "upsert",
19379                    base_version: op.base_version,
19380                    values: op.values,
19381                });
19382            }
19383            dropped.push(OutboxCommit {
19384                client_commit_id: id.clone(),
19385                ops,
19386            });
19387            self.conn
19388                .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
19389                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19390        }
19391        Ok(dropped)
19392    }
19393
19394    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
19395    /// the outbox changed since the last rebuild (the no-op sync round).
19396    fn rebuild_overlay_if_dirty(&mut self) -> Result<(), String> {
19397        if self.overlay_dirty.get() {
19398            self.rebuild_overlay()?;
19399        }
19400        Ok(())
19401    }
19402
19403    /// §7.1: local reads see outbox state applied optimistically — rebuild
19404    /// every visible table as (base server state) + (pending outbox replay
19405    /// on top). Optimistic rows carry version `-1`.
19406    fn rebuild_overlay(&mut self) -> Result<(), String> {
19407        #[cfg(feature = "bench-internals")]
19408        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
19409        #[cfg(test)]
19410        self.overlay_rebuild_count
19411            .set(self.overlay_rebuild_count.get() + 1);
19412        let dirty = self.overlay_dirty.snapshot();
19413        let tables: Vec<_> = self
19414            .schema
19415            .tables
19416            .iter()
19417            .filter(|table| {
19418                dirty
19419                    .as_ref()
19420                    .is_none_or(|names| names.is_empty() || names.contains(&table.name))
19421            })
19422            .cloned()
19423            .collect();
19424        let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
19425        self.begin_observation("syncular_overlay")?;
19426        let rebuilt = (|| {
19427            for table in &tables {
19428                for index in &table.fts_indexes {
19429                    self.drop_fts_triggers(index)?;
19430                }
19431                let visible = visible_table(&table.name);
19432                let base = base_table(&table.name);
19433                self.conn
19434                    .execute_batch(&format!(
19435                        "DELETE FROM {visible}; INSERT INTO {visible} SELECT * FROM {base}"
19436                    ))
19437                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19438            }
19439            let acknowledged = self.acknowledged_operations(None, Some(&names))?;
19440            self.apply_outbox_ops(
19441                acknowledged
19442                    .iter()
19443                    .filter(|op| names.contains(op.table.as_str())),
19444            )?;
19445            for (commit, initial) in &self.failed_commits {
19446                for operation in &commit.ops {
19447                    if !names.contains(operation.table.as_str()) || !operation.upsert {
19448                        continue;
19449                    }
19450                    if let Some(table) = self.schema.table(&operation.table) {
19451                        if self.visible_row(table, &operation.row_id)?.is_none()
19452                            && operation.values.as_ref().is_some_and(|values| {
19453                                table
19454                                    .columns
19455                                    .iter()
19456                                    .all(|column| values.contains_key(&column.name))
19457                            })
19458                        {
19459                            let key = serde_json::to_string(&(
19460                                operation.table.clone(),
19461                                operation.row_id.clone(),
19462                            ))
19463                            .expect("row key serialization");
19464                            if let Some(Value::Object(values)) = initial.get(&key) {
19465                                self.apply_outbox_ops(&[OutboxOp {
19466                                    values: Some(values.clone()),
19467                                    ..operation.clone()
19468                                }])?;
19469                            }
19470                        }
19471                    }
19472                }
19473                self.apply_outbox_ops(
19474                    commit
19475                        .ops
19476                        .iter()
19477                        .filter(|op| names.contains(op.table.as_str())),
19478                )?;
19479            }
19480            for commit in &self.outbox {
19481                self.apply_outbox_ops(
19482                    commit
19483                        .ops
19484                        .iter()
19485                        .filter(|op| names.contains(op.table.as_str())),
19486                )?;
19487            }
19488            for table in &tables {
19489                for index in &table.fts_indexes {
19490                    self.rebuild_fts_projection(table, index)?;
19491                    self.create_fts_triggers(table, index)?;
19492                }
19493            }
19494            self.conn
19495                .execute_batch("RELEASE syncular_overlay")
19496                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19497            Ok(())
19498        })();
19499        if rebuilt.is_err() {
19500            self.conn
19501                .execute_batch("ROLLBACK TO syncular_overlay; RELEASE syncular_overlay")
19502                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19503        } else {
19504            self.overlay_dirty.set(false);
19505        }
19506        rebuilt
19507    }
19508
19509    /// §7.1 replay: every operation is applied over the current visible
19510    /// state. An operation that no longer applies (a server row now holds its
19511    /// unique value) stays pending and invisible until a later replay admits
19512    /// it or the server answers its push.
19513    fn apply_outbox_ops<'a>(
19514        &self,
19515        ops: impl IntoIterator<Item = &'a OutboxOp>,
19516    ) -> Result<(), String> {
19517        #[cfg(feature = "bench-internals")]
19518        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
19519        for op in ops {
19520            #[cfg(feature = "bench-internals")]
19521            if let Some(phase) = &mut phase {
19522                phase.unit();
19523            }
19524            if op.upsert {
19525                if let (Some(table), Some(values)) = (
19526                    self.schema
19527                        .table(&op.table)
19528                        .filter(|table| table.indexes.iter().any(|index| index.unique)),
19529                    op.values.as_ref(),
19530                ) {
19531                    let mut intended: Map<String, Value> = self
19532                        .visible_row(table, &op.row_id)?
19533                        .map(|(row, _)| {
19534                            table
19535                                .columns
19536                                .iter()
19537                                .zip(&row)
19538                                .map(|(column, value)| {
19539                                    (column.name.clone(), column_value_to_json(value))
19540                                })
19541                                .collect()
19542                        })
19543                        .unwrap_or_default();
19544                    intended.extend(values.clone());
19545                    if !self.unique_conflicts(table, &intended, false)?.is_empty() {
19546                        continue;
19547                    }
19548                }
19549            }
19550            self.apply_outbox_op(op)?;
19551        }
19552        Ok(())
19553    }
19554
19555    fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
19556        let Some(table) = self.schema.table(&op.table) else {
19557            return Ok(());
19558        };
19559        let visible = visible_table(&table.name);
19560        if !op.upsert {
19561            let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
19562            self.conn
19563                .prepare_cached(&sql)
19564                .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
19565                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19566            return Ok(());
19567        }
19568        let Some(values) = op.values.as_ref() else {
19569            return Ok(());
19570        };
19571        // §7.1: the overlay applies the operation's PRESENT columns
19572        // over the current local row. A partial operation over an
19573        // absent local row leaves it absent — the server answers it
19574        // with sync.row_deleted or sync.row_missing (§6.2).
19575        let full = table
19576            .columns
19577            .iter()
19578            .all(|column| values.contains_key(&column.name));
19579        let local = self.visible_row(table, &op.row_id)?;
19580        if local.is_none() && !full {
19581            return Ok(());
19582        }
19583        let mut row: Row = Vec::with_capacity(table.columns.len());
19584        for (index, column) in table.columns.iter().enumerate() {
19585            let incoming = match values.get(&column.name) {
19586                Some(raw) => json_to_column_value(
19587                    if raw.get("$bytes").is_some()
19588                        && table
19589                            .encrypted_columns
19590                            .iter()
19591                            .any(|encrypted| encrypted.index == index)
19592                    {
19593                        &table.wire_columns[index]
19594                    } else {
19595                        column
19596                    },
19597                    Some(raw),
19598                ),
19599                None => Ok(local
19600                    .as_ref()
19601                    .and_then(|(values, _)| values.get(index).cloned())
19602                    .flatten()),
19603            };
19604            row.push(incoming.map_err(|_| {
19605                "sync.local_corrupt: persisted optimistic row value is invalid".to_owned()
19606            })?);
19607        }
19608        let version = local.as_ref().map_or(-1, |(_, version)| *version);
19609        self.write_row(&visible, &table.name, &row, version)
19610            .map_err(|error| {
19611                if error.starts_with("UNIQUE constraint failed:") {
19612                    "sync.constraint_violation: local write violates a unique constraint".to_owned()
19613                } else {
19614                    error
19615                }
19616            })
19617    }
19618
19619    /// The visible (optimistic) row's values plus version, or `None` when the
19620    /// row is absent from the overlay.
19621    fn visible_row(&self, table: &TableSchema, row_id: &str) -> Result<Option<(Row, i64)>, String> {
19622        self.stored_row(table, row_id, false)
19623    }
19624
19625    fn stored_row(
19626        &self,
19627        table: &TableSchema,
19628        row_id: &str,
19629        base: bool,
19630    ) -> Result<Option<(Row, i64)>, String> {
19631        let sql = format!(
19632            "SELECT * FROM {} WHERE {}",
19633            if base {
19634                base_table(&table.name)
19635            } else {
19636                visible_table(&table.name)
19637            },
19638            row_id_predicate(table)
19639        );
19640        let mut stmt = self
19641            .conn
19642            .prepare_cached(&sql)
19643            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19644        let mut rows = stmt
19645            .query(rusqlite::params![row_id])
19646            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19647        let Some(row) = rows
19648            .next()
19649            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
19650        else {
19651            return Ok(None);
19652        };
19653        let mut values = Vec::with_capacity(table.columns.len());
19654        for (index, column) in table.columns.iter().enumerate() {
19655            let raw = row
19656                .get_ref(index)
19657                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19658            if let rusqlite::types::ValueRef::Blob(bytes) = raw {
19659                if table
19660                    .encrypted_columns
19661                    .iter()
19662                    .any(|encrypted| encrypted.index == index)
19663                {
19664                    values.push(Some(ColumnValue::Bytes(bytes.to_vec())));
19665                    continue;
19666                }
19667            }
19668            let json = sql_ref_to_json(column, raw);
19669            values.push(json_to_column_value(column, Some(&json)).map_err(|_| {
19670                "sync.local_corrupt: persisted local row value is invalid".to_owned()
19671            })?);
19672        }
19673        let version: i64 = row
19674            .get(table.columns.len())
19675            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19676        Ok(Some((values, version)))
19677    }
19678
19679    // -- realtime (§8) ---------------------------------------------------------------
19680
19681    /// §8.8: the configured realtime policy.
19682    #[must_use]
19683    pub fn realtime_policy(&self) -> RealtimePolicy {
19684        self.realtime_policy
19685    }
19686
19687    /// §8.8: the explicit realtime availability state.
19688    #[must_use]
19689    pub fn realtime_state(&self) -> RealtimeState {
19690        self.realtime_state
19691    }
19692
19693    /// §8.8: set the realtime policy. Refused while the socket is connected:
19694    /// a policy change never silently re-routes an ownership state.
19695    pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
19696        if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
19697            return Err(
19698                "sync.invalid_request: disconnect realtime before changing the realtime policy"
19699                    .to_owned(),
19700            );
19701        }
19702        self.realtime_policy = policy;
19703        match policy {
19704            RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
19705            RealtimePolicy::Required | RealtimePolicy::Optional
19706                if self.realtime_state == RealtimeState::Disabled =>
19707            {
19708                self.set_realtime_state(RealtimeState::Disconnected, None);
19709            }
19710            RealtimePolicy::Required | RealtimePolicy::Optional => {}
19711        }
19712        Ok(())
19713    }
19714
19715    /// Record an availability transition, keeping the reason code bounded and
19716    /// code-like (§7.6) and clearing it where it cannot apply.
19717    fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
19718        self.realtime_state = state;
19719        self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
19720        if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
19721            self.realtime_reason_code = None;
19722            self.realtime_retry_delay_ms = None;
19723        }
19724    }
19725
19726    #[must_use]
19727    fn realtime_connected(&self) -> bool {
19728        self.realtime_state == RealtimeState::Connected
19729    }
19730
19731    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
19732        if !self.transport_enabled {
19733            return Err("sync.offline: transport gate is closed".into());
19734        }
19735        if self.realtime_policy == RealtimePolicy::Off {
19736            return Err(
19737                "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
19738            );
19739        }
19740        if self.realtime_connected() {
19741            return Ok(());
19742        }
19743        self.realtime_state = RealtimeState::Connecting;
19744        match transport.realtime_connect_for_client(&self.client_id) {
19745            Ok(()) => {
19746                self.set_realtime_state(RealtimeState::Connected, None);
19747                Ok(())
19748            }
19749            Err(error) => {
19750                self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
19751                Err(format!("{}: {}", error.code, error.message))
19752            }
19753        }
19754    }
19755
19756    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
19757        self.cancel_sync_round();
19758        // A deliberately disconnected or policy-disabled client has nothing
19759        // to release; a `lost` socket still does (the host owns the handle).
19760        if self.realtime_state == RealtimeState::Disconnected
19761            || self.realtime_state == RealtimeState::Disabled
19762        {
19763            return;
19764        }
19765        let _ = transport.realtime_close();
19766        self.set_realtime_state(
19767            if self.realtime_policy == RealtimePolicy::Off {
19768                RealtimeState::Disabled
19769            } else {
19770                RealtimeState::Disconnected
19771            },
19772            None,
19773        );
19774        self.presence.clear(); // §8.6.1: presence is per-connection
19775    }
19776
19777    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
19778    /// document for `scope_key`. Requires a live socket; the document is
19779    /// ephemeral (lost on disconnect). Authorization is the connection's
19780    /// registration (§8.6.3) — an unheld key is rejected loudly by the
19781    /// server with `presence.forbidden`.
19782    pub fn set_presence(
19783        &mut self,
19784        transport: &mut dyn Transport,
19785        scope_key: &str,
19786        doc: Option<&Value>,
19787    ) -> Result<(), String> {
19788        if !self.transport_enabled {
19789            return Err("sync.offline: transport gate is closed".into());
19790        }
19791        if !self.realtime_connected() {
19792            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
19793        }
19794        let text = encode_presence_publish(scope_key, doc);
19795        transport
19796            .realtime_send(&text)
19797            .map_err(|e| format!("{}: {}", e.code, e.message))
19798    }
19799
19800    /// §8.6: the peers currently present on a scope key (ephemeral).
19801    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
19802        self.presence
19803            .get(scope_key)
19804            .map(|peers| peers.values().cloned().collect())
19805            .unwrap_or_default()
19806    }
19807
19808    /// §8.6 apply an inbound presence fanout to the local map.
19809    fn apply_presence(
19810        &mut self,
19811        scope_key: String,
19812        kind: Option<PresenceKind>,
19813        actor_id: Option<String>,
19814        client_id: Option<String>,
19815        doc: Option<Value>,
19816        error: Option<String>,
19817    ) {
19818        // The publisher-directed error variant is out-of-band; nothing to
19819        // record in the peer map.
19820        if error.is_some() {
19821            return;
19822        }
19823        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
19824            return;
19825        };
19826        let peer_key = format!("{actor_id} {client_id}");
19827        match kind {
19828            PresenceKind::Leave => {
19829                if let Some(peers) = self.presence.get_mut(&scope_key) {
19830                    peers.remove(&peer_key);
19831                    if peers.is_empty() {
19832                        self.presence.remove(&scope_key);
19833                    }
19834                }
19835            }
19836            _ => {
19837                let doc = match doc {
19838                    Some(Value::Object(_)) => doc.unwrap(),
19839                    _ => return,
19840                };
19841                self.presence.entry(scope_key).or_default().insert(
19842                    peer_key,
19843                    PresencePeer {
19844                        actor_id,
19845                        client_id,
19846                        doc,
19847                    },
19848                );
19849            }
19850        }
19851    }
19852
19853    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
19854    pub fn on_realtime_text(&mut self, text: &str) {
19855        if self.stopped || !self.transport_enabled || self.security_preflight {
19856            return;
19857        }
19858        match parse_control(text) {
19859            Ok(ControlMessage::Hello { requires_sync, .. }) => {
19860                if requires_sync {
19861                    // §8.1: pull before trusting the socket for continuity.
19862                    self.set_sync_needed(true, true);
19863                }
19864            }
19865            Ok(ControlMessage::Presence {
19866                scope_key,
19867                kind,
19868                actor_id,
19869                client_id,
19870                doc,
19871                error,
19872                ..
19873            }) => {
19874                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
19875            }
19876            Ok(ControlMessage::Wake { .. }) => {
19877                // §8.3: any wake-up means "run a pull soon", never data.
19878                self.set_sync_needed(true, true);
19879            }
19880            _ => {}
19881        }
19882    }
19883
19884    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
19885    /// a pull response per section; an unapplied delta is a wake-up.
19886    /// Mailbox hosts send durable acknowledgements on their I/O executor.
19887    pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
19888        let mut transport = crate::round::DownloadResults::default();
19889        self.on_realtime_binary(&mut transport, bytes);
19890        transport.controls
19891    }
19892
19893    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
19894        if self.stopped || !self.transport_enabled || self.security_preflight {
19895            return;
19896        }
19897        #[cfg(feature = "bench-internals")]
19898        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
19899        let message = match decode_message(bytes) {
19900            Ok(m) if m.msg_kind == MsgKind::Response => m,
19901            _ => {
19902                self.set_sync_needed(true, true);
19903                return;
19904            }
19905        };
19906        #[cfg(feature = "bench-internals")]
19907        drop(decode_phase);
19908        let mut frames = message.frames.into_iter();
19909        let mut applied_cursor: Option<i64> = None;
19910        let mut any_covered = false;
19911        let mut dropped = self.begin_coalesced_apply().is_err();
19912        while let Some(frame) = frames.next() {
19913            let Frame::SubStart {
19914                id,
19915                status,
19916                effective_scopes,
19917                ..
19918            } = frame
19919            else {
19920                continue;
19921            };
19922            let mut body = Vec::new();
19923            let mut next_cursor: Option<i64> = None;
19924            for inner in frames.by_ref() {
19925                match inner {
19926                    Frame::SubEnd {
19927                        next_cursor: nc, ..
19928                    } => {
19929                        next_cursor = Some(nc);
19930                        break;
19931                    }
19932                    Frame::Unknown { .. } => {}
19933                    other => body.push(other),
19934                }
19935            }
19936            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
19937                dropped = true;
19938                continue;
19939            };
19940            let sub = &self.subs[sub_index];
19941            // §8.2: only locally active, not mid-bootstrap subscriptions
19942            // apply; skipped sections are repaired by the next pull.
19943            if status != SubStatus::Active
19944                || sub.state != SubState::Active
19945                || sub.bootstrap_state.is_some()
19946                || !sub.synced_once
19947            {
19948                dropped = true;
19949                continue;
19950            }
19951            if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
19952                // Idempotent redelivery of an already-covered window.
19953                any_covered = true;
19954                continue;
19955            }
19956            let previous_effective = self.subs[sub_index].effective.clone();
19957            let previous_cursor = self.subs[sub_index].cursor;
19958            self.subs[sub_index].effective = Some(effective_scopes);
19959            let mut failed = false;
19960            for inner in body {
19961                match inner {
19962                    Frame::Commit {
19963                        tables,
19964                        changes,
19965                        commit_seq,
19966                        ..
19967                    } => {
19968                        if self
19969                            .apply_commit_frame(&tables, &changes, Some(commit_seq))
19970                            .is_err()
19971                        {
19972                            failed = true;
19973                            break;
19974                        }
19975                    }
19976                    _ => {
19977                        failed = true;
19978                        break;
19979                    }
19980                }
19981            }
19982            let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
19983                self.apply_sub_end(sub_index, cursor, None)
19984                    .ok()
19985                    .map(|()| cursor)
19986            });
19987            let Some(next_cursor) = committed else {
19988                self.subs[sub_index].effective = previous_effective;
19989                self.subs[sub_index].cursor = previous_cursor;
19990                dropped = true;
19991                continue;
19992            };
19993            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
19994        }
19995        // §1.4 rule 4: the ack follows the COMMIT. A failed COMMIT restored
19996        // the cursors, so the delta counts as a wake-up.
19997        if self.finish_coalesced_apply().is_err() {
19998            applied_cursor = None;
19999            dropped = true;
20000        }
20001        if let Some(cursor) = applied_cursor {
20002            // §8.2 ack point: the highest applied SUB_END.nextCursor.
20003            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
20004            let _ = transport.realtime_send(&ack);
20005        } else if !any_covered || dropped {
20006            // §8.2: a delta not applied at all is treated as a wake-up.
20007            self.set_sync_needed(true, true);
20008        }
20009    }
20010
20011    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
20012    /// cursor across active, non-bootstrapping subscriptions that have
20013    /// synced at least once. No such subscription, no ack.
20014    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
20015        if !self.realtime_connected() {
20016            return;
20017        }
20018        let floor = self
20019            .subs
20020            .iter()
20021            .filter(|s| {
20022                s.state == SubState::Active
20023                    && s.bootstrap_state.is_none()
20024                    && s.synced_once
20025                    && s.cursor >= 0
20026            })
20027            .map(|s| s.cursor)
20028            .min();
20029        if let Some(cursor) = floor {
20030            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
20031            let _ = transport.realtime_send(&ack);
20032        }
20033    }
20034}
20035
20036#[cfg(test)]
20037mod previous_version_wiring_tests {
20038    use super::*;
20039    use serde_json::{json, Value};
20040
20041    fn previous_version_schema(version: i32) -> Value {
20042        json!({
20043            "version": version,
20044            "tables": [{
20045                "name": "tasks",
20046                "primaryKey": "id",
20047                "columns": [
20048                    { "name": "id", "type": "string", "nullable": false },
20049                    { "name": "note", "type": "string", "nullable": true }
20050                ],
20051                "scopes": []
20052            }]
20053        })
20054    }
20055
20056    fn previous_version_limits() -> crate::api::ClientLimits {
20057        crate::api::ClientLimits {
20058            previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
20059                enabled: true,
20060                ..Default::default()
20061            }),
20062            ..Default::default()
20063        }
20064    }
20065
20066    /// The configuration the live client carries, for a direct capture call.
20067    fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
20068        previous_version_limits()
20069            .previous_version_context
20070            .expect("configured")
20071    }
20072
20073    /// Capture the container the way a bump would, so a read test starts from a
20074    /// container this client's own state will then decide about.
20075    fn recapture_previous_version(client: &SyncClient) {
20076        let replica_path = client
20077            .previous_version_replica_path()
20078            .expect("file-backed replica");
20079        let outcome = crate::previous_version::capture_previous_version_from_replica(
20080            &client.conn,
20081            &replica_path,
20082            2,
20083            2,
20084            &previous_version_config(),
20085            1,
20086        )
20087        .expect("capture");
20088        assert!(matches!(
20089            outcome,
20090            crate::previous_version::CaptureOutcome::Captured(_)
20091        ));
20092    }
20093
20094    fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
20095        Subscription {
20096            id: "wiring-sub".to_owned(),
20097            table: "tasks".to_owned(),
20098            requested: Vec::new(),
20099            params: None,
20100            cursor,
20101            bootstrap_state: bootstrap_state.map(str::to_owned),
20102            state,
20103            reason_code: None,
20104            effective: None,
20105            synced_once: false,
20106        }
20107    }
20108
20109    #[test]
20110    fn schema_downgrade_refuses_all_opens_and_recreate_without_mutation() {
20111        let path =
20112            std::env::temp_dir().join(format!("syncular-downgrade-{}.db", uuid::Uuid::new_v4()));
20113        let path_str = path.to_str().expect("temp path");
20114        let schema = previous_version_schema(3);
20115        let mut older = previous_version_schema(2);
20116        older["tables"][0]["columns"] =
20117            json!([{ "name": "id", "type": "string", "nullable": false }]);
20118        let mut client = SyncClient::open_path(
20119            "downgrade".into(),
20120            &previous_version_schema(1),
20121            previous_version_limits(),
20122            path_str,
20123        )
20124        .expect("fresh");
20125        client
20126            .mutate(vec![Mutation::Upsert {
20127                table: "tasks".into(),
20128                values: Map::from_iter([
20129                    ("id".into(), json!("accepted")),
20130                    ("note".into(), json!("old")),
20131                ]),
20132                base_version: None,
20133            }])
20134            .expect("v1 write");
20135        client
20136            .recreate_with_schema(&schema)
20137            .expect("upgrade captures context");
20138        let commit = client
20139            .mutate(vec![Mutation::Upsert {
20140                table: "tasks".into(),
20141                values: Map::from_iter([
20142                    ("id".into(), json!("queued")),
20143                    ("note".into(), json!("v3-only")),
20144                ]),
20145                base_version: None,
20146            }])
20147            .expect("queued v3 write");
20148        let changes = client.conn.total_changes();
20149        let outbox = client.pending_commit_ids();
20150        assert!(outbox.contains(&commit));
20151        let descriptor = client.get_meta("localSchemaDescriptor");
20152        let container = format!(
20153            "{path_str}{}",
20154            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
20155        );
20156        let context = std::fs::read(&container).expect("captured context");
20157        assert!(client
20158            .recreate_with_schema(&older)
20159            .expect_err("refuse recreate")
20160            .starts_with("client.schema_downgrade:"));
20161        assert_eq!(client.schema.version, 3);
20162        assert_eq!(client.conn.total_changes(), changes);
20163        assert_eq!(client.pending_commit_ids(), outbox);
20164        assert_eq!(client.get_meta("localSchemaDescriptor"), descriptor);
20165        assert_eq!(
20166            client
20167                .conn
20168                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
20169                    row.get::<_, String>(0)
20170                })
20171                .expect("queued row"),
20172            "v3-only"
20173        );
20174        assert_eq!(std::fs::read(&container).expect("context remains"), context);
20175        drop(client);
20176        let before = std::fs::read(&path).expect("replica bytes");
20177        // open_path uses DELETE journaling. The identity open must refuse
20178        // before its normal WAL configuration changes this database.
20179        for entry in 0..3 {
20180            let error = match entry {
20181                0 => SyncClient::open_path(
20182                    "downgrade".into(),
20183                    &older,
20184                    previous_version_limits(),
20185                    path_str,
20186                ),
20187                1 => SyncClient::open_path_with_identity(
20188                    None,
20189                    &older,
20190                    previous_version_limits(),
20191                    path_str,
20192                ),
20193                _ => SyncClient::with_connection(
20194                    "downgrade".into(),
20195                    &older,
20196                    previous_version_limits(),
20197                    Connection::open(&path).expect("connection"),
20198                ),
20199            }
20200            .err()
20201            .expect("refuse open");
20202            assert!(error.starts_with("client.schema_downgrade:"));
20203            let conn = Connection::open(&path).expect("inspect");
20204            assert_eq!(
20205                conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
20206                    .expect("journal mode"),
20207                "delete"
20208            );
20209            drop(conn);
20210            assert_eq!(std::fs::read(&path).expect("replica unchanged"), before);
20211            assert_eq!(
20212                std::fs::read(&container).expect("context unchanged"),
20213                context
20214            );
20215        }
20216        let reopened = SyncClient::open_path(
20217            "downgrade".into(),
20218            &schema,
20219            previous_version_limits(),
20220            path_str,
20221        )
20222        .expect("compatible reopen");
20223        assert_eq!(reopened.pending_commit_ids(), outbox);
20224        assert_eq!(
20225            reopened
20226                .conn
20227                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
20228                    row.get::<_, String>(0)
20229                })
20230                .expect("queued row remains"),
20231            "v3-only"
20232        );
20233        reopened.delete_meta(LOCAL_SCHEMA_VERSION_KEY);
20234        reopened.delete_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
20235        drop(reopened);
20236        let legacy = SyncClient::open_path(
20237            "downgrade".into(),
20238            &schema,
20239            previous_version_limits(),
20240            path_str,
20241        )
20242        .expect("legacy no-marker reopen");
20243        assert_eq!(legacy.pending_commit_ids(), outbox);
20244        assert!(!legacy.upgrading());
20245        assert_eq!(
20246            legacy
20247                .conn
20248                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
20249                    row.get::<_, String>(0)
20250                })
20251                .expect("legacy row remains"),
20252            "v3-only"
20253        );
20254        drop(legacy);
20255        std::fs::remove_file(path).expect("remove replica");
20256        let _ = std::fs::remove_file(container);
20257    }
20258
20259    #[test]
20260    fn schema_marker_errors_refuse_open_and_recreate_without_mutation() {
20261        for marker in [
20262            "''",
20263            "'bad'",
20264            "'0'",
20265            "'-1'",
20266            "'2.5'",
20267            "'03'",
20268            "'3\n'",
20269            "' 3'",
20270            "'+3'",
20271            "'3e0'",
20272            "'2147483648'",
20273            "NULL",
20274            "X'33'",
20275        ] {
20276            let conn = Connection::open_in_memory().expect("connection");
20277            conn.execute_batch(&format!("CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value); INSERT INTO _syncular_meta VALUES ('localSchemaVersion', {marker});")).expect("invalid marker fixture");
20278            let error = SyncClient::with_connection(
20279                "marker".into(),
20280                &previous_version_schema(3),
20281                ClientLimits::default(),
20282                conn,
20283            )
20284            .err()
20285            .expect("refuse marker");
20286            assert!(
20287                error.starts_with("sync.local_corrupt:"),
20288                "{marker}: {error}"
20289            );
20290        }
20291        for fault in [
20292            "UPDATE _syncular_meta SET value = 'bad' WHERE key = 'localSchemaVersion'",
20293            "ALTER TABLE _syncular_meta RENAME COLUMN value TO unreadable_value",
20294        ] {
20295            let mut client = SyncClient::new(
20296                "marker".into(),
20297                &previous_version_schema(3),
20298                ClientLimits::default(),
20299            )
20300            .expect("fresh");
20301            let commit = client
20302                .mutate(vec![Mutation::Upsert {
20303                    table: "tasks".into(),
20304                    values: Map::from_iter([("id".into(), json!("queued"))]),
20305                    base_version: None,
20306                }])
20307                .expect("queued write");
20308            client.conn.execute_batch(fault).expect("marker fault");
20309            let changes = client.conn.total_changes();
20310            let error = client
20311                .recreate_with_schema(&previous_version_schema(4))
20312                .expect_err("refuse corrupt marker");
20313            assert!(error.starts_with("sync.local_corrupt:"));
20314            assert_eq!(client.conn.total_changes(), changes);
20315            assert_eq!(client.schema.version, 3);
20316            assert_eq!(client.pending_commit_ids(), vec![commit]);
20317        }
20318        for setup in [
20319            "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY)",
20320            "CREATE VIEW _syncular_meta AS SELECT 'localSchemaVersion' AS key, '3' AS value",
20321        ] {
20322            let path =
20323                std::env::temp_dir().join(format!("syncular-marker-{}.db", uuid::Uuid::new_v4()));
20324            let conn = Connection::open(&path).expect("connection");
20325            conn.execute_batch(setup)
20326                .expect("unreadable metadata fixture");
20327            drop(conn);
20328            let before = std::fs::read(&path).expect("before");
20329            let error = SyncClient::open_path_with_identity(
20330                None,
20331                &previous_version_schema(3),
20332                ClientLimits::default(),
20333                path.to_str().expect("path"),
20334            )
20335            .err()
20336            .expect("refuse unreadable marker");
20337            assert!(error.starts_with("sync.local_corrupt:"));
20338            assert_eq!(std::fs::read(&path).expect("after"), before);
20339            std::fs::remove_file(path).expect("remove fixture");
20340        }
20341    }
20342
20343    std::thread_local! {
20344        /// The pending concurrent upgrade a busy handler commits, so a lock
20345        /// collision deterministically becomes the marker race under test.
20346        static CONCURRENT_UPGRADE: RefCell<Option<Connection>> =
20347            const { RefCell::new(None) };
20348    }
20349
20350    fn commit_concurrent_upgrade(attempts: i32) -> bool {
20351        CONCURRENT_UPGRADE.with(|slot| {
20352            if let Some(banner) = slot.borrow_mut().take() {
20353                banner
20354                    .execute_batch(
20355                        "UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion'; COMMIT",
20356                    )
20357                    .expect("commit the concurrent upgrade");
20358            }
20359        });
20360        attempts < 10
20361    }
20362
20363    fn journal_mode(conn: &Connection) -> String {
20364        conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
20365            .expect("journal mode")
20366    }
20367
20368    fn temp_replica(label: &str) -> std::path::PathBuf {
20369        std::env::temp_dir().join(format!("syncular-{label}-{}.db", uuid::Uuid::new_v4()))
20370    }
20371
20372    #[test]
20373    fn file_identity_constructor_requires_the_returned_wal_mode() {
20374        for path in [":memory:", ""] {
20375            let error = SyncClient::open_path_with_identity(
20376                None,
20377                &previous_version_schema(1),
20378                ClientLimits::default(),
20379                path,
20380            )
20381            .err()
20382            .expect("temporary SQLite databases cannot enter WAL mode");
20383            assert_eq!(
20384                error,
20385                "sync.invalid_request: file-backed client requires WAL journal mode"
20386            );
20387        }
20388        let path = temp_replica("confirmed-wal");
20389        let client = SyncClient::open_path_with_identity(
20390            None,
20391            &previous_version_schema(1),
20392            ClientLimits::default(),
20393            path.to_str().unwrap(),
20394        )
20395        .unwrap();
20396        assert_eq!(journal_mode(&client.conn), "wal");
20397        drop(client);
20398        std::fs::remove_file(path).unwrap();
20399    }
20400
20401    #[test]
20402    fn non_positive_generated_schema_versions_are_refused_before_storage() {
20403        for version in [json!(0), json!(-1), json!(i32::MIN), json!(2147483648i64)] {
20404            let mut schema = previous_version_schema(1);
20405            schema["version"] = version.clone();
20406            let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
20407                .err()
20408                .expect("refuse an out-of-range generated version");
20409            assert_eq!(
20410                error,
20411                "sync.invalid_request: generated schema version must be an integer in 1..=2147483647"
20412            );
20413            // The file-backed seam refuses before the replica gains a table.
20414            let path = temp_replica("bad-version");
20415            let path_str = path.to_str().expect("path");
20416            for identity in [false, true] {
20417                let error = if identity {
20418                    SyncClient::open_path_with_identity(
20419                        None,
20420                        &schema,
20421                        ClientLimits::default(),
20422                        path_str,
20423                    )
20424                } else {
20425                    SyncClient::open_path(
20426                        "bad-version".into(),
20427                        &schema,
20428                        ClientLimits::default(),
20429                        path_str,
20430                    )
20431                }
20432                .err()
20433                .expect("refuse before storage");
20434                assert!(
20435                    error.starts_with("sync.invalid_request:"),
20436                    "{version}: {error}"
20437                );
20438                assert!(
20439                    !path.exists(),
20440                    "{version}: a refused constructor creates no replica file"
20441                );
20442            }
20443        }
20444        // A missing version is the same request error, not a parse failure.
20445        let mut schema = previous_version_schema(1);
20446        schema
20447            .as_object_mut()
20448            .expect("schema object")
20449            .remove("version");
20450        let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
20451            .err()
20452            .expect("refuse a missing version");
20453        assert!(error.starts_with("sync.invalid_request:"), "{error}");
20454    }
20455
20456    #[test]
20457    fn a_duplicated_schema_marker_is_refused_without_a_reset() {
20458        let path = temp_replica("duplicate-marker");
20459        let path_str = path.to_str().expect("path");
20460        {
20461            // A hand-built/legacy metadata table without its primary key, with
20462            // the older marker first: `query_row` would pick it and reset.
20463            let conn = Connection::open(path_str).expect("fixture");
20464            conn.execute_batch(
20465                "CREATE TABLE _syncular_meta(key TEXT, value);
20466                 INSERT INTO _syncular_meta VALUES
20467                   ('localSchemaVersion', '1'),
20468                   ('localSchemaVersion', '2'),
20469                   ('clientId', 'duplicate');",
20470            )
20471            .expect("duplicate marker fixture");
20472        }
20473        let error = SyncClient::open_path_with_identity(
20474            None,
20475            &previous_version_schema(3),
20476            ClientLimits::default(),
20477            path_str,
20478        )
20479        .err()
20480        .expect("refuse a duplicated marker");
20481        assert!(error.starts_with("sync.local_corrupt:"), "{error}");
20482        let probe = Connection::open(path_str).expect("probe");
20483        assert_eq!(
20484            probe
20485                .query_row(
20486                    "SELECT COUNT(*) FROM _syncular_meta WHERE key = 'localSchemaVersion'",
20487                    [],
20488                    |row| row.get::<_, i64>(0),
20489                )
20490                .expect("marker rows"),
20491            2,
20492            "the duplicated rows are left for the operator, not collapsed"
20493        );
20494        assert_eq!(
20495            probe
20496                .query_row(
20497                    "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'",
20498                    [],
20499                    |row| row.get::<_, i64>(0),
20500                )
20501                .expect("synced tables"),
20502            0,
20503            "a duplicated marker never drives the reset that recreates v3 tables"
20504        );
20505        assert_eq!(
20506            journal_mode(&probe),
20507            "delete",
20508            "the refusal leaves the journal mode alone"
20509        );
20510        drop(probe);
20511        std::fs::remove_file(path).expect("remove replica");
20512    }
20513
20514    #[test]
20515    fn a_refused_open_does_not_persist_wal_or_mutate_the_replica() {
20516        let path = temp_replica("refused-wal");
20517        let path_str = path.to_str().expect("path");
20518        // `open_path` leaves the default journal mode, so a refused open is
20519        // the only thing that could switch this replica to WAL.
20520        drop(
20521            SyncClient::open_path(
20522                "owner".into(),
20523                &previous_version_schema(2),
20524                ClientLimits::default(),
20525                path_str,
20526            )
20527            .expect("v2 replica"),
20528        );
20529        let before = std::fs::read(&path).expect("before");
20530        let error = SyncClient::open_path_with_identity(
20531            Some("intruder".into()),
20532            &previous_version_schema(2),
20533            ClientLimits::default(),
20534            path_str,
20535        )
20536        .err()
20537        .expect("refuse the identity mismatch");
20538        assert!(error.starts_with("client.identity_mismatch:"), "{error}");
20539        let probe = Connection::open(path_str).expect("probe");
20540        assert_eq!(
20541            journal_mode(&probe),
20542            "delete",
20543            "a refused open must not persist WAL"
20544        );
20545        assert_eq!(std::fs::read(&path).expect("after"), before);
20546        assert_eq!(
20547            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
20548            Some("2")
20549        );
20550        drop(probe);
20551        std::fs::remove_file(path).expect("remove replica");
20552    }
20553
20554    #[test]
20555    fn a_concurrent_marker_upgrade_refuses_a_stale_open() {
20556        let path = temp_replica("marker-race");
20557        let path_str = path.to_str().expect("path");
20558        drop(
20559            SyncClient::open_path_with_identity(
20560                Some("stale".into()),
20561                &previous_version_schema(1),
20562                ClientLimits::default(),
20563                path_str,
20564            )
20565            .expect("v1 replica"),
20566        );
20567        // Another process holds the write lock with an uncommitted upgrade; a
20568        // stale open reads the v1 marker first and only then meets the lock.
20569        // The first busy therefore lands after this replica became v2.
20570        let banner = Connection::open(path_str).expect("banner");
20571        banner
20572            .execute_batch(
20573                "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
20574            )
20575            .expect("pending upgrade");
20576        CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
20577        let conn = Connection::open(path_str).expect("stale connection");
20578        conn.busy_handler(Some(commit_concurrent_upgrade))
20579            .expect("busy handler");
20580        let error = SyncClient::with_connection(
20581            "stale".into(),
20582            &previous_version_schema(1),
20583            ClientLimits::default(),
20584            conn,
20585        )
20586        .err()
20587        .expect("a concurrent upgrade must refuse the stale open");
20588        assert!(error.starts_with("client.schema_downgrade:"), "{error}");
20589        let probe = Connection::open(path_str).expect("probe");
20590        assert_eq!(
20591            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
20592            Some("2"),
20593            "the concurrent upgrade survives"
20594        );
20595        drop(probe);
20596        std::fs::remove_file(path).expect("remove replica");
20597    }
20598
20599    #[test]
20600    fn a_concurrent_marker_upgrade_refuses_a_stale_recreate() {
20601        let path = temp_replica("recreate-race");
20602        let path_str = path.to_str().expect("path");
20603        let mut client = SyncClient::open_path_with_identity(
20604            Some("stale".into()),
20605            &previous_version_schema(1),
20606            ClientLimits::default(),
20607            path_str,
20608        )
20609        .expect("v1 replica");
20610        client
20611            .mutate(vec![Mutation::Upsert {
20612                table: "tasks".into(),
20613                values: Map::from_iter([("id".into(), json!("queued"))]),
20614                base_version: None,
20615            }])
20616            .expect("queued write");
20617        let commit = client.pending_commit_ids();
20618        let banner = Connection::open(path_str).expect("banner");
20619        banner
20620            .execute_batch(
20621                "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
20622            )
20623            .expect("pending upgrade");
20624        CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
20625        client
20626            .conn
20627            .busy_handler(Some(commit_concurrent_upgrade))
20628            .expect("busy handler");
20629        let error = client
20630            .recreate_with_schema(&previous_version_schema(1))
20631            .expect_err("a concurrent upgrade must refuse the stale recreate");
20632        assert!(error.starts_with("client.schema_downgrade:"), "{error}");
20633        assert_eq!(client.schema.version, 1, "the compiled schema is restored");
20634        assert_eq!(
20635            client.pending_commit_ids(),
20636            commit,
20637            "the outbox is preserved"
20638        );
20639        let probe = Connection::open(path_str).expect("probe");
20640        assert_eq!(
20641            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
20642            Some("2")
20643        );
20644        drop(probe);
20645        drop(client);
20646        std::fs::remove_file(path).expect("remove replica");
20647    }
20648
20649    #[test]
20650    fn failed_schema_record_writes_roll_back_recreation() {
20651        for key in [
20652            LOCAL_SCHEMA_VERSION_KEY,
20653            crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY,
20654        ] {
20655            let mut client = SyncClient::new(
20656                "schema-record".into(),
20657                &previous_version_schema(1),
20658                ClientLimits::default(),
20659            )
20660            .expect("v1");
20661            let marker = client.get_meta(LOCAL_SCHEMA_VERSION_KEY);
20662            let descriptor = client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
20663            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");
20664            client
20665                .recreate_with_schema(&previous_version_schema(2))
20666                .expect_err("both record writes must propagate failure");
20667            assert_eq!(client.schema.version, 1);
20668            assert_eq!(client.get_meta(LOCAL_SCHEMA_VERSION_KEY), marker);
20669            assert_eq!(
20670                client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY),
20671                descriptor
20672            );
20673            client
20674                .conn
20675                .execute_batch("DROP TRIGGER refuse_schema_record")
20676                .expect("remove fault");
20677            client
20678                .recreate_with_schema(&previous_version_schema(2))
20679                .expect("retry");
20680            assert_eq!(client.schema.version, 2);
20681        }
20682    }
20683
20684    #[test]
20685    fn a_failed_outer_commit_restores_every_reset_side_effect() {
20686        let path = temp_replica("outer-commit");
20687        let path_str = path.to_str().expect("path");
20688        // A rollback-journal replica (no WAL), so a second read connection can
20689        // hold SHARED across the recreated transaction's COMMIT.
20690        let conn = Connection::open(path_str).expect("replica connection");
20691        let mut client = SyncClient::with_connection(
20692            "outer-commit".into(),
20693            &previous_version_schema(1),
20694            previous_version_limits(),
20695            conn,
20696        )
20697        .expect("v1 replica");
20698        assert_eq!(journal_mode(&client.conn), "delete");
20699        let commit = client
20700            .mutate(vec![Mutation::Upsert {
20701                table: "tasks".into(),
20702                values: Map::from_iter([
20703                    ("id".into(), json!("queued")),
20704                    ("note".into(), json!("dropped-by-v2")),
20705                ]),
20706                base_version: None,
20707            }])
20708            .expect("queued write");
20709        client.drain_change_batches();
20710        // v2 drops `note`, so the queued upsert cannot re-encode.
20711        let mut dropped = previous_version_schema(2);
20712        dropped["tables"][0]["columns"] =
20713            json!([{ "name": "id", "type": "string", "nullable": false }]);
20714        // Prior database and memory state: the refused COMMIT must restore all
20715        // of it.
20716        let marker = meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY);
20717        let revision = client.local_revision();
20718        let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
20719        let columns: i64 = client
20720            .conn
20721            .query_row(
20722                "SELECT count(*) FROM pragma_table_info('tasks')",
20723                [],
20724                |row| row.get(0),
20725            )
20726            .expect("column count");
20727        let rebuilds = client.overlay_rebuild_count.get();
20728        let prior_subs = client.subs.len();
20729        let prior_changes = client.change_queue.len();
20730        let prior_intents = client.sync_intent_queue.len();
20731        let prior_last_change = client.last_change.clone();
20732        let prior_overlay_dirty = client.overlay_dirty.get();
20733        let prior_sync_needed = client.sync_needed;
20734        let prior_upgrading = client.upgrading;
20735        let prior_stopped = client.stopped;
20736        let prior_schema_floor = client.schema_floor.clone();
20737        let prior_active_round = client.active_round;
20738        // The reader holds SHARED for the whole attempt: the writer takes
20739        // RESERVED, runs the reset, and fails when COMMIT needs EXCLUSIVE.
20740        let reader = Connection::open(path_str).expect("reader");
20741        reader
20742            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
20743            .expect("reader snapshot");
20744        client
20745            .conn
20746            .busy_timeout(std::time::Duration::ZERO)
20747            .expect("busy timeout");
20748        let error = client
20749            .recreate_with_schema(&dropped)
20750            .expect_err("a blocked COMMIT must refuse the recreation");
20751        assert!(error.starts_with("client.storage_busy:"), "{error}");
20752        assert!(
20753            client.overlay_rebuild_count.get() > rebuilds,
20754            "the reset ran to its overlay rebuild inside the transaction"
20755        );
20756        assert!(client.conn.is_autocommit(), "the transaction is released");
20757        // Database: the rolled-back reset left nothing behind.
20758        assert_eq!(meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY), marker);
20759        assert_eq!(client.local_revision(), revision);
20760        assert_eq!(
20761            client.query("SELECT * FROM tasks", &[]).expect("rows"),
20762            rows
20763        );
20764        assert_eq!(
20765            client
20766                .conn
20767                .query_row(
20768                    "SELECT count(*) FROM pragma_table_info('tasks')",
20769                    [],
20770                    |row| { row.get::<_, i64>(0) }
20771                )
20772                .expect("column count"),
20773            columns,
20774            "the previous table layout survives the rollback"
20775        );
20776        assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
20777        // Memory: every side effect of the successful reset is restored with
20778        // the rolled-back database.
20779        assert_eq!(client.schema.version, 1, "compiled schema");
20780        assert_eq!(client.subs.len(), prior_subs);
20781        assert_eq!(client.change_queue.len(), prior_changes);
20782        assert_eq!(client.sync_intent_queue.len(), prior_intents);
20783        assert_eq!(client.last_change, prior_last_change);
20784        assert_eq!(client.overlay_dirty.get(), prior_overlay_dirty);
20785        assert_eq!(client.sync_needed, prior_sync_needed);
20786        assert_eq!(client.upgrading, prior_upgrading);
20787        assert_eq!(client.stopped, prior_stopped);
20788        assert_eq!(client.schema_floor, prior_schema_floor);
20789        assert_eq!(client.active_round, prior_active_round);
20790        assert!(
20791            client.rejections.is_empty(),
20792            "the dropped-column rejection is rolled back too"
20793        );
20794        // The capture committed in its separate file before the failed replica
20795        // COMMIT. It belongs to v2 and cannot be exposed by the restored v1 core.
20796        let container = crate::previous_version::previous_version_container_path(path_str);
20797        assert!(std::path::Path::new(&container).exists());
20798        // The reader no longer needs its snapshot, so discard can clean the
20799        // replica's audit records along with the stale sibling file.
20800        drop(reader);
20801        let snapshot = client
20802            .previous_version_snapshot(&PreviousVersionReadSpec {
20803                table: "tasks".into(),
20804                ..Default::default()
20805            })
20806            .expect("refuse stale capture");
20807        assert!(!snapshot.available);
20808        assert_eq!(snapshot.current_version, 1);
20809        assert!(!std::path::Path::new(&container).exists());
20810        // The client remains usable: releasing the reader lets the identical
20811        // recreation run to completion, and the reset preserves the
20812        // incompatible intent until the send-time prepass classifies it
20813        // (§7.4.3/§7.4.4: the reset never touches the outbox).
20814        client
20815            .conn
20816            .busy_timeout(std::time::Duration::from_secs(1))
20817            .expect("busy timeout");
20818        client
20819            .recreate_with_schema(&dropped)
20820            .expect("the recreation succeeds once the reader releases");
20821        assert_eq!(client.schema.version, 2);
20822        assert_eq!(
20823            client.pending_commit_ids(),
20824            vec![commit.clone()],
20825            "the reset kept the incompatible commit pending"
20826        );
20827        assert!(
20828            client.rejections.is_empty(),
20829            "no rejection exists before the send-time prepass"
20830        );
20831        // The send boundary classifies and drops it.
20832        client
20833            .prepare_sync_round(false)
20834            .expect("the send prepass runs during round preparation");
20835        assert!(client.pending_commit_ids().is_empty());
20836        assert!(
20837            client
20838                .rejections
20839                .iter()
20840                .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE),
20841            "the dropped-column commit surfaced as a rejection at send time"
20842        );
20843        client.previous_version_discard();
20844        drop(client);
20845        std::fs::remove_file(path).expect("remove replica");
20846    }
20847
20848    #[test]
20849    fn the_send_time_incompatible_drop_is_atomic_and_publishes_once() {
20850        let v1 = previous_version_schema(1);
20851        let mut v2 = previous_version_schema(2);
20852        // v2 drops `note`, so the queued `dropped` upsert cannot re-encode.
20853        v2["tables"][0]["columns"] = json!([{ "name": "id", "type": "string", "nullable": false }]);
20854        let mut client = SyncClient::new("drop".to_string(), &v1, ClientLimits::default()).unwrap();
20855        let dropped = client
20856            .mutate(vec![Mutation::Upsert {
20857                table: "tasks".into(),
20858                values: Map::from_iter([
20859                    ("id".into(), json!("dropped")),
20860                    ("note".into(), json!("gone")),
20861                ]),
20862                base_version: None,
20863            }])
20864            .unwrap();
20865        // A full-row v1 upsert always carries the v1 columns, so the surviving
20866        // commit is a value-free delete (it encodes under any schema that still
20867        // has the table).
20868        let survivor = client
20869            .mutate(vec![Mutation::Delete {
20870                table: "tasks".into(),
20871                row_id: "gone".into(),
20872                base_version: None,
20873            }])
20874            .unwrap();
20875        client
20876            .recreate_with_schema(&v2)
20877            .expect("the reset preserves the outbox");
20878        assert_eq!(
20879            client.pending_commit_ids(),
20880            vec![dropped.clone(), survivor.clone()],
20881            "the reset preserves both commits"
20882        );
20883        assert!(client.rejections.is_empty(), "the reset classifies nothing");
20884        client.drain_change_batches();
20885        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
20886        assert_eq!(rows.len(), 1, "only the dropped upsert has a visible row");
20887
20888        // A fault anywhere in the atomic drop leaves the replica, the outbox,
20889        // the durable outcomes, the visible projection, the revision, and the
20890        // publish surface untouched. Clearing `syncNeeded` first keeps the
20891        // round's own status flip out of the assertion, so any published change
20892        // can only be the drop's.
20893        for fault in [
20894            "outcome-insert",
20895            "outbox-delete",
20896            "overlay-rebuild",
20897            "publish-revision",
20898        ] {
20899            client.set_sync_needed(false, false);
20900            client.drain_change_batches();
20901            let revision = client.local_revision();
20902            let trigger = match fault {
20903                "outcome-insert" => {
20904                    "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20905                }
20906                "outbox-delete" => {
20907                    "CREATE TRIGGER fail_drop BEFORE DELETE ON _syncular_outbox BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20908                }
20909                // The optimized replay removes only the dropped projection, so
20910                // the replay fault belongs on the DELETE; a publish fault lands
20911                // on the revision write that `finish_observation` performs after
20912                // every durable effect already succeeded.
20913                "publish-revision" => {
20914                    "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20915                }
20916                _ => {
20917                    "CREATE TRIGGER fail_drop BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20918                }
20919            };
20920            client.conn.execute_batch(trigger).unwrap();
20921            let outcome = client
20922                .prepare_sync_round(false)
20923                .err()
20924                .unwrap_or_else(|| panic!("{fault}: a fault in the drop must fail the round"));
20925            assert!(
20926                matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "storage.failed"),
20927                "{fault}: {outcome:?}"
20928            );
20929            client.conn.execute_batch("DROP TRIGGER fail_drop").unwrap();
20930            assert!(client.conn.is_autocommit(), "{fault}: no leaked savepoint");
20931            assert_eq!(
20932                client.pending_commit_ids(),
20933                vec![dropped.clone(), survivor.clone()],
20934                "{fault}: the outbox is restored"
20935            );
20936            assert!(
20937                client.rejections.is_empty(),
20938                "{fault}: no in-memory rejection survives"
20939            );
20940            assert!(
20941                client.commit_outcome(&dropped).unwrap().is_none(),
20942                "{fault}: the durable outcome rolled back"
20943            );
20944            assert_eq!(client.local_revision(), revision, "{fault}: revision");
20945            assert_eq!(
20946                client.query("SELECT * FROM tasks", &[]).unwrap(),
20947                rows,
20948                "{fault}: the visible projection is unchanged"
20949            );
20950            assert!(
20951                client.drain_change_batches().is_empty(),
20952                "{fault}: nothing is published"
20953            );
20954        }
20955
20956        // A nested failure leaves the caller's transaction and earlier writes
20957        // intact; only the drop's savepoint is rolled back.
20958        client
20959            .conn
20960            .execute_batch(
20961                "BEGIN; INSERT INTO _syncular_meta(key,value) VALUES ('outerProbe','kept');
20962             CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes
20963             BEGIN SELECT RAISE(ABORT,'nested drop fault'); END;",
20964            )
20965            .unwrap();
20966        assert!(client.drop_incompatible_outbox().is_err());
20967        assert!(!client.conn.is_autocommit());
20968        assert_eq!(client.get_meta("outerProbe").as_deref(), Some("kept"));
20969        assert_eq!(
20970            client.pending_commit_ids(),
20971            vec![dropped.clone(), survivor.clone()]
20972        );
20973        assert!(client.commit_outcome(&dropped).unwrap().is_none());
20974        client
20975            .conn
20976            .execute_batch("DROP TRIGGER fail_drop; COMMIT")
20977            .unwrap();
20978
20979        // The successful drop publishes exactly once and leaves the surviving
20980        // intent queued for the same round. A log epoch is required before a
20981        // round builds push frames at all, so the classification provably runs
20982        // before any operation of the dropped commit is sent.
20983        client.set_meta(LOG_EPOCH_KEY, "epoch");
20984        client.set_sync_needed(false, false);
20985        client.drain_change_batches();
20986        let revision = client.local_revision();
20987        let prepared = client.prepare_sync_round(false).expect("the drop succeeds");
20988        assert_eq!(client.pending_commit_ids(), vec![survivor.clone()]);
20989        assert_eq!(
20990            prepared.meta.pushed_ids,
20991            vec![survivor.clone()],
20992            "the incompatible commit is classified before any operation is sent"
20993        );
20994        let rejection = client
20995            .rejections
20996            .iter()
20997            .find(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE)
20998            .expect("a sync.outbox_incompatible rejection");
20999        assert_eq!(rejection.client_commit_id, dropped);
21000        assert!(
21001            !rejection.retryable,
21002            "a schema-incompatible commit is final"
21003        );
21004        assert_eq!(
21005            client
21006                .commit_outcome(&dropped)
21007                .unwrap()
21008                .expect("a durable rejected outcome")
21009                .status,
21010            CommitOutcomeStatus::Rejected
21011        );
21012        assert_eq!(client.local_revision(), revision + 1, "one revision");
21013        let batches = client.drain_change_batches();
21014        assert_eq!(batches.len(), 1, "one change batch");
21015        assert!(
21016            batches[0].rejections_changed
21017                && batches[0].outcomes_changed
21018                && batches[0].status.is_some(),
21019            "{:?}",
21020            batches[0]
21021        );
21022        assert!(
21023            batches[0].tables.iter().any(|entry| entry.table == "tasks"),
21024            "{:?}",
21025            batches[0]
21026        );
21027        let visible = client.query("SELECT * FROM tasks", &[]).unwrap();
21028        assert!(
21029            visible.is_empty(),
21030            "the dropped projection is undone: {visible:?}"
21031        );
21032
21033        // The observation's outer RELEASE (transaction COMMIT) is guarded too:
21034        // a rollback-journal reader holding SHARED makes it fail with the typed
21035        // busy classification, and releasing the reader lets the retry succeed.
21036        let path = temp_replica("outbox-drop-commit");
21037        let path_str = path.to_str().expect("path");
21038        let conn = Connection::open(path_str).expect("replica connection");
21039        let mut client =
21040            SyncClient::with_connection("drop-commit".into(), &v1, ClientLimits::default(), conn)
21041                .expect("v1 replica");
21042        let dropped = client
21043            .mutate(vec![Mutation::Upsert {
21044                table: "tasks".into(),
21045                values: Map::from_iter([
21046                    ("id".into(), json!("dropped")),
21047                    ("note".into(), json!("gone")),
21048                ]),
21049                base_version: None,
21050            }])
21051            .unwrap();
21052        client
21053            .recreate_with_schema(&v2)
21054            .expect("the reset preserves the outbox");
21055        assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
21056        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
21057        assert_eq!(rows.len(), 1, "the optimistic row is visible");
21058        client.set_sync_needed(false, false);
21059        client.drain_change_batches();
21060        let revision = client.local_revision();
21061        let reader = Connection::open(path_str).expect("reader");
21062        reader
21063            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
21064            .expect("reader snapshot");
21065        client
21066            .conn
21067            .busy_timeout(std::time::Duration::ZERO)
21068            .expect("busy timeout");
21069        let outcome = client
21070            .prepare_sync_round(false)
21071            .err()
21072            .expect("a blocked COMMIT must fail the round");
21073        assert!(
21074            matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "client.storage_busy"),
21075            "{outcome:?}"
21076        );
21077        assert!(client.conn.is_autocommit(), "the savepoint is released");
21078        assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
21079        assert!(client.rejections.is_empty());
21080        assert!(client.commit_outcome(&dropped).unwrap().is_none());
21081        assert_eq!(client.local_revision(), revision);
21082        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
21083        assert!(client.drain_change_batches().is_empty());
21084        // The same round succeeds once the reader releases.
21085        drop(reader);
21086        client
21087            .conn
21088            .busy_timeout(std::time::Duration::from_secs(1))
21089            .expect("busy timeout");
21090        client
21091            .prepare_sync_round(false)
21092            .expect("the drop succeeds after the reader releases");
21093        assert!(client.pending_commit_ids().is_empty());
21094        assert!(client
21095            .rejections
21096            .iter()
21097            .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE));
21098        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
21099        drop(client);
21100        std::fs::remove_file(path).expect("remove replica");
21101    }
21102
21103    #[test]
21104    fn previous_version_read_reasons_come_from_live_client_state() {
21105        use crate::previous_version::{
21106            PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
21107        };
21108        let path = std::env::temp_dir()
21109            .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
21110            .to_string_lossy()
21111            .into_owned();
21112        let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
21113        {
21114            let mut setup = SyncClient::open_path(
21115                "wiring".to_owned(),
21116                &previous_version_schema(1),
21117                previous_version_limits(),
21118                &path,
21119            )
21120            .expect("v1 install");
21121            setup
21122                .mutate(vec![Mutation::Upsert {
21123                    table: "tasks".to_owned(),
21124                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
21125                    base_version: None,
21126                }])
21127                .expect("seed a row");
21128        }
21129        let mut client = SyncClient::open_path(
21130            "wiring".to_owned(),
21131            &previous_version_schema(2),
21132            previous_version_limits(),
21133            &path,
21134        )
21135        .expect("v2 bump captures");
21136        assert!(std::path::Path::new(&container).exists());
21137        let spec = PreviousVersionReadSpec {
21138            table: "tasks".to_owned(),
21139            ..Default::default()
21140        };
21141
21142        // §7.3.5 lease error ⇒ lease-inactive, and the read discards.
21143        recapture_previous_version(&client);
21144        client.lease_state = Some(LeaseState {
21145            lease_id: None,
21146            expires_at_ms: None,
21147            error_code: Some("lease.expired".to_owned()),
21148        });
21149        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
21150        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
21151        assert!(!snapshot.available);
21152        assert!(!std::path::Path::new(&container).exists());
21153
21154        // An expired lease uses the client clock (injected, no sleeps).
21155        recapture_previous_version(&client);
21156        client.set_now_ms(1_000);
21157        client.lease_state = Some(LeaseState {
21158            lease_id: Some("lease".to_owned()),
21159            expires_at_ms: Some(1_000),
21160            error_code: None,
21161        });
21162        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
21163        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
21164        assert!(!std::path::Path::new(&container).exists());
21165        client.lease_state = None;
21166
21167        // A revoked subscription ⇒ scope-revoked.
21168        recapture_previous_version(&client);
21169        client.subs.push(subscription(SubState::Revoked, 0, None));
21170        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
21171        assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
21172        assert!(!std::path::Path::new(&container).exists());
21173        client.subs.clear();
21174
21175        // Coverage completion: every ACTIVE subscription has a cursor and no
21176        // resume token — a still-resuming subscription is not completion.
21177        recapture_previous_version(&client);
21178        client
21179            .subs
21180            .push(subscription(SubState::Active, 0, Some("resume")));
21181        assert!(!client.previous_version_coverage_complete());
21182        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
21183        assert!(snapshot.available);
21184        assert_eq!(snapshot.previous_version, Some(2));
21185        assert_eq!(snapshot.rows.len(), 1);
21186
21187        recapture_previous_version(&client);
21188        client.subs[0].bootstrap_state = None;
21189        assert!(client.previous_version_coverage_complete());
21190        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
21191        assert_eq!(
21192            snapshot.reason,
21193            Some(PreviousVersionReason::CoverageComplete)
21194        );
21195        assert!(!std::path::Path::new(&container).exists());
21196        client.subs.clear();
21197
21198        // With no active subscription at all, coverage is NOT complete.
21199        assert!(!client.previous_version_coverage_complete());
21200
21201        drop(client);
21202        let _ = std::fs::remove_file(&path);
21203    }
21204
21205    /// RFC 0005 D7 lifetime drops. The read surface already proves the reasons
21206    /// come from live state; this proves the OTHER caller —
21207    /// `previous_version_lifetime_check`, the once-per-sync-round trigger —
21208    /// actually removes the container for lease-inactivity, coverage
21209    /// completion and scope revocation, and leaves it alone while the
21210    /// replacement bootstrap is still resuming.
21211    #[test]
21212    fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
21213        let path = std::env::temp_dir()
21214            .join(format!(
21215                "syncular-prev-lifetime-{}.db",
21216                uuid::Uuid::new_v4()
21217            ))
21218            .to_string_lossy()
21219            .into_owned();
21220        let container = format!(
21221            "{path}{}",
21222            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
21223        );
21224        let exists = || std::path::Path::new(&container).exists();
21225        {
21226            let mut setup = SyncClient::open_path(
21227                "lifetime".to_owned(),
21228                &previous_version_schema(1),
21229                previous_version_limits(),
21230                &path,
21231            )
21232            .expect("v1 install");
21233            setup
21234                .mutate(vec![Mutation::Upsert {
21235                    table: "tasks".to_owned(),
21236                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
21237                    base_version: None,
21238                }])
21239                .expect("seed a row");
21240        }
21241        let mut client = SyncClient::open_path(
21242            "lifetime".to_owned(),
21243            &previous_version_schema(2),
21244            previous_version_limits(),
21245            &path,
21246        )
21247        .expect("v2 bump captures");
21248        // Pin the clock so the 24h TTL never fires in this test (the TTL has
21249        // its own coverage); the capture record's createdAtMs is 1.
21250        client.set_now_ms(1_000);
21251        assert!(exists());
21252
21253        // §7.3.5 lease error ⇒ the lifetime check drops the container.
21254        client.lease_state = Some(LeaseState {
21255            lease_id: None,
21256            expires_at_ms: None,
21257            error_code: Some("lease.expired".to_owned()),
21258        });
21259        client.previous_version_lifetime_check();
21260        assert!(!exists());
21261        client.lease_state = None;
21262
21263        // §7.4.5 coverage completion (every active sub has a cursor and no
21264        // resume token) ⇒ drop.
21265        recapture_previous_version(&client);
21266        client.subs.push(subscription(SubState::Active, 0, None));
21267        client.previous_version_lifetime_check();
21268        assert!(!exists());
21269        client.subs.clear();
21270
21271        // §3.3 scope revocation ⇒ drop.
21272        recapture_previous_version(&client);
21273        client.subs.push(subscription(SubState::Revoked, 0, None));
21274        client.previous_version_lifetime_check();
21275        assert!(!exists());
21276        client.subs.clear();
21277
21278        // Negative control: a still-resuming active subscription is NOT
21279        // coverage completion, so the container survives the same call.
21280        recapture_previous_version(&client);
21281        client
21282            .subs
21283            .push(subscription(SubState::Active, 0, Some("resume")));
21284        client.previous_version_lifetime_check();
21285        assert!(exists());
21286        client.subs.clear();
21287
21288        drop(client);
21289        let _ = std::fs::remove_file(&path);
21290        let _ = std::fs::remove_file(&container);
21291        let _ = std::fs::remove_file(format!("{container}-wal"));
21292        let _ = std::fs::remove_file(format!("{container}-shm"));
21293    }
21294}