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    /// Serves one response and records the segment batch it causes. A single
1443    /// `download_segment` call means the core fell back to per-descriptor
1444    /// fetches.
1445    struct SegmentBatchTransport {
1446        response: Message,
1447        bodies: BTreeMap<String, Vec<u8>>,
1448        batches: Vec<Vec<String>>,
1449        single_fetches: usize,
1450    }
1451
1452    impl Transport for SegmentBatchTransport {
1453        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1454            Ok(encode_message(&self.response))
1455        }
1456
1457        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1458            self.sync(request)
1459        }
1460
1461        fn download_segment(
1462            &mut self,
1463            _request: &SegmentRequest,
1464            _on_progress: &mut dyn FnMut(u64),
1465        ) -> Result<Vec<u8>, TransportError> {
1466            self.single_fetches += 1;
1467            Err(TransportError::new(
1468                "sync.transport_failed",
1469                "per-descriptor fetch",
1470            ))
1471        }
1472
1473        fn download_segments(
1474            &mut self,
1475            fetches: &[crate::SegmentFetch],
1476            on_progress: &mut (dyn FnMut(usize, u64) + Send),
1477        ) -> Vec<Result<Vec<u8>, TransportError>> {
1478            self.batches.push(
1479                fetches
1480                    .iter()
1481                    .map(|fetch| match fetch {
1482                        crate::SegmentFetch::Direct(request) => request.segment_id.clone(),
1483                        crate::SegmentFetch::Url(url) => url.clone(),
1484                    })
1485                    .collect(),
1486            );
1487            fetches
1488                .iter()
1489                .enumerate()
1490                .map(|(index, fetch)| {
1491                    let crate::SegmentFetch::Direct(request) = fetch else {
1492                        panic!("the descriptors are direct-endpoint requests")
1493                    };
1494                    let bytes = self.bodies[&request.segment_id].clone();
1495                    on_progress(index, bytes.len() as u64);
1496                    Ok(bytes)
1497                })
1498                .collect()
1499        }
1500
1501        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1502            Ok(())
1503        }
1504
1505        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1506            Ok(())
1507        }
1508
1509        fn realtime_close(&mut self) -> Result<(), TransportError> {
1510            Ok(())
1511        }
1512    }
1513
1514    #[test]
1515    fn a_response_fetches_all_its_segment_pages_in_one_ordered_batch() {
1516        let mut client = client();
1517        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1518        client
1519            .subscribe(
1520                "tasks".into(),
1521                "tasks".into(),
1522                vec![("project_id".into(), vec!["p1".into()])],
1523                None,
1524            )
1525            .unwrap();
1526        client.subs[0].cursor = 0;
1527        client.subs[0].synced_once = true;
1528        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1529        client.persist_sub(&client.subs[0]).unwrap();
1530        client.drain_change_batches();
1531        let columns = client.schema.table("tasks").unwrap().wire_columns.clone();
1532
1533        // Four pages, one row each. Applying a page under another page's
1534        // descriptor fails §5.1 content-address verification, so a green round
1535        // proves the results kept the wire order.
1536        let pages = ["one", "two", "three", "four"].map(|id| {
1537            let bytes = ssp2::segment::encode_rows_segment(&RowsSegment {
1538                table: "tasks".into(),
1539                schema_version: 1,
1540                columns: columns.clone(),
1541                blocks: vec![vec![ssp2::segment::SegmentRow {
1542                    server_version: 1,
1543                    values: vec![
1544                        Some(ColumnValue::String(id.into())),
1545                        Some(ColumnValue::String("p1".into())),
1546                    ],
1547                }]],
1548            });
1549            (
1550                format!("sha256:{}", bytes_to_hex(&Sha256::digest(&bytes))),
1551                bytes,
1552            )
1553        });
1554
1555        let mut frames = vec![
1556            Frame::RespHeader {
1557                required_schema_version: None,
1558                latest_schema_version: None,
1559                log_epoch: Some("epoch-1".into()),
1560                reset_required: Some(false),
1561            },
1562            Frame::SubStart {
1563                id: "tasks".into(),
1564                status: SubStatus::Active,
1565                reason_code: String::new(),
1566                effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1567                bootstrap: false,
1568            },
1569        ];
1570        for (segment_id, bytes) in &pages {
1571            frames.push(Frame::SegmentRef {
1572                segment_id: segment_id.clone(),
1573                media_type: MediaType::Rows,
1574                table: "tasks".into(),
1575                byte_length: bytes.len() as i64,
1576                row_count: 1,
1577                as_of_commit_seq: 1,
1578                scope_digest: "digest".into(),
1579                row_cursor: None,
1580                next_row_cursor: None,
1581                url: None,
1582                url_expires_at_ms: None,
1583            });
1584        }
1585        frames.push(Frame::SubEnd {
1586            next_cursor: 1,
1587            bootstrap_state: None,
1588        });
1589        let mut transport = SegmentBatchTransport {
1590            response: Message {
1591                wire_version: WIRE_VERSION,
1592                msg_kind: MsgKind::Response,
1593                frames,
1594            },
1595            bodies: pages
1596                .iter()
1597                .map(|(segment_id, bytes)| (segment_id.clone(), bytes.clone()))
1598                .collect(),
1599            batches: Vec::new(),
1600            single_fetches: 0,
1601        };
1602
1603        let outcome = client.sync(&mut transport);
1604        assert!(matches!(outcome, SyncOutcome::Ok(_)), "{outcome:?}");
1605        assert_eq!(
1606            transport.batches,
1607            vec![pages
1608                .iter()
1609                .map(|(segment_id, _)| segment_id.clone())
1610                .collect::<Vec<_>>()],
1611            "one batch per response, in wire order"
1612        );
1613        assert_eq!(
1614            transport.single_fetches, 0,
1615            "the batch seam replaces the per-descriptor calls"
1616        );
1617        assert_eq!(
1618            client
1619                .query("SELECT id FROM tasks ORDER BY id", &[])
1620                .unwrap(),
1621            ["four", "one", "three", "two"]
1622                .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
1623                .to_vec()
1624        );
1625        assert_eq!(client.subs[0].cursor, 1);
1626    }
1627
1628    #[test]
1629    fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
1630        assert_eq!(
1631            client().diagnostics_storage().blob_cache_bytes_approx,
1632            Some(0)
1633        );
1634        let client = SyncClient::new(
1635            "diagnostic-storage".to_owned(),
1636            &json!({
1637                "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
1638                    "columns": [{"name": "id", "type": "string", "nullable": false},
1639                        {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
1640            }),
1641            ClientLimits {
1642                blob_cache_max_bytes: Some(5),
1643                ..Default::default()
1644            },
1645        )
1646        .unwrap();
1647        let empty = client.diagnostics_storage();
1648        assert_eq!(empty.status, "healthy");
1649        assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
1650        assert_eq!(empty.retained_outcome_entries, Some(0));
1651        assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1652        assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1653        client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1654            INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1655                VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1656            INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1657                VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1658        let written = client.diagnostics_storage();
1659        assert_eq!(written.status, "pressure");
1660        assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1661        assert_eq!(written.retained_outcome_entries, Some(2));
1662        assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1663        assert_eq!(written.blob_cache_bytes_approx, Some(8));
1664        assert_eq!(
1665            written.pressure_reason_code.as_deref(),
1666            Some("client.blob_cache_over_limit")
1667        );
1668        client
1669            .conn
1670            .execute_batch(
1671                "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1672            DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1673            )
1674            .unwrap();
1675        let staged = client.diagnostics_storage();
1676        assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1677        assert_eq!(staged.retained_outcome_entries, Some(0));
1678        assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1679        assert_eq!(staged.status, "healthy");
1680        client.conn.execute_batch("ROLLBACK").unwrap();
1681        assert_eq!(client.diagnostics_storage(), written);
1682        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1683        let unreadable = client.diagnostics_storage();
1684        assert_eq!(unreadable.status, "unreadable");
1685        assert!(unreadable.database_bytes_approx.is_none());
1686        assert!(unreadable.pending_outbox_bytes_approx.is_none());
1687        assert!(unreadable.retained_outcome_entries.is_none());
1688        assert!(unreadable.retained_outcome_bytes_approx.is_none());
1689        assert!(unreadable.blob_cache_bytes_approx.is_none());
1690        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1691        let restored = client.diagnostics_storage();
1692        assert_eq!(restored.status, written.status);
1693        assert_eq!(
1694            restored.retained_outcome_bytes_approx,
1695            written.retained_outcome_bytes_approx
1696        );
1697        client.conn.set_prepared_statement_cache_capacity(0);
1698        assert_eq!(client.diagnostics_storage(), restored);
1699    }
1700
1701    /// Answers every round with one server `ERROR` frame (§1.6).
1702    struct ServerErrorTransport {
1703        code: &'static str,
1704        retryable: bool,
1705    }
1706
1707    impl Transport for ServerErrorTransport {
1708        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1709            Ok(encode_message(&Message {
1710                wire_version: WIRE_VERSION,
1711                msg_kind: MsgKind::Response,
1712                frames: vec![
1713                    Frame::RespHeader {
1714                        required_schema_version: None,
1715                        latest_schema_version: None,
1716                        log_epoch: Some("epoch-1".to_owned()),
1717                        reset_required: Some(false),
1718                    },
1719                    Frame::Error {
1720                        code: self.code.to_owned(),
1721                        message: "server error".to_owned(),
1722                        category: "internal".to_owned(),
1723                        retryable: self.retryable,
1724                        recommended_action: "retryLater".to_owned(),
1725                        details: None,
1726                    },
1727                ],
1728            }))
1729        }
1730
1731        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1732            self.sync(request)
1733        }
1734
1735        fn download_segment(
1736            &mut self,
1737            _request: &SegmentRequest,
1738            _on_progress: &mut dyn FnMut(u64),
1739        ) -> Result<Vec<u8>, TransportError> {
1740            Err(TransportError::new("sync.transport_failed", "offline"))
1741        }
1742
1743        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1744            Ok(())
1745        }
1746
1747        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1748            Ok(())
1749        }
1750
1751        fn realtime_close(&mut self) -> Result<(), TransportError> {
1752            Ok(())
1753        }
1754    }
1755
1756    #[test]
1757    fn a_retryable_in_band_failure_keeps_the_socket_connected_and_still_asks_for_a_retry() {
1758        let mut client = client();
1759        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1760        let mut transport = ServerErrorTransport {
1761            code: "sync.schema_not_ready",
1762            retryable: true,
1763        };
1764        client.connect_realtime(&mut transport).expect("connect");
1765        assert!(matches!(
1766            client.sync(&mut transport),
1767            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.schema_not_ready"
1768        ));
1769        assert_eq!(
1770            client.realtime_state(),
1771            RealtimeState::Connected,
1772            "the socket delivered the answer, so it is not lost"
1773        );
1774        assert!(
1775            matches!(
1776                client.drain_sync_intents().as_slice(),
1777                [SyncIntent::Background { delay_ms: 250 }]
1778            ),
1779            "the retry deadline is a sync round and no reconnect satisfies it"
1780        );
1781        assert_eq!(
1782            client.progress().snapshot().unwrap().retry_delay_ms,
1783            Some(250)
1784        );
1785    }
1786
1787    #[test]
1788    fn retryable_server_error_schedules_a_background_retry() {
1789        let mut client = client();
1790        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1791        let mut transport = ServerErrorTransport {
1792            code: "sync.internal_error",
1793            retryable: true,
1794        };
1795        assert!(matches!(
1796            client.sync(&mut transport),
1797            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1798        ));
1799        assert!(matches!(
1800            client.drain_sync_intents().as_slice(),
1801            [SyncIntent::Background { delay_ms: 250 }]
1802        ));
1803        assert_eq!(
1804            client.progress().snapshot().unwrap().retry_delay_ms,
1805            Some(250)
1806        );
1807        let mut transport = ServerErrorTransport {
1808            code: "sync.invalid_request",
1809            retryable: false,
1810        };
1811        assert!(matches!(
1812            client.sync(&mut transport),
1813            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1814        ));
1815        assert!(client.drain_sync_intents().is_empty());
1816        let progress = client.progress().snapshot().unwrap();
1817        assert_eq!(progress.state, ProgressState::Failed);
1818        assert_eq!(progress.retry_delay_ms, None);
1819    }
1820
1821    #[test]
1822    fn sync_until_idle_reports_budget_exhaustion_or_failure() {
1823        // §7.7: the exhausted outcome is a partial success, not a failure,
1824        // and serializes with an explicit flag.
1825        let exhausted = SyncOutcome::BudgetExhausted(SyncReport::default()).to_json();
1826        assert_eq!(exhausted["ok"], json!(true));
1827        assert_eq!(exhausted["budgetExhausted"], json!(true));
1828        assert!(exhausted["report"].is_object());
1829
1830        let mut client = client();
1831        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1832        // Zero explicit budget is a caller error, not an exhaustion.
1833        let mut idle_transport = HostTransport::new_from_config(&json!({})).unwrap();
1834        let zero = client.sync_until_idle(&mut idle_transport, Some(0));
1835        assert!(
1836            matches!(zero, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"),
1837            "{zero:?}"
1838        );
1839        // A real server failure stays a failure across the budget loop.
1840        let mut erroring = ServerErrorTransport {
1841            code: "sync.internal_error",
1842            retryable: false,
1843        };
1844        let failed = client.sync_until_idle(&mut erroring, Some(3));
1845        assert!(
1846            matches!(failed, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"),
1847            "{failed:?}"
1848        );
1849    }
1850
1851    /// Returns one pre-built response per round so a test can pin a successful
1852    /// work-bearing round followed by a failure or a schema floor.
1853    struct ScriptedSyncTransport {
1854        responses: std::collections::VecDeque<Message>,
1855        calls: usize,
1856    }
1857
1858    impl Transport for ScriptedSyncTransport {
1859        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1860            self.calls += 1;
1861            let response = self.responses.pop_front().expect("scripted response");
1862            Ok(encode_message(&response))
1863        }
1864
1865        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1866            self.sync(request)
1867        }
1868
1869        fn download_segment(
1870            &mut self,
1871            _request: &SegmentRequest,
1872            _on_progress: &mut dyn FnMut(u64),
1873        ) -> Result<Vec<u8>, TransportError> {
1874            Err(TransportError::new("sync.transport_failed", "offline"))
1875        }
1876
1877        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1878            Ok(())
1879        }
1880
1881        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1882            Ok(())
1883        }
1884
1885        fn realtime_close(&mut self) -> Result<(), TransportError> {
1886            Ok(())
1887        }
1888    }
1889
1890    #[test]
1891    fn sync_until_idle_success_then_failure_keeps_earlier_work() {
1892        let path = std::env::temp_dir().join(format!(
1893            "syncular-budget-script-{}.sqlite",
1894            uuid::Uuid::new_v4()
1895        ));
1896        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1897            {"name":"id","type":"string","nullable":false},
1898            {"name":"project_id","type":"string","nullable":false}],
1899            "scopes":[{"pattern":"project:{project_id}"}]}]});
1900        let mut client = SyncClient::open_path(
1901            "budget-script".into(),
1902            &schema,
1903            ClientLimits::default(),
1904            path.to_str().unwrap(),
1905        )
1906        .expect("open client");
1907        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1908        client
1909            .subscribe(
1910                "tasks".into(),
1911                "tasks".into(),
1912                vec![("project_id".into(), vec!["p1".into()])],
1913                None,
1914            )
1915            .unwrap();
1916        client.subs[0].cursor = 0;
1917        client.subs[0].synced_once = true;
1918        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1919        client.persist_sub(&client.subs[0]).unwrap();
1920        client.drain_change_batches();
1921
1922        let row = encode_row_json(
1923            client.schema.table("tasks").unwrap(),
1924            "kept",
1925            &Map::from_iter([
1926                ("id".into(), json!("kept")),
1927                ("project_id".into(), json!("p1")),
1928            ]),
1929            &client.encryption,
1930        )
1931        .unwrap();
1932        let success = Message {
1933            wire_version: WIRE_VERSION,
1934            msg_kind: MsgKind::Response,
1935            frames: vec![
1936                Frame::RespHeader {
1937                    required_schema_version: None,
1938                    latest_schema_version: None,
1939                    log_epoch: Some("epoch-1".into()),
1940                    reset_required: Some(false),
1941                },
1942                Frame::SubStart {
1943                    id: "tasks".into(),
1944                    status: SubStatus::Active,
1945                    reason_code: String::new(),
1946                    effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1947                    bootstrap: false,
1948                },
1949                Frame::Commit {
1950                    commit_seq: 1,
1951                    created_at_ms: 1,
1952                    actor_id: "writer".into(),
1953                    tables: vec!["tasks".into()],
1954                    changes: vec![ssp2::model::Change {
1955                        table_index: 0,
1956                        row_id: "kept".into(),
1957                        op: Op::Upsert,
1958                        row_version: Some(1),
1959                        scopes: vec![("project_id".into(), "p1".into())],
1960                        row: Some(row),
1961                    }],
1962                },
1963                Frame::SubEnd {
1964                    next_cursor: 1,
1965                    bootstrap_state: None,
1966                },
1967            ],
1968        };
1969        let failure = Message {
1970            wire_version: WIRE_VERSION,
1971            msg_kind: MsgKind::Response,
1972            frames: vec![
1973                Frame::RespHeader {
1974                    required_schema_version: None,
1975                    latest_schema_version: None,
1976                    log_epoch: Some("epoch-1".into()),
1977                    reset_required: Some(false),
1978                },
1979                Frame::Error {
1980                    code: "sync.internal_error".into(),
1981                    message: "second round failed".into(),
1982                    category: "internal".into(),
1983                    retryable: false,
1984                    recommended_action: "retryLater".into(),
1985                    details: None,
1986                },
1987            ],
1988        };
1989        let mut transport = ScriptedSyncTransport {
1990            responses: std::collections::VecDeque::from([success, failure]),
1991            calls: 0,
1992        };
1993        let outcome = client.sync_until_idle(&mut transport, Some(2));
1994        assert!(
1995            matches!(outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"),
1996            "{outcome:?}"
1997        );
1998        assert_eq!(
1999            transport.calls, 2,
2000            "one successful round then the failing round"
2001        );
2002        assert_eq!(
2003            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
2004            json!("kept"),
2005            "the earlier round's durable work survives the later failure"
2006        );
2007        drop(client);
2008        let _ = std::fs::remove_file(&path);
2009    }
2010
2011    #[test]
2012    fn sync_until_idle_stops_on_a_schema_floor() {
2013        let mut client = client();
2014        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2015        let floor = Message {
2016            wire_version: WIRE_VERSION,
2017            msg_kind: MsgKind::Response,
2018            frames: vec![Frame::RespHeader {
2019                required_schema_version: Some(2),
2020                latest_schema_version: Some(2),
2021                log_epoch: Some("epoch-1".into()),
2022                reset_required: Some(false),
2023            }],
2024        };
2025        let mut transport = ScriptedSyncTransport {
2026            responses: std::collections::VecDeque::from([floor.clone(), floor]),
2027            calls: 0,
2028        };
2029        let outcome = client.sync_until_idle(&mut transport, Some(3));
2030        match outcome {
2031            SyncOutcome::Ok(report) => assert_eq!(
2032                report
2033                    .schema_floor
2034                    .as_ref()
2035                    .and_then(|floor| floor.required_schema_version),
2036                Some(2),
2037                "the stop retains the latest schema floor"
2038            ),
2039            other => panic!("expected a schema-floor stop, got {other:?}"),
2040        }
2041        assert_eq!(
2042            transport.calls, 1,
2043            "a schema floor stops the budget loop after one round"
2044        );
2045    }
2046
2047    /// Records the decoded request of every round and answers from a script of
2048    /// round results, so a test can pin what the following request carries.
2049    struct FailureScriptTransport {
2050        rounds: std::collections::VecDeque<Result<Vec<u8>, TransportError>>,
2051        requests: Vec<Message>,
2052    }
2053
2054    impl Transport for FailureScriptTransport {
2055        fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2056            self.requests
2057                .push(decode_message(request).expect("the client encodes a legal request"));
2058            self.rounds.pop_front().expect("scripted round")
2059        }
2060
2061        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2062            self.sync(request)
2063        }
2064
2065        fn download_segment(
2066            &mut self,
2067            _request: &SegmentRequest,
2068            _on_progress: &mut dyn FnMut(u64),
2069        ) -> Result<Vec<u8>, TransportError> {
2070            Err(TransportError::new("sync.transport_failed", "offline"))
2071        }
2072
2073        fn realtime_connect(&mut self) -> Result<(), TransportError> {
2074            Ok(())
2075        }
2076
2077        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
2078            Ok(())
2079        }
2080
2081        fn realtime_close(&mut self) -> Result<(), TransportError> {
2082            Ok(())
2083        }
2084    }
2085
2086    /// A complete response: RESP_HEADER for the client's epoch plus `frames`.
2087    fn completed_response(frames: Vec<Frame>) -> Vec<u8> {
2088        let mut all = vec![Frame::RespHeader {
2089            required_schema_version: None,
2090            latest_schema_version: None,
2091            log_epoch: Some("epoch-1".into()),
2092            reset_required: Some(false),
2093        }];
2094        all.extend(frames);
2095        encode_message(&Message {
2096            wire_version: WIRE_VERSION,
2097            msg_kind: MsgKind::Response,
2098            frames: all,
2099        })
2100    }
2101
2102    fn carried_round_failures(request: &Message) -> Vec<String> {
2103        request
2104            .frames
2105            .iter()
2106            .filter_map(|frame| match frame {
2107                Frame::RoundFailure { code } => Some(code.clone()),
2108                _ => None,
2109            })
2110            .collect()
2111    }
2112
2113    fn failure_transport(
2114        rounds: impl IntoIterator<Item = Result<Vec<u8>, TransportError>>,
2115    ) -> FailureScriptTransport {
2116        FailureScriptTransport {
2117            rounds: rounds.into_iter().collect(),
2118            requests: Vec::new(),
2119        }
2120    }
2121
2122    #[test]
2123    fn a_round_without_a_complete_response_reports_its_code_on_every_following_request() {
2124        let mut client = client();
2125        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2126        let mut transport = failure_transport([
2127            Err(TransportError::new("transport.timeout", "round timed out")),
2128            Ok(completed_response(Vec::new())),
2129            Ok(completed_response(Vec::new())),
2130        ]);
2131        for _ in 0..3 {
2132            client.sync(&mut transport);
2133        }
2134        assert!(carried_round_failures(&transport.requests[0]).is_empty());
2135        assert_eq!(
2136            carried_round_failures(&transport.requests[1]),
2137            vec!["transport.timeout"]
2138        );
2139        assert!(
2140            matches!(transport.requests[1].frames[0], Frame::ReqHeader { .. })
2141                && matches!(transport.requests[1].frames[1], Frame::RoundFailure { .. }),
2142            "the report sits immediately after REQ_HEADER"
2143        );
2144        assert!(
2145            carried_round_failures(&transport.requests[2]).is_empty(),
2146            "a complete response ends the report"
2147        );
2148    }
2149
2150    #[test]
2151    fn a_newer_round_failure_replaces_the_kept_code() {
2152        let mut client = client();
2153        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2154        let mut transport = failure_transport([
2155            Err(TransportError::new("transport.timeout", "round timed out")),
2156            Err(TransportError::new("transport.failed", "socket closed")),
2157            Ok(completed_response(Vec::new())),
2158            Ok(completed_response(Vec::new())),
2159        ]);
2160        for _ in 0..4 {
2161            client.sync(&mut transport);
2162        }
2163        assert_eq!(
2164            carried_round_failures(&transport.requests[1]),
2165            vec!["transport.timeout"]
2166        );
2167        assert_eq!(
2168            carried_round_failures(&transport.requests[2]),
2169            vec!["transport.failed"]
2170        );
2171        assert!(carried_round_failures(&transport.requests[3]).is_empty());
2172    }
2173
2174    #[test]
2175    fn an_in_band_error_completes_the_round_and_is_never_reported() {
2176        let mut client = client();
2177        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2178        let mut transport = failure_transport([
2179            Err(TransportError::new("transport.timeout", "round timed out")),
2180            Ok(completed_response(vec![Frame::Error {
2181                code: "sync.internal_error".into(),
2182                message: "server error".into(),
2183                category: "internal".into(),
2184                retryable: false,
2185                recommended_action: "retryLater".into(),
2186                details: None,
2187            }])),
2188            Ok(completed_response(Vec::new())),
2189        ]);
2190        client.sync(&mut transport);
2191        assert!(matches!(
2192            client.sync(&mut transport),
2193            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
2194        ));
2195        client.sync(&mut transport);
2196        assert_eq!(
2197            carried_round_failures(&transport.requests[1]),
2198            vec!["transport.timeout"]
2199        );
2200        assert!(
2201            carried_round_failures(&transport.requests[2]).is_empty(),
2202            "the server already recorded an in-band ERROR"
2203        );
2204    }
2205
2206    #[test]
2207    fn a_code_the_frame_grammar_rejects_is_not_attached() {
2208        let mut client = client();
2209        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2210        let mut transport = failure_transport([
2211            Err(TransportError::new("offline", "no dotted code")),
2212            Ok(completed_response(Vec::new())),
2213        ]);
2214        client.sync(&mut transport);
2215        client.sync(&mut transport);
2216        assert!(carried_round_failures(&transport.requests[1]).is_empty());
2217    }
2218
2219    #[test]
2220    fn sync_report_merge_schema_floor_is_latest_state() {
2221        let mut aggregate = SyncReport {
2222            schema_floor: Some(SchemaFloor {
2223                required_schema_version: Some(2),
2224                latest_schema_version: Some(2),
2225            }),
2226            ..SyncReport::default()
2227        };
2228        aggregate.merge(&SyncReport::default());
2229        assert!(
2230            aggregate.schema_floor.is_none(),
2231            "a later round without a floor clears it"
2232        );
2233    }
2234
2235    #[test]
2236    fn push_capacity_limits_are_hard_and_keep_intent() {
2237        // The configured operation cap is hard for the first commit.
2238        let mut c = client();
2239        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2240        c.limits.max_push_operations_per_request = Some(1);
2241        let id = c
2242            .mutate(vec![
2243                Mutation::Upsert {
2244                    table: "tasks".into(),
2245                    values: Map::from_iter([
2246                        ("id".into(), json!("a")),
2247                        ("project_id".into(), json!("p1")),
2248                    ]),
2249                    base_version: None,
2250                },
2251                Mutation::Upsert {
2252                    table: "tasks".into(),
2253                    values: Map::from_iter([
2254                        ("id".into(), json!("b")),
2255                        ("project_id".into(), json!("p1")),
2256                    ]),
2257                    base_version: None,
2258                },
2259            ])
2260            .unwrap();
2261        let error = c.build_request(false).unwrap_err();
2262        let SyncOutcome::Failed {
2263            error_code,
2264            details,
2265            ..
2266        } = *error
2267        else {
2268            panic!("expected a capacity failure");
2269        };
2270        assert_eq!(error_code, "client.push_request_too_large");
2271        let details = details.expect("capacity details");
2272        assert_eq!(details["kind"], json!("operations"));
2273        assert_eq!(details["limit"], json!(1));
2274        assert_eq!(details["size"], json!(2));
2275        assert_eq!(details["clientCommitId"], json!(id));
2276        assert_eq!(c.pending_commit_ids(), vec![id]);
2277
2278        // The commit-count cap defers whole later commits.
2279        let mut c = client();
2280        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2281        c.limits.max_push_commits_per_request = Some(1);
2282        let first = c
2283            .mutate(vec![Mutation::Upsert {
2284                table: "tasks".into(),
2285                values: Map::from_iter([
2286                    ("id".into(), json!("one")),
2287                    ("project_id".into(), json!("p1")),
2288                ]),
2289                base_version: None,
2290            }])
2291            .unwrap();
2292        let second = c
2293            .mutate(vec![Mutation::Upsert {
2294                table: "tasks".into(),
2295                values: Map::from_iter([
2296                    ("id".into(), json!("two")),
2297                    ("project_id".into(), json!("p1")),
2298                ]),
2299                base_version: None,
2300            }])
2301            .unwrap();
2302        let (_, meta) = c.build_request(false).unwrap();
2303        assert_eq!(meta.pushed_ids, vec![first.clone()]);
2304        assert_eq!(meta.deferred_commits, 1);
2305        assert_eq!(c.pending_commit_ids(), vec![first, second]);
2306    }
2307
2308    #[test]
2309    fn push_request_byte_cap_defers_the_prefix_boundary_and_errors_the_head() {
2310        // Two small commits: a cap just below the full request defers the
2311        // last commit and sends the fitting prefix (header + first commit).
2312        let mut c = client();
2313        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2314        let first = c
2315            .mutate(vec![Mutation::Upsert {
2316                table: "tasks".into(),
2317                values: Map::from_iter([
2318                    ("id".into(), json!("one")),
2319                    ("project_id".into(), json!("p1")),
2320                ]),
2321                base_version: None,
2322            }])
2323            .unwrap();
2324        let second = c
2325            .mutate(vec![Mutation::Upsert {
2326                table: "tasks".into(),
2327                values: Map::from_iter([
2328                    ("id".into(), json!("two")),
2329                    ("project_id".into(), json!("p1")),
2330                ]),
2331                base_version: None,
2332            }])
2333            .unwrap();
2334        let (message, meta) = c.build_request(false).unwrap();
2335        assert_eq!(meta.pushed_ids.len(), 2);
2336        let full = encode_message(&message).len();
2337        c.limits.max_push_request_bytes = Some(full - 1);
2338        let (_, meta) = c.build_request(false).unwrap();
2339        assert_eq!(meta.pushed_ids, vec![first.clone()]);
2340        assert_eq!(meta.deferred_commits, 1);
2341        assert_eq!(c.pending_commit_ids(), vec![first, second]);
2342
2343        // A single commit larger than the budget is a typed capacity error and
2344        // stays queued, reporting the full projected request size.
2345        let mut c = client();
2346        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2347        let huge = c
2348            .mutate(vec![Mutation::Upsert {
2349                table: "tasks".into(),
2350                values: Map::from_iter([
2351                    ("id".into(), json!("x".repeat(20_000))),
2352                    ("project_id".into(), json!("p1")),
2353                ]),
2354                base_version: None,
2355            }])
2356            .unwrap();
2357        let (message, meta) = c.build_request(false).unwrap();
2358        assert_eq!(meta.pushed_ids, vec![huge.clone()]);
2359        let with_huge = encode_message(&message).len();
2360        c.limits.max_push_request_bytes = Some(with_huge - 1);
2361        let error = c.build_request(false).unwrap_err();
2362        let SyncOutcome::Failed {
2363            error_code,
2364            details,
2365            ..
2366        } = *error
2367        else {
2368            panic!("expected a capacity failure");
2369        };
2370        assert_eq!(error_code, "client.push_request_too_large");
2371        let details = details.expect("capacity details");
2372        assert_eq!(details["kind"], json!("bytes"));
2373        assert_eq!(details["limit"], json!((with_huge - 1) as u64));
2374        assert_eq!(details["size"], json!(with_huge as u64));
2375        assert_eq!(details["clientCommitId"], json!(huge));
2376        assert_eq!(c.pending_commit_ids(), vec![huge]);
2377
2378        // A cap smaller than the fixed frames reports no blocked commit.
2379        let mut c = client();
2380        c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2381        c.limits.max_push_request_bytes = Some(1);
2382        let error = c.build_request(false).unwrap_err();
2383        let SyncOutcome::Failed { details, .. } = *error else {
2384            panic!("expected a capacity failure");
2385        };
2386        let details = details.expect("capacity details");
2387        assert_eq!(details["kind"], json!("bytes"));
2388        assert!(details.get("clientCommitId").is_none(), "{details}");
2389    }
2390
2391    #[test]
2392    fn push_limits_validate_at_construction() {
2393        for limits in [
2394            ClientLimits {
2395                max_push_commits_per_request: Some(0),
2396                ..Default::default()
2397            },
2398            ClientLimits {
2399                max_push_operations_per_request: Some(0),
2400                ..Default::default()
2401            },
2402            ClientLimits {
2403                max_push_request_bytes: Some(0),
2404                ..Default::default()
2405            },
2406            ClientLimits {
2407                max_push_request_bytes: Some(u32::MAX as usize + 1),
2408                ..Default::default()
2409            },
2410        ] {
2411            let schema = json!({"version":1,"tables":[]});
2412            let error = SyncClient::with_connection(
2413                "bad-limits".into(),
2414                &schema,
2415                limits,
2416                Connection::open_in_memory().unwrap(),
2417            )
2418            .err();
2419            assert_eq!(
2420                error.as_deref(),
2421                Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2422            );
2423        }
2424    }
2425
2426    #[test]
2427    fn open_path_rejects_invalid_push_limits_before_storage() {
2428        for identity in [false, true] {
2429            let path = std::env::temp_dir().join(format!(
2430                "syncular-bad-limits-{}.sqlite",
2431                uuid::Uuid::new_v4()
2432            ));
2433            let schema = json!({"version":1,"tables":[]});
2434            let limits = ClientLimits {
2435                max_push_request_bytes: Some(0),
2436                ..Default::default()
2437            };
2438            let error = if identity {
2439                SyncClient::open_path_with_identity(None, &schema, limits, path.to_str().unwrap())
2440            } else {
2441                SyncClient::open_path("bad-limits".into(), &schema, limits, path.to_str().unwrap())
2442            }
2443            .err();
2444            assert_eq!(
2445                error.as_deref(),
2446                Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2447            );
2448            assert!(
2449                !path.exists(),
2450                "an invalid limit is rejected before the database file is created"
2451            );
2452        }
2453    }
2454
2455    #[test]
2456    fn failed_progress_retry_delay_doubles_to_the_cap() {
2457        let mut client = client();
2458        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2459        let mut transport = ServerErrorTransport {
2460            code: "sync.internal_error",
2461            retryable: true,
2462        };
2463        let delays: Vec<Option<u64>> = (0..9)
2464            .map(|_| {
2465                client.sync(&mut transport);
2466                client.progress().snapshot().unwrap().retry_delay_ms
2467            })
2468            .collect();
2469        assert_eq!(
2470            delays,
2471            [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
2472        );
2473    }
2474
2475    #[test]
2476    fn background_retry_deadlines_back_off_and_reset() {
2477        let mut client = client();
2478        client.schedule_background_retry();
2479        assert!(matches!(
2480            client.drain_sync_intents().as_slice(),
2481            [SyncIntent::Background { delay_ms: 250 }]
2482        ));
2483        client.schedule_background_retry();
2484        assert!(matches!(
2485            client.drain_sync_intents().as_slice(),
2486            [SyncIntent::Background { delay_ms: 500 }]
2487        ));
2488        client.reset_background_retry();
2489        client.schedule_background_retry();
2490        assert!(matches!(
2491            client.drain_sync_intents().as_slice(),
2492            [SyncIntent::Background { delay_ms: 250 }]
2493        ));
2494    }
2495
2496    #[test]
2497    fn log_epoch_reset_requests_an_immediate_follow_up_round() {
2498        let mut client = client();
2499        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2500        client
2501            .subscribe(
2502                "epoch-tasks".to_owned(),
2503                "tasks".to_owned(),
2504                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2505                None,
2506            )
2507            .expect("subscribe");
2508        client.drain_sync_intents();
2509
2510        client
2511            .run_log_epoch_reset("epoch-2")
2512            .expect("reset partition log epoch");
2513
2514        assert!(matches!(
2515            client.drain_sync_intents().as_slice(),
2516            [SyncIntent::Interactive]
2517        ));
2518    }
2519
2520    #[derive(Default)]
2521    struct CountingRealtimeTransport {
2522        connects: usize,
2523        closes: usize,
2524        messages: Vec<String>,
2525        blob_uploads: Vec<(String, Vec<u8>)>,
2526        blob_download: Option<Vec<u8>>,
2527        sync_calls: usize,
2528        realtime_sync_calls: usize,
2529        refuse_connect: bool,
2530    }
2531
2532    impl Transport for CountingRealtimeTransport {
2533        fn blob_upload(
2534            &mut self,
2535            blob_id: &str,
2536            bytes: &[u8],
2537            _media_type: Option<&str>,
2538        ) -> Result<(), TransportError> {
2539            self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
2540            Ok(())
2541        }
2542
2543        fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
2544            self.blob_download
2545                .clone()
2546                .map(BlobDownload::Bytes)
2547                .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
2548        }
2549
2550        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2551            self.sync_calls += 1;
2552            Err(TransportError::new("sync.transport_failed", "offline"))
2553        }
2554
2555        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2556            self.realtime_sync_calls += 1;
2557            Err(TransportError::new("sync.transport_failed", "offline"))
2558        }
2559
2560        fn download_segment(
2561            &mut self,
2562            _request: &SegmentRequest,
2563            _on_progress: &mut dyn FnMut(u64),
2564        ) -> Result<Vec<u8>, TransportError> {
2565            Err(TransportError::new("sync.transport_failed", "offline"))
2566        }
2567
2568        fn realtime_connect(&mut self) -> Result<(), TransportError> {
2569            self.connects += 1;
2570            if self.refuse_connect {
2571                return Err(TransportError::new("transport.failed", "refused"));
2572            }
2573            Ok(())
2574        }
2575
2576        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
2577            self.messages.push(text.to_owned());
2578            Ok(())
2579        }
2580
2581        fn realtime_close(&mut self) -> Result<(), TransportError> {
2582            self.closes += 1;
2583            Ok(())
2584        }
2585    }
2586
2587    #[test]
2588    fn blob_results_own_downloaded_bytes() {
2589        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
2590            {"name":"id","type":"string","nullable":false},
2591            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
2592        let bytes = b"owned typed result".to_vec();
2593        let blob_id = blob_id_for(&bytes);
2594        let mut transport = CountingRealtimeTransport {
2595            blob_download: Some(bytes.clone()),
2596            ..Default::default()
2597        };
2598        let result = {
2599            let mut client =
2600                SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
2601                    .unwrap();
2602            let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
2603            assert_eq!(result.blob_id, blob_id);
2604            assert_eq!(result.byte_length, bytes.len() as i64);
2605            assert_eq!(result.bytes, bytes);
2606            assert_eq!(result.media_type, None);
2607            assert_eq!(
2608                client
2609                    .conn
2610                    .query_row(
2611                        "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
2612                        rusqlite::params![blob_id],
2613                        |row| row.get::<_, i64>(0),
2614                    )
2615                    .unwrap(),
2616                0
2617            );
2618            result
2619        };
2620        assert_eq!(result.bytes, bytes);
2621
2622        let mut client =
2623            SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
2624        assert_eq!(
2625            client
2626                .fetch_blob_bytes(&mut transport, "not json")
2627                .unwrap_err(),
2628            (
2629                "client.failed".to_owned(),
2630                "blob ref is not JSON".to_owned()
2631            )
2632        );
2633    }
2634
2635    #[test]
2636    fn visible_blob_references_survive_cache_trimming() {
2637        let schema = json!({"version":1,"tables":[
2638            {"name":"attachments","primaryKey":"id","columns":[
2639                {"name":"id","type":"string","nullable":false},
2640                {"name":"body","type":"blob_ref","nullable":true},
2641                {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
2642            {"name":"avatars","primaryKey":"id","columns":[
2643                {"name":"id","type":"string","nullable":false},
2644                {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
2645        ]});
2646        let bytes = b"referenced above cap".to_vec();
2647        let blob_id = blob_id_for(&bytes);
2648        let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
2649        let mut client = SyncClient::new(
2650            "visible-blob-refs".to_owned(),
2651            &schema,
2652            ClientLimits {
2653                blob_cache_max_bytes: Some(1),
2654                ..Default::default()
2655            },
2656        )
2657        .unwrap();
2658        client.conn.execute(
2659            "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
2660            rusqlite::params![reference, reference, reference, reference],
2661        ).unwrap();
2662        client
2663            .conn
2664            .execute(
2665                "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
2666                rusqlite::params![reference],
2667            )
2668            .unwrap();
2669        client.conn.execute(
2670            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
2671            [],
2672        ).unwrap();
2673        let mut transport = CountingRealtimeTransport {
2674            blob_download: Some(bytes.clone()),
2675            ..Default::default()
2676        };
2677
2678        assert_eq!(
2679            client
2680                .fetch_blob_bytes(&mut transport, &blob_id)
2681                .unwrap()
2682                .bytes,
2683            bytes
2684        );
2685        let rows = client
2686            .conn
2687            .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
2688            .unwrap()
2689            .query_map([], |row| row.get::<_, String>(0))
2690            .unwrap()
2691            .collect::<Result<Vec<_>, _>>()
2692            .unwrap();
2693        assert_eq!(rows, vec![blob_id]);
2694    }
2695
2696    #[test]
2697    fn closed_transport_never_invokes_network_and_reopen_is_host_owned() {
2698        let mut transport = CountingRealtimeTransport::default();
2699        let path = std::env::temp_dir().join(format!(
2700            "syncular-transport-gate-{}.db",
2701            uuid::Uuid::new_v4()
2702        ));
2703        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
2704            {"name":"id","type":"string","nullable":false},
2705            {"name":"project_id","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
2706        let mut client = SyncClient::open_path(
2707            "gate".into(),
2708            &schema,
2709            ClientLimits::default(),
2710            path.to_str().unwrap(),
2711        )
2712        .unwrap();
2713        client.connect_realtime(&mut transport).unwrap();
2714        client.set_transport_enabled(&mut transport, false);
2715        assert_eq!(transport.closes, 1);
2716        client.begin_security_preflight();
2717        client.activate_security(Default::default()).unwrap();
2718        client
2719            .subscribe(
2720                "tasks".into(),
2721                "tasks".into(),
2722                vec![("project_id".into(), vec!["p1".into()])],
2723                None,
2724            )
2725            .unwrap();
2726        for id in ["one", "two"] {
2727            client
2728                .mutate(vec![Mutation::Upsert {
2729                    table: "tasks".into(),
2730                    values: Map::from_iter([
2731                        ("id".into(), json!(id)),
2732                        ("project_id".into(), json!("p1")),
2733                    ]),
2734                    base_version: None,
2735                }])
2736                .unwrap();
2737        }
2738        assert_eq!(
2739            client
2740                .query("SELECT id FROM tasks ORDER BY id", &[])
2741                .unwrap()
2742                .len(),
2743            2
2744        );
2745        assert!(
2746            matches!(client.sync(&mut transport), SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
2747        );
2748        assert!(client
2749            .connect_realtime(&mut transport)
2750            .unwrap_err()
2751            .starts_with("sync.offline:"));
2752        assert!(client
2753            .set_presence(&mut transport, "project:p1", None)
2754            .unwrap_err()
2755            .starts_with("sync.offline:"));
2756        assert_eq!(transport.sync_calls, 0);
2757        assert_eq!(transport.realtime_sync_calls, 0);
2758        assert_eq!(transport.connects, 1);
2759        assert!(transport.messages.is_empty());
2760        assert!(matches!(
2761            client.drain_sync_intents().as_slice(),
2762            [SyncIntent::None]
2763        ));
2764        let pending = client.pending_commit_ids();
2765        drop(client);
2766        let mut reopened = SyncClient::open_path(
2767            "gate".into(),
2768            &schema,
2769            ClientLimits::default(),
2770            path.to_str().unwrap(),
2771        )
2772        .unwrap();
2773        assert!(reopened.transport_enabled());
2774        reopened.set_transport_enabled(&mut transport, false);
2775        assert_eq!(reopened.pending_commit_ids(), pending);
2776        assert_eq!(
2777            reopened.query("SELECT id FROM tasks", &[]).unwrap().len(),
2778            2
2779        );
2780        reopened.set_transport_enabled(&mut transport, true);
2781        assert!(matches!(
2782            reopened.drain_sync_intents().as_slice(),
2783            [SyncIntent::Interactive]
2784        ));
2785        reopened.set_transport_enabled(&mut transport, true);
2786        assert!(reopened.drain_sync_intents().is_empty());
2787        drop(reopened);
2788        std::fs::remove_file(path).unwrap();
2789    }
2790
2791    #[test]
2792    fn realtime_connection_ownership_is_idempotent() {
2793        let mut client = client();
2794        let mut transport = CountingRealtimeTransport {
2795            connects: 0,
2796            closes: 0,
2797            ..Default::default()
2798        };
2799        client
2800            .connect_realtime(&mut transport)
2801            .expect("first connect");
2802        client
2803            .connect_realtime(&mut transport)
2804            .expect("idempotent connect");
2805        assert_eq!(transport.connects, 1);
2806        client.disconnect_realtime(&mut transport);
2807        client.disconnect_realtime(&mut transport);
2808        assert_eq!(transport.closes, 1);
2809        client
2810            .connect_realtime(&mut transport)
2811            .expect("deliberate reconnect");
2812        assert_eq!(transport.connects, 2);
2813    }
2814
2815    #[test]
2816    fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
2817        let mut client = client();
2818        client
2819            .set_realtime_policy(RealtimePolicy::Required)
2820            .expect("set policy");
2821        let mut transport = CountingRealtimeTransport::default();
2822        match client.sync(&mut transport) {
2823            SyncOutcome::RealtimeUnavailable {
2824                state,
2825                reason_code,
2826                retry_delay_ms,
2827            } => {
2828                assert_eq!(state, RealtimeState::Disconnected);
2829                assert_eq!(reason_code, None);
2830                assert_eq!(retry_delay_ms, 250);
2831            }
2832            other => panic!("expected RealtimeUnavailable, got {other:?}"),
2833        }
2834        assert_eq!(transport.sync_calls, 0);
2835        assert!(matches!(
2836            client.drain_sync_intents().as_slice(),
2837            [SyncIntent::Background { delay_ms: 250 }]
2838        ));
2839        let snapshot = client
2840            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2841            .expect("diagnostics");
2842        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
2843        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
2844        assert_eq!(snapshot.host.realtime_reason_code, None);
2845        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
2846        assert_eq!(
2847            snapshot
2848                .last_round
2849                .as_ref()
2850                .and_then(|round| round.error_code.clone()),
2851            Some(REALTIME_UNAVAILABLE_CODE.to_owned())
2852        );
2853    }
2854
2855    #[test]
2856    fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
2857        let mut client = client();
2858        client
2859            .set_realtime_policy(RealtimePolicy::Required)
2860            .expect("set policy");
2861        let mut transport = CountingRealtimeTransport {
2862            refuse_connect: true,
2863            ..Default::default()
2864        };
2865        assert!(client.connect_realtime(&mut transport).is_err());
2866        assert_eq!(client.realtime_state(), RealtimeState::Refused);
2867        let snapshot = client
2868            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2869            .expect("diagnostics");
2870        assert_eq!(
2871            snapshot.host.realtime_reason_code.as_deref(),
2872            Some("transport.failed")
2873        );
2874        match client.sync(&mut transport) {
2875            SyncOutcome::RealtimeUnavailable {
2876                state, reason_code, ..
2877            } => {
2878                assert_eq!(state, RealtimeState::Refused);
2879                assert_eq!(reason_code.as_deref(), Some("transport.failed"));
2880            }
2881            other => panic!("expected RealtimeUnavailable, got {other:?}"),
2882        }
2883        assert_eq!(transport.sync_calls, 0);
2884        assert_eq!(transport.realtime_sync_calls, 0);
2885    }
2886
2887    #[test]
2888    fn an_unsolicited_socket_end_is_lost_with_the_transport_code_and_a_retry_intent() {
2889        let mut client = client();
2890        let mut transport = CountingRealtimeTransport::default();
2891        let ended = TransportError::new("transport.failed", "realtime connection closed");
2892        client.realtime_lost(&mut transport, &ended);
2893        assert_eq!(
2894            client.realtime_state(),
2895            RealtimeState::Disconnected,
2896            "a socket that was never connected has nothing to lose"
2897        );
2898        assert!(client.drain_sync_intents().is_empty());
2899
2900        client.connect_realtime(&mut transport).expect("connect");
2901        client.realtime_lost(&mut transport, &ended);
2902        assert_eq!(client.realtime_state(), RealtimeState::Lost);
2903        assert_eq!(transport.closes, 1);
2904        assert!(matches!(
2905            client.drain_sync_intents().as_slice(),
2906            [SyncIntent::Background { delay_ms: 250 }]
2907        ));
2908        let snapshot = client
2909            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2910            .expect("diagnostics");
2911        assert_eq!(snapshot.host.realtime, RealtimeState::Lost);
2912        assert_eq!(
2913            snapshot.host.realtime_reason_code.as_deref(),
2914            Some("transport.failed")
2915        );
2916        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
2917
2918        client.realtime_lost(&mut transport, &ended);
2919        assert!(
2920            client.drain_sync_intents().is_empty(),
2921            "a second report for the same loss schedules nothing"
2922        );
2923        client.connect_realtime(&mut transport).expect("reconnect");
2924        assert_eq!(client.realtime_state(), RealtimeState::Connected);
2925    }
2926
2927    #[test]
2928    fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
2929        let mut client = client();
2930        client
2931            .set_realtime_policy(RealtimePolicy::Required)
2932            .expect("set policy");
2933        let mut transport = CountingRealtimeTransport::default();
2934        client.connect_realtime(&mut transport).expect("connect");
2935        assert_eq!(client.realtime_state(), RealtimeState::Connected);
2936        assert!(
2937            client.set_realtime_policy(RealtimePolicy::Off).is_err(),
2938            "a policy change cannot silently re-route a connected socket"
2939        );
2940        assert!(matches!(
2941            client.sync(&mut transport),
2942            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
2943        ));
2944        assert_eq!(client.realtime_state(), RealtimeState::Lost);
2945        assert_eq!(transport.realtime_sync_calls, 1);
2946        assert_eq!(transport.sync_calls, 0);
2947        let snapshot = client
2948            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2949            .expect("diagnostics");
2950        assert_eq!(
2951            snapshot.host.realtime_reason_code.as_deref(),
2952            Some("sync.transport_failed")
2953        );
2954        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
2955        match client.sync(&mut transport) {
2956            SyncOutcome::RealtimeUnavailable {
2957                state, reason_code, ..
2958            } => {
2959                assert_eq!(state, RealtimeState::Lost);
2960                assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
2961            }
2962            other => panic!("expected RealtimeUnavailable, got {other:?}"),
2963        }
2964        assert_eq!(transport.sync_calls, 0);
2965        client.disconnect_realtime(&mut transport);
2966        assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
2967        assert_eq!(transport.closes, 1);
2968        client.connect_realtime(&mut transport).expect("reconnect");
2969        assert_eq!(client.realtime_state(), RealtimeState::Connected);
2970        assert_eq!(transport.connects, 2);
2971    }
2972
2973    #[test]
2974    fn off_policy_uses_http_and_refuses_an_explicit_connect() {
2975        let mut client = client();
2976        client
2977            .set_realtime_policy(RealtimePolicy::Off)
2978            .expect("set policy");
2979        let mut transport = CountingRealtimeTransport::default();
2980        assert!(client.connect_realtime(&mut transport).is_err());
2981        assert_eq!(client.realtime_state(), RealtimeState::Disabled);
2982        assert!(matches!(
2983            client.sync(&mut transport),
2984            SyncOutcome::Failed { .. }
2985        ));
2986        assert_eq!(transport.sync_calls, 1);
2987        assert_eq!(transport.realtime_sync_calls, 0);
2988        let snapshot = client
2989            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2990            .expect("diagnostics");
2991        assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
2992        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
2993    }
2994
2995    #[test]
2996    fn optional_policy_keeps_the_http_round() {
2997        let mut client = client();
2998        let mut transport = CountingRealtimeTransport::default();
2999        assert!(matches!(
3000            client.sync(&mut transport),
3001            SyncOutcome::Failed { .. }
3002        ));
3003        assert_eq!(transport.sync_calls, 1);
3004        assert_eq!(transport.realtime_sync_calls, 0);
3005        let snapshot = client
3006            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3007            .expect("diagnostics");
3008        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
3009        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
3010    }
3011
3012    #[test]
3013    fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
3014        let mut client = client();
3015        client
3016            .subscribe(
3017                "repair-tasks".to_owned(),
3018                "tasks".to_owned(),
3019                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3020                None,
3021            )
3022            .expect("subscribe");
3023        {
3024            let sub = client
3025                .subs
3026                .iter_mut()
3027                .find(|sub| sub.id == "repair-tasks")
3028                .expect("subscription");
3029            sub.cursor = 42;
3030            sub.synced_once = true;
3031            let persisted = sub.clone();
3032            client.persist_sub(&persisted).unwrap();
3033        }
3034        for table in ["_syncular_base_tasks", "tasks"] {
3035            client
3036                .conn
3037                .execute(
3038                    &format!(
3039                        "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
3040                    ),
3041                    rusqlite::params!["server-row", "p1"],
3042                )
3043                .expect("seed server row");
3044        }
3045        let pending = client
3046            .mutate(vec![Mutation::Upsert {
3047                table: "tasks".to_owned(),
3048                values: Map::from_iter([
3049                    ("id".to_owned(), Value::from("offline-row")),
3050                    ("project_id".to_owned(), Value::from("p1")),
3051                ]),
3052                base_version: None,
3053            }])
3054            .expect("queue offline work");
3055        client.drain_change_batches();
3056        client.drain_sync_intents();
3057
3058        assert_eq!(
3059            client
3060                .rebootstrap_local_data(&LocalDataRebootstrapInput {
3061                    rebootstrap_id: "support-case-001".to_owned(),
3062                })
3063                .expect("rebootstrap"),
3064            LocalDataRebootstrapResult {
3065                already_applied: false,
3066                retained_commits: 1,
3067                reset_subscriptions: 1,
3068            }
3069        );
3070        let visible_ids = client
3071            .conn
3072            .prepare("SELECT id FROM tasks ORDER BY id")
3073            .expect("prepare visible ids")
3074            .query_map([], |row| row.get::<_, String>(0))
3075            .expect("query visible ids")
3076            .collect::<Result<Vec<_>, _>>()
3077            .expect("collect visible ids");
3078        assert_eq!(visible_ids, vec!["offline-row"]);
3079        assert_eq!(client.pending_commit_ids(), vec![pending]);
3080        assert_eq!(
3081            client
3082                .subscription_state("repair-tasks")
3083                .expect("subscription")
3084                .cursor,
3085            -1
3086        );
3087        assert!(client.upgrading());
3088        assert!(client.sync_needed());
3089        assert!(matches!(
3090            client.drain_sync_intents().as_slice(),
3091            [SyncIntent::Interactive]
3092        ));
3093        assert_eq!(client.drain_change_batches().len(), 1);
3094
3095        assert_eq!(
3096            client
3097                .rebootstrap_local_data(&LocalDataRebootstrapInput {
3098                    rebootstrap_id: "support-case-001".to_owned(),
3099                })
3100                .expect("idempotent retry"),
3101            LocalDataRebootstrapResult {
3102                already_applied: true,
3103                retained_commits: 1,
3104                reset_subscriptions: 1,
3105            }
3106        );
3107        assert!(client.drain_change_batches().is_empty());
3108    }
3109
3110    #[test]
3111    fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
3112        let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
3113        assert_eq!(
3114            decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
3115            (3, 4)
3116        );
3117        assert_eq!(
3118            decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
3119            (0, 0)
3120        );
3121        for malformed in [
3122            "",
3123            "{}",
3124            r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
3125            r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
3126            r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
3127        ] {
3128            assert_eq!(
3129                decode_local_rebootstrap_receipt(malformed)
3130                    .expect_err("malformed receipt must fail"),
3131                "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
3132            );
3133        }
3134    }
3135
3136    #[test]
3137    fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
3138        let path = std::env::temp_dir().join(format!(
3139            "syncular-rebootstrap-receipt-{}.db",
3140            uuid::Uuid::new_v4()
3141        ));
3142        let schema = json!({
3143            "version": 1,
3144            "tables": [{
3145                "name": "tasks",
3146                "primaryKey": "id",
3147                "columns": [
3148                    { "name": "id", "type": "string", "nullable": false },
3149                    { "name": "project_id", "type": "string", "nullable": false }
3150                ],
3151                "scopes": [{ "pattern": "project:{project_id}" }]
3152            }]
3153        });
3154        let path_string = path.to_str().expect("UTF-8 temp path");
3155
3156        {
3157            let mut first = SyncClient::open_path(
3158                "repair-restart-client".to_owned(),
3159                &schema,
3160                ClientLimits::default(),
3161                path_string,
3162            )
3163            .expect("first open");
3164            first
3165                .subscribe(
3166                    "repair-tasks".to_owned(),
3167                    "tasks".to_owned(),
3168                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3169                    None,
3170                )
3171                .expect("subscribe");
3172            first
3173                .mutate(vec![Mutation::Upsert {
3174                    table: "tasks".to_owned(),
3175                    values: Map::from_iter([
3176                        ("id".to_owned(), Value::from("offline-row")),
3177                        ("project_id".to_owned(), Value::from("p1")),
3178                    ]),
3179                    base_version: None,
3180                }])
3181                .expect("queue offline work");
3182            assert_eq!(
3183                first
3184                    .rebootstrap_local_data(&LocalDataRebootstrapInput {
3185                        rebootstrap_id: "restart-receipt".to_owned(),
3186                    })
3187                    .expect("first rebootstrap"),
3188                LocalDataRebootstrapResult {
3189                    already_applied: false,
3190                    retained_commits: 1,
3191                    reset_subscriptions: 1,
3192                }
3193            );
3194        }
3195
3196        let mut reopened = SyncClient::open_path(
3197            "repair-restart-client".to_owned(),
3198            &schema,
3199            ClientLimits::default(),
3200            path_string,
3201        )
3202        .expect("reopen");
3203        reopened.drain_change_batches();
3204        reopened.drain_sync_intents();
3205        assert_eq!(
3206            reopened
3207                .rebootstrap_local_data(&LocalDataRebootstrapInput {
3208                    rebootstrap_id: "restart-receipt".to_owned(),
3209                })
3210                .expect("receipt replay"),
3211            LocalDataRebootstrapResult {
3212                already_applied: true,
3213                retained_commits: 1,
3214                reset_subscriptions: 1,
3215            }
3216        );
3217        assert!(reopened.drain_change_batches().is_empty());
3218        assert!(reopened.drain_sync_intents().is_empty());
3219        drop(reopened);
3220        std::fs::remove_file(path).expect("remove temp database");
3221    }
3222
3223    #[test]
3224    fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
3225        let mut malformed = client();
3226        malformed
3227            .subscribe(
3228                "repair-tasks".to_owned(),
3229                "tasks".to_owned(),
3230                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3231                None,
3232            )
3233            .expect("subscribe");
3234        malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
3235        malformed.drain_change_batches();
3236        malformed.drain_sync_intents();
3237        let malformed_error = malformed
3238            .rebootstrap_local_data(&LocalDataRebootstrapInput {
3239                rebootstrap_id: "malformed".to_owned(),
3240            })
3241            .expect_err("malformed receipt must fail closed");
3242        assert_eq!(
3243            malformed_error,
3244            "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
3245        );
3246        assert!(!malformed.upgrading());
3247        assert_eq!(
3248            malformed
3249                .subscription_state("repair-tasks")
3250                .expect("unchanged subscription")
3251                .cursor,
3252            -1
3253        );
3254        assert!(malformed.drain_change_batches().is_empty());
3255        assert!(malformed.drain_sync_intents().is_empty());
3256
3257        let mut unreadable = client();
3258        unreadable
3259            .conn
3260            .execute(
3261                "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
3262                rusqlite::params!["server-row", "p1"],
3263            )
3264            .expect("seed visible row");
3265        unreadable
3266            .conn
3267            .execute("DROP TABLE _syncular_meta", [])
3268            .expect("break marker storage");
3269        let unreadable_error = unreadable
3270            .rebootstrap_local_data(&LocalDataRebootstrapInput {
3271                rebootstrap_id: "unreadable".to_owned(),
3272            })
3273            .expect_err("unreadable marker storage must fail closed");
3274        assert_eq!(
3275            unreadable_error,
3276            "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
3277        );
3278        let visible_rows = unreadable
3279            .conn
3280            .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
3281            .expect("visible projection remains");
3282        assert_eq!(visible_rows, 1);
3283        assert!(!unreadable.upgrading());
3284        assert!(unreadable.drain_change_batches().is_empty());
3285        assert!(unreadable.drain_sync_intents().is_empty());
3286    }
3287
3288    #[test]
3289    fn local_rebootstrap_cannot_bypass_schema_floor() {
3290        let mut client = client();
3291        client.set_schema_floor(Some(SchemaFloor {
3292            required_schema_version: Some(2),
3293            latest_schema_version: Some(2),
3294        }));
3295        let error = client
3296            .rebootstrap_local_data(&LocalDataRebootstrapInput {
3297                rebootstrap_id: "blocked-floor".to_owned(),
3298            })
3299            .expect_err("schema floor must block repair");
3300        assert!(error.contains("cannot bypass an active schema-floor stop"));
3301    }
3302
3303    #[test]
3304    fn diego_applied_sparse_moves_stay_visible_until_pull_image_arrives() {
3305        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3306            "columns":[{"name":"id","type":"string","nullable":false},
3307                {"name":"project_id","type":"string","nullable":false},
3308                {"name":"starts_at_ms","type":"integer","nullable":false},
3309                {"name":"version","type":"integer","nullable":false}],
3310            "scopes":[{"pattern":"project:{project_id}"}]}]});
3311        let mut client =
3312            SyncClient::new("diego-ack-gap".into(), &schema, ClientLimits::default()).unwrap();
3313        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3314        client
3315            .write_base_row(
3316                "tasks",
3317                &vec![
3318                    Some(ColumnValue::String("t1".into())),
3319                    Some(ColumnValue::String("p1".into())),
3320                    Some(ColumnValue::Integer(100)),
3321                    Some(ColumnValue::Integer(1)),
3322                ],
3323                1,
3324            )
3325            .unwrap();
3326        client.rebuild_overlay().expect("rebuild overlay");
3327        let first = client
3328            .patch(
3329                "tasks",
3330                "t1",
3331                Map::from_iter([
3332                    ("starts_at_ms".into(), json!(200)),
3333                    ("version".into(), json!(2)),
3334                ]),
3335                None,
3336            )
3337            .unwrap();
3338        let (_, first_request) = client.build_request(false).unwrap();
3339        // This second edit happens while the first request is in flight.
3340        let second = client
3341            .patch(
3342                "tasks",
3343                "t1",
3344                Map::from_iter([
3345                    ("starts_at_ms".into(), json!(300)),
3346                    ("version".into(), json!(3)),
3347                ]),
3348                None,
3349            )
3350            .unwrap();
3351        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3352        for (id, seq, meta) in [
3353            (first, 2, first_request),
3354            (second, 3, client.build_request(false).unwrap().1),
3355        ] {
3356            let response = Message {
3357                wire_version: WIRE_VERSION,
3358                msg_kind: MsgKind::Response,
3359                frames: vec![
3360                    Frame::RespHeader {
3361                        required_schema_version: None,
3362                        latest_schema_version: None,
3363                        log_epoch: Some("epoch-1".into()),
3364                        reset_required: Some(false),
3365                    },
3366                    Frame::PushResult {
3367                        client_commit_id: id,
3368                        status: PushStatus::Applied,
3369                        commit_seq: Some(seq),
3370                        results: vec![OpResult::Applied { op_index: 0 }],
3371                    },
3372                ],
3373            };
3374            assert!(matches!(
3375                client.process_response(&mut transport, response, &meta),
3376                SyncOutcome::Ok(_)
3377            ));
3378            let row = &client
3379                .query("SELECT starts_at_ms, version FROM tasks WHERE id='t1'", &[])
3380                .unwrap()[0];
3381            assert_eq!(
3382                row["starts_at_ms"],
3383                json!(300),
3384                "an applied ACK without a pull image must not expose the old base"
3385            );
3386            assert_eq!(
3387                row["version"],
3388                json!(3),
3389                "the queued domain version must remain monotonic"
3390            );
3391        }
3392    }
3393
3394    #[test]
3395    fn first_epoch_acquisition_preserves_tables_subscriptions_and_local_intent() {
3396        for stored in [None, Some("epoch-old")] {
3397            let mut client = client();
3398            client
3399                .subscribe(
3400                    "tasks".into(),
3401                    "tasks".into(),
3402                    vec![("project_id".into(), vec!["p1".into()])],
3403                    None,
3404                )
3405                .unwrap();
3406            client
3407                .conn
3408                .execute_batch(
3409                    "CREATE TRIGGER epoch_table_identity AFTER INSERT ON tasks BEGIN SELECT 1; END",
3410                )
3411                .unwrap();
3412            let id = client
3413                .mutate(vec![Mutation::Upsert {
3414                    table: "tasks".into(),
3415                    values: Map::from_iter([
3416                        ("id".into(), json!("offline")),
3417                        ("project_id".into(), json!("p1")),
3418                    ]),
3419                    base_version: None,
3420                }])
3421                .unwrap();
3422            if let Some(epoch) = stored {
3423                client.set_meta(LOG_EPOCH_KEY, epoch);
3424            }
3425            let before = serde_json::to_value(client.subscription_state("tasks")).unwrap();
3426            let (_, meta) = client.build_request(false).unwrap();
3427            client.drain_change_batches();
3428            client.drain_sync_intents();
3429            let response = Message {
3430                wire_version: WIRE_VERSION,
3431                msg_kind: MsgKind::Response,
3432                frames: vec![Frame::RespHeader {
3433                    required_schema_version: None,
3434                    latest_schema_version: None,
3435                    log_epoch: Some("epoch-new".into()),
3436                    reset_required: Some(true),
3437                }],
3438            };
3439            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3440            let outcome = client.process_response(&mut transport, response, &meta);
3441            let SyncOutcome::Ok(report) = outcome else {
3442                panic!("epoch round failed: {outcome:?}");
3443            };
3444            assert_eq!(
3445                report.resets,
3446                if stored.is_none() {
3447                    Vec::<String>::new()
3448                } else {
3449                    vec!["tasks".into()]
3450                }
3451            );
3452            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3453            assert_eq!(client.pending_commit_ids(), vec![id]);
3454            assert_eq!(
3455                client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3456                json!("offline")
3457            );
3458            let trigger: i64 = client
3459                .conn
3460                .query_row(
3461                    "SELECT count(*) FROM sqlite_master WHERE name='epoch_table_identity'",
3462                    [],
3463                    |row| row.get(0),
3464                )
3465                .unwrap();
3466            if stored.is_none() {
3467                assert!(!client.upgrading);
3468                assert!(client
3469                    .drain_change_batches()
3470                    .iter()
3471                    .all(|batch| batch.status.as_ref().is_none_or(|status| !status.upgrading)));
3472                assert_eq!(
3473                    serde_json::to_value(client.subscription_state("tasks")).unwrap(),
3474                    before
3475                );
3476                assert_eq!(trigger, 1, "acquisition must not drop the synced table");
3477            } else {
3478                assert!(client.upgrading);
3479                assert_eq!(trigger, 0, "an epoch change must drop the old table");
3480            }
3481            assert!(matches!(
3482                client.drain_sync_intents().as_slice(),
3483                [SyncIntent::Interactive]
3484            ));
3485        }
3486    }
3487
3488    #[test]
3489    fn epoch_acquisition_storage_failures_preserve_ready_state_and_intent() {
3490        for key in ["logEpoch", "localRevision"] {
3491            let mut client = client();
3492            let id = client
3493                .mutate(vec![Mutation::Upsert {
3494                    table: "tasks".into(),
3495                    values: Map::from_iter([
3496                        ("id".into(), json!("offline")),
3497                        ("project_id".into(), json!("p1")),
3498                    ]),
3499                    base_version: None,
3500                }])
3501                .unwrap();
3502            let revision = client.local_revision();
3503            let needed = client.sync_needed;
3504            client.drain_change_batches();
3505            client.drain_sync_intents();
3506            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();
3507            assert!(client.run_log_epoch_reset("epoch-new").is_err());
3508            assert!(client.get_meta(LOG_EPOCH_KEY).is_none());
3509            assert!(!client.upgrading);
3510            assert_eq!(client.sync_needed, needed);
3511            assert_eq!(client.local_revision(), revision);
3512            assert_eq!(client.pending_commit_ids(), [id]);
3513            assert!(client.drain_change_batches().is_empty());
3514            assert!(client.drain_sync_intents().is_empty());
3515        }
3516    }
3517
3518    #[test]
3519    fn failed_log_epoch_resets_restore_memory_and_durable_state() {
3520        for key in [
3521            "localSchemaVersion",
3522            "localSchemaDescriptor",
3523            "logEpoch",
3524            "localRevision",
3525        ] {
3526            let mut client = client();
3527            client.set_meta(LOG_EPOCH_KEY, "epoch-old");
3528            let commit = client
3529                .mutate(vec![Mutation::Upsert {
3530                    table: "tasks".into(),
3531                    values: Map::from_iter([
3532                        ("id".into(), json!("offline")),
3533                        ("project_id".into(), json!("p1")),
3534                    ]),
3535                    base_version: None,
3536                }])
3537                .unwrap();
3538            client.active_round = Some(uuid::Uuid::new_v4());
3539            client.overlay_dirty.set(false);
3540            let round = client.active_round;
3541            let needed = client.sync_needed;
3542            let revision = client.local_revision();
3543            let rows = serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap();
3544            client.drain_change_batches();
3545            client.drain_sync_intents();
3546            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();
3547            assert!(client.run_log_epoch_reset("epoch-new").is_err(), "{key}");
3548            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-old"));
3549            assert!(!client.upgrading);
3550            assert_eq!(client.active_round, round);
3551            assert!(!client.overlay_dirty.get());
3552            assert_eq!(client.sync_needed, needed);
3553            assert_eq!(client.local_revision(), revision);
3554            assert_eq!(
3555                client.pending_commit_ids().as_slice(),
3556                std::slice::from_ref(&commit)
3557            );
3558            assert_eq!(
3559                serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap(),
3560                rows
3561            );
3562            assert!(client.drain_change_batches().is_empty());
3563            assert!(client.drain_sync_intents().is_empty());
3564            assert!(client.conn.is_autocommit());
3565            client
3566                .conn
3567                .execute_batch("DROP TRIGGER fail_reset")
3568                .unwrap();
3569            client.run_log_epoch_reset("epoch-new").unwrap();
3570            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3571            assert_eq!(client.pending_commit_ids(), [commit]);
3572        }
3573    }
3574
3575    #[test]
3576    fn acknowledged_restore_and_replay_ignore_unrelated_subscription_count() {
3577        for subscriptions in [0, 2, 6, 12] {
3578            let mut client = SyncClient::new("ack-scope".into(), &json!({ "version": 1, "tables": [
3579                { "name": "tasks", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] },
3580                { "name": "docs", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] }
3581            ] }), ClientLimits::default()).unwrap();
3582            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3583            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();
3584            for index in 0..32 {
3585                client
3586                    .mutate(vec![Mutation::Upsert {
3587                        table: "tasks".into(),
3588                        values: Map::from_iter([
3589                            ("id".into(), json!(format!("task-{index}"))),
3590                            ("project_id".into(), json!("p1")),
3591                        ]),
3592                        base_version: None,
3593                    }])
3594                    .unwrap();
3595            }
3596            let (_, meta) = client.build_request(false).unwrap();
3597            let mut frames = vec![Frame::RespHeader {
3598                required_schema_version: None,
3599                latest_schema_version: None,
3600                log_epoch: Some("epoch-1".into()),
3601                reset_required: Some(false),
3602            }];
3603            frames.extend(meta.pushed_ids.iter().enumerate().map(|(index, id)| {
3604                Frame::PushResult {
3605                    client_commit_id: id.clone(),
3606                    status: PushStatus::Applied,
3607                    commit_seq: Some(index as i64 + 1),
3608                    results: vec![OpResult::Applied { op_index: 0 }],
3609                }
3610            }));
3611            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3612            client.acknowledged_replay_count.set(0);
3613            client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3614            assert!(matches!(
3615                client.process_response(
3616                    &mut transport,
3617                    Message {
3618                        wire_version: WIRE_VERSION,
3619                        msg_kind: MsgKind::Response,
3620                        frames
3621                    },
3622                    &meta
3623                ),
3624                SyncOutcome::Ok(_)
3625            ));
3626            assert_eq!(client.acknowledged_replay_count.get(), 32);
3627            assert_eq!(
3628                client
3629                    .query(
3630                        "SELECT count(*) AS n FROM ack_writes WHERE kind='restore'",
3631                        &[]
3632                    )
3633                    .unwrap()[0]["n"],
3634                json!(32)
3635            );
3636            assert_eq!(
3637                client
3638                    .query(
3639                        "SELECT count(*) AS n FROM ack_writes WHERE kind='replay'",
3640                        &[]
3641                    )
3642                    .unwrap()[0]["n"],
3643                json!(32)
3644            );
3645            client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3646            client.acknowledged_replay_count.set(0);
3647            for index in 0..subscriptions {
3648                client
3649                    .subscribe(
3650                        format!("docs-{index}"),
3651                        "docs".into(),
3652                        vec![("project_id".into(), vec![format!("scope-{index}")])],
3653                        None,
3654                    )
3655                    .unwrap();
3656                let sub = client.subs.len() - 1;
3657                client.subs[sub].effective =
3658                    Some(vec![("project_id".into(), vec![format!("scope-{index}")])]);
3659                let segment = RowsSegment {
3660                    table: "docs".into(),
3661                    schema_version: 1,
3662                    columns: client.schema.table("docs").unwrap().wire_columns.clone(),
3663                    blocks: if index % 2 == 0 {
3664                        vec![vec![ssp2::segment::SegmentRow {
3665                            values: vec![
3666                                Some(ColumnValue::String(format!("doc-{index}"))),
3667                                Some(ColumnValue::String(format!("scope-{index}"))),
3668                            ],
3669                            server_version: 1,
3670                        }]]
3671                    } else {
3672                        vec![]
3673                    },
3674                };
3675                assert!(client.apply_segment(sub, &segment, true).is_ok());
3676            }
3677            assert_eq!(
3678                client
3679                    .query("SELECT count(*) AS n FROM ack_writes", &[])
3680                    .unwrap()[0]["n"],
3681                json!(0),
3682                "unrelated bootstrap must not restore/replay tasks"
3683            );
3684            assert_eq!(
3685                client.acknowledged_replay_count.get(),
3686                0,
3687                "unrelated bootstrap must not decode task ACKs"
3688            );
3689            assert_eq!(
3690                client
3691                    .query("SELECT count(*) AS n FROM tasks", &[])
3692                    .unwrap()[0]["n"],
3693                json!(32)
3694            );
3695            assert_eq!(
3696                client
3697                    .query("SELECT count(*) AS n FROM _syncular_acked_rows", &[])
3698                    .unwrap()[0]["n"],
3699                json!(32)
3700            );
3701        }
3702    }
3703
3704    #[test]
3705    fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
3706        let mut client = client();
3707        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3708        const COMMIT_COUNT: usize = 32;
3709
3710        for index in 0..COMMIT_COUNT {
3711            client
3712                .mutate(vec![Mutation::Upsert {
3713                    table: "tasks".to_owned(),
3714                    values: Map::from_iter([
3715                        ("id".to_owned(), Value::from(format!("task-{index}"))),
3716                        ("project_id".to_owned(), Value::from("project-1")),
3717                    ]),
3718                    base_version: None,
3719                }])
3720                .expect("queue commit");
3721        }
3722
3723        let (_, request_meta) = client.build_request(false).unwrap();
3724        assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
3725        let mut frames = vec![Frame::RespHeader {
3726            required_schema_version: None,
3727            latest_schema_version: None,
3728            log_epoch: Some("epoch-1".to_owned()),
3729            reset_required: Some(false),
3730        }];
3731        frames.extend(request_meta.pushed_ids.iter().enumerate().map(
3732            |(index, client_commit_id)| Frame::PushResult {
3733                client_commit_id: client_commit_id.clone(),
3734                status: PushStatus::Applied,
3735                commit_seq: Some(index as i64 + 1),
3736                results: vec![OpResult::Applied { op_index: 0 }],
3737            },
3738        ));
3739        let response = Message {
3740            wire_version: WIRE_VERSION,
3741            msg_kind: MsgKind::Response,
3742            frames,
3743        };
3744        let mut transport =
3745            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
3746
3747        client.overlay_rebuild_count.set(0);
3748        client.outcome_prune_count.set(0);
3749        let outcome = client.process_response(&mut transport, response, &request_meta);
3750        assert!(matches!(outcome, SyncOutcome::Ok(_)));
3751        assert!(client.pending_commit_ids().is_empty());
3752        assert_eq!(
3753            client.overlay_rebuild_count.get(),
3754            1,
3755            "one response must reconcile its acknowledged commits with one overlay rebuild"
3756        );
3757        assert_eq!(
3758            client.outcome_prune_count.get(),
3759            1,
3760            "one response must enforce outcome retention once"
3761        );
3762    }
3763
3764    #[test]
3765    fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
3766        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
3767            "columns": [
3768                {"name":"id", "type":"string", "nullable":false},
3769                {"name":"project_id", "type":"string", "nullable":false},
3770                {"name":"title", "type":"string", "nullable":false}
3771            ], "scopes": [{"pattern":"project:{project_id}"}],
3772            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
3773            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
3774        }] });
3775        let mut incremental =
3776            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
3777        let mut reference =
3778            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
3779        let tables = vec!["tasks".to_owned()];
3780        for index in 0..32 {
3781            let id = format!("task-{}", index % 8);
3782            let values = Map::from_iter([
3783                ("id".to_owned(), Value::from(id.clone())),
3784                (
3785                    "project_id".to_owned(),
3786                    Value::from(format!("p{}", index % 2)),
3787                ),
3788                ("title".to_owned(), Value::from(format!("needle{index}"))),
3789            ]);
3790            let payload = encode_row_json(
3791                incremental.schema.table("tasks").unwrap(),
3792                &id,
3793                &values,
3794                &incremental.encryption,
3795            )
3796            .unwrap();
3797            let change = ssp2::model::Change {
3798                table_index: 0,
3799                row_id: id,
3800                op: if index % 5 == 0 {
3801                    Op::Delete
3802                } else {
3803                    Op::Upsert
3804                },
3805                row_version: Some(index + 1),
3806                scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
3807                row: Some(payload),
3808            };
3809            reference.overlay_dirty.set(true);
3810            for instance in [&mut incremental, &mut reference] {
3811                instance.begin_observation("mirror_test").unwrap();
3812                let mut batch = ChangeAccumulator::default();
3813                instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
3814                instance
3815                    .apply_commit_changes(&tables, std::slice::from_ref(&change))
3816                    .unwrap();
3817                instance
3818                    .rebuild_overlay_if_dirty()
3819                    .expect("rebuild overlay");
3820                instance.finish_observation("mirror_test", batch).unwrap();
3821            }
3822            for sql in [
3823                "SELECT * FROM tasks ORDER BY id",
3824                "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
3825            ] {
3826                assert_eq!(
3827                    incremental.query(sql, &[]).unwrap(),
3828                    reference.query(sql, &[]).unwrap()
3829                );
3830            }
3831            assert_eq!(incremental.local_revision(), reference.local_revision());
3832            assert_eq!(
3833                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
3834                serde_json::to_value(reference.drain_change_batches()).unwrap()
3835            );
3836        }
3837        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
3838        assert_eq!(reference.overlay_rebuild_count.get(), 32);
3839    }
3840
3841    #[test]
3842    fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
3843        let cases = [
3844            (
3845                "string",
3846                vec![
3847                    json!("1"),
3848                    json!("01"),
3849                    json!(""),
3850                    json!("nul\u{0}id"),
3851                    json!("é"),
3852                ],
3853            ),
3854            (
3855                "integer",
3856                vec![
3857                    json!(i64::MIN),
3858                    json!(-1),
3859                    json!(0),
3860                    json!(1),
3861                    json!(i64::MAX),
3862                ],
3863            ),
3864            ("boolean", vec![json!(false), json!(true)]),
3865            ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
3866        ];
3867        for (kind, ids) in cases {
3868            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3869                "columns":[{"name":"id","type":kind,"nullable":false},
3870                    {"name":"project_id","type":"string","nullable":false}],
3871                "scopes":[{"pattern":"project:{project_id}"}]}]});
3872            let mut client =
3873                SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
3874            for (index, id) in ids.iter().enumerate() {
3875                let table = client.schema.table("tasks").unwrap();
3876                client
3877                    .write_base_row(
3878                        "tasks",
3879                        &vec![
3880                            if kind == "integer" {
3881                                // Exercise local SQLite key lookup across its
3882                                // full i64 range independently of authoring.
3883                                Some(ColumnValue::Integer(id.as_i64().unwrap()))
3884                            } else {
3885                                json_to_column_value(&table.columns[0], Some(id)).unwrap()
3886                            },
3887                            Some(ColumnValue::String(format!("p{index}"))),
3888                        ],
3889                        1,
3890                    )
3891                    .unwrap();
3892            }
3893            client.rebuild_overlay_if_dirty().expect("rebuild overlay");
3894            for base in [true, false] {
3895                let full = if base {
3896                    base_table("tasks")
3897                } else {
3898                    visible_table("tasks")
3899                };
3900                let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
3901                for row_id in [
3902                    "1",
3903                    "01",
3904                    "1.0",
3905                    "1.0000000000000002",
3906                    "+1",
3907                    "1e0",
3908                    " 1",
3909                    "-1",
3910                    "0",
3911                    "9223372036854775807",
3912                    "-9223372036854775808",
3913                    "true",
3914                    "false",
3915                    "",
3916                    "nul\u{0}id",
3917                    "é",
3918                    "null",
3919                    "{\"a\":1}",
3920                ] {
3921                    let old_sql =
3922                        format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
3923                    let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
3924                    let old = client
3925                        .conn
3926                        .prepare(&old_sql)
3927                        .unwrap()
3928                        .query_map([row_id], |row| row.get::<_, String>(0))
3929                        .unwrap()
3930                        .collect::<Result<Vec<_>, _>>()
3931                        .unwrap();
3932                    let new = client
3933                        .conn
3934                        .prepare_cached(&new_sql)
3935                        .unwrap()
3936                        .query_map([row_id], |row| row.get::<_, String>(0))
3937                        .unwrap()
3938                        .collect::<Result<Vec<_>, _>>()
3939                        .unwrap();
3940                    assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
3941                    let mut batch = ChangeAccumulator::default();
3942                    assert_eq!(
3943                        client.record_row_scopes(&mut batch, "tasks", row_id, base),
3944                        !old.is_empty()
3945                    );
3946                    let expected = old.first().map(|scope| {
3947                        BTreeMap::from([(
3948                            "tasks".to_owned(),
3949                            Some(BTreeSet::from([format!("project:{scope}")])),
3950                        )])
3951                    });
3952                    assert_eq!(batch.tables, expected.unwrap_or_default());
3953                    client
3954                        .conn
3955                        .execute_batch("SAVEPOINT lookup_delete")
3956                        .unwrap();
3957                    if base {
3958                        client.delete_base_row("tasks", row_id).unwrap();
3959                    } else {
3960                        client
3961                            .apply_outbox_ops(&[OutboxOp {
3962                                upsert: false,
3963                                table: "tasks".into(),
3964                                row_id: row_id.into(),
3965                                base_version: None,
3966                                values: None,
3967                            }])
3968                            .expect("replay delete");
3969                    }
3970                    let remaining: i64 = client
3971                        .conn
3972                        .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
3973                            row.get(0)
3974                        })
3975                        .unwrap();
3976                    assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
3977                    client
3978                        .conn
3979                        .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
3980                        .unwrap();
3981                    client.overlay_dirty.set(false);
3982                }
3983                for (predicate, expected) in [
3984                    ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
3985                    (predicate, "SEARCH"),
3986                ] {
3987                    let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
3988                    let details = client
3989                        .conn
3990                        .prepare(&sql)
3991                        .unwrap()
3992                        .query_map(["1"], |row| row.get::<_, String>(3))
3993                        .unwrap()
3994                        .collect::<Result<Vec<_>, _>>()
3995                        .unwrap();
3996                    assert!(
3997                        details.iter().any(|detail| detail.contains(expected)),
3998                        "{kind}: {details:?}"
3999                    );
4000                }
4001            }
4002        }
4003    }
4004
4005    #[test]
4006    fn primary_keys_reject_every_ineligible_column_type() {
4007        // §2.4: a `rowId` is a string, so a primary key type whose string form
4008        // differs per renderer, or cannot be reproduced by local storage
4009        // comparison, is a schema error rather than a silent lookup miss.
4010        for ty in ["float", "bytes", "crdt", "blob_ref"] {
4011            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4012                "columns":[{"name":"id","type":ty,"nullable":false},
4013                    {"name":"project_id","type":"string","nullable":false}],
4014                "scopes":[{"pattern":"project:{project_id}"}]}]});
4015            let error =
4016                match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
4017                    Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
4018                    Err(error) => error,
4019                };
4020            assert!(
4021                error.contains("has an ineligible column type (§2.4)"),
4022                "{ty}: {error}"
4023            );
4024        }
4025        for ty in ["string", "integer", "boolean", "json"] {
4026            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4027                "columns":[{"name":"id","type":ty,"nullable":false},
4028                    {"name":"project_id","type":"string","nullable":false}],
4029                "scopes":[{"pattern":"project:{project_id}"}]}]});
4030            SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
4031                .unwrap_or_else(|error| panic!("{ty}: {error}"));
4032        }
4033    }
4034
4035    #[test]
4036    fn pending_row_reconciliation_matches_full_fifo_replay() {
4037        for unique in [false, true] {
4038            let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
4039                "name": name, "primaryKey": "id", "columns": [
4040                    {"name":"id", "type":"string", "nullable":false},
4041                    {"name":"project_id", "type":"string", "nullable":false},
4042                    {"name":"title", "type":"string", "nullable":false}],
4043                "scopes": [{"pattern":"project:{project_id}"}],
4044                "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
4045                "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
4046            })))});
4047            let mut incremental =
4048                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
4049            let mut reference =
4050                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
4051            let tables = vec!["tasks".to_owned(), "notes".to_owned()];
4052            for instance in [&mut incremental, &mut reference] {
4053                for table in &tables {
4054                    for index in 0..12 {
4055                        instance
4056                            .write_base_row(
4057                                table,
4058                                &vec![
4059                                    Some(ColumnValue::String(format!("row{index}"))),
4060                                    Some(ColumnValue::String("p0".into())),
4061                                    Some(ColumnValue::String(format!("base{index}"))),
4062                                ],
4063                                1,
4064                            )
4065                            .unwrap();
4066                    }
4067                }
4068                instance
4069                    .rebuild_overlay_if_dirty()
4070                    .expect("rebuild overlay");
4071            }
4072            for index in 0..80 {
4073                let mutations = (0..3)
4074                    .map(|offset| {
4075                        let table = tables[(index + offset) % 2].clone();
4076                        let id = format!("row{}", (index + offset) % 16);
4077                        if (index + offset) % 7 == 0 {
4078                            Mutation::Delete {
4079                                table,
4080                                row_id: id,
4081                                base_version: None,
4082                            }
4083                        } else {
4084                            Mutation::Upsert {
4085                                table,
4086                                values: Map::from_iter([
4087                                    ("id".into(), Value::from(id)),
4088                                    ("project_id".into(), Value::from(format!("p{}", index % 3))),
4089                                    (
4090                                        "title".into(),
4091                                        Value::from(format!("needle{}", (index + offset) % 16)),
4092                                    ),
4093                                ]),
4094                                base_version: None,
4095                            }
4096                        }
4097                    })
4098                    .collect::<Vec<_>>();
4099                for instance in [&mut incremental, &mut reference] {
4100                    instance.mutate(mutations.clone()).unwrap();
4101                }
4102                // Most frames touch only the table without unique constraints.
4103                // Mixed frames must retain full replay for both tables.
4104                let changes = (0..3)
4105                    .map(|offset| {
4106                        let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
4107                        let id = format!("row{}", (index + offset) % 16);
4108                        let values = Map::from_iter([
4109                            ("id".into(), Value::from(id.clone())),
4110                            (
4111                                "project_id".into(),
4112                                Value::from(format!("p{}", (index + 1) % 3)),
4113                            ),
4114                            (
4115                                "title".into(),
4116                                Value::from(format!("remote{index}_{offset}")),
4117                            ),
4118                        ]);
4119                        ssp2::model::Change {
4120                            table_index: table_index as u16,
4121                            row_id: id.clone(),
4122                            op: if (index + offset) % 5 == 0 {
4123                                Op::Delete
4124                            } else {
4125                                Op::Upsert
4126                            },
4127                            row_version: Some(index as i64 + 2),
4128                            scopes: vec![],
4129                            row: Some(
4130                                encode_row_json(
4131                                    incremental.schema.table(&tables[table_index]).unwrap(),
4132                                    &id,
4133                                    &values,
4134                                    &incremental.encryption,
4135                                )
4136                                .unwrap(),
4137                            ),
4138                        }
4139                    })
4140                    .collect::<Vec<_>>();
4141                reference.overlay_dirty.set(true);
4142                incremental
4143                    .apply_commit_frame(&tables, &changes, None)
4144                    .unwrap();
4145                reference
4146                    .apply_commit_frame(&tables, &changes, None)
4147                    .unwrap();
4148                for table in &tables {
4149                    for sql in [format!("SELECT * FROM {table} ORDER BY id"),
4150                        format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
4151                        format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
4152                        format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
4153                        assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
4154                    }
4155                }
4156                assert_eq!(incremental.local_revision(), reference.local_revision());
4157                assert_eq!(
4158                    serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4159                    serde_json::to_value(reference.drain_change_batches()).unwrap()
4160                );
4161            }
4162            assert_eq!(
4163                incremental.overlay_rebuild_count.get(),
4164                if unique { 81 } else { 21 }
4165            );
4166            assert_eq!(reference.overlay_rebuild_count.get(), 81);
4167        }
4168    }
4169
4170    #[test]
4171    fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
4172        let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
4173            "columns":[{"name":"id", "type":"string", "nullable":false},
4174                {"name":"title", "type":"string", "nullable":false}],
4175            "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
4176        let mut client =
4177            SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
4178        for (id, title) in [("a", "original"), ("b", "occupied")] {
4179            client
4180                .write_base_row(
4181                    "tasks",
4182                    &vec![
4183                        Some(ColumnValue::String(id.into())),
4184                        Some(ColumnValue::String(title.into())),
4185                    ],
4186                    1,
4187                )
4188                .unwrap();
4189        }
4190        client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4191        let pending = client
4192            .mutate(vec![Mutation::Upsert {
4193                table: "tasks".into(),
4194                values: Map::from_iter([
4195                    ("id".into(), Value::from("a")),
4196                    ("title".into(), Value::from("available")),
4197                ]),
4198                base_version: None,
4199            }])
4200            .unwrap();
4201        assert_eq!(
4202            client
4203                .conn
4204                .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
4205                    .get::<_, String>(
4206                    0
4207                ))
4208                .unwrap(),
4209            "available"
4210        );
4211        for (title, expected) in [("available", "original"), ("occupied", "available")] {
4212            let change = ssp2::model::Change {
4213                table_index: 0,
4214                row_id: "b".into(),
4215                op: Op::Upsert,
4216                row_version: Some(2),
4217                scopes: vec![],
4218                row: Some(
4219                    encode_row_json(
4220                        client.schema.table("tasks").unwrap(),
4221                        "b",
4222                        &Map::from_iter([
4223                            ("id".into(), Value::from("b")),
4224                            ("title".into(), Value::from(title)),
4225                        ]),
4226                        &client.encryption,
4227                    )
4228                    .unwrap(),
4229                ),
4230            };
4231            client
4232                .apply_commit_frame(&["tasks".into()], &[change], None)
4233                .unwrap();
4234            assert_eq!(
4235                client
4236                    .conn
4237                    .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
4238                        .get::<_, String>(
4239                        0
4240                    ))
4241                    .unwrap(),
4242                expected
4243            );
4244            assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
4245        }
4246        assert_eq!(client.overlay_rebuild_count.get(), 3);
4247    }
4248
4249    fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
4250        let schema = json!({ "version": 1, "tables": [
4251            { "name":"tasks", "primaryKey":"id", "columns":[
4252                {"name":"id","type":"string","nullable":false},
4253                {"name":"project_id","type":"string","nullable":false}],
4254                "scopes":[{"pattern":"project:{project_id}"}] },
4255            { "name":"catalogue", "primaryKey":"id", "columns":[
4256                {"name":"id","type":"string","nullable":false},
4257                {"name":"project_id","type":"string","nullable":false},
4258                {"name":"title","type":"string","nullable":false}],
4259                "scopes":[{"pattern":"project:{project_id}"}],
4260                "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
4261        ] });
4262        let mut client =
4263            SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
4264        if unchanged_rows > 0 {
4265            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();
4266        }
4267        client.rebuild_overlay().expect("rebuild overlay");
4268        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();
4269        let mut elapsed = Vec::new();
4270        for round in 1..=rounds {
4271            let pending = client
4272                .mutate(vec![Mutation::Upsert {
4273                    table: "tasks".into(),
4274                    values: Map::from_iter([
4275                        ("id".into(), json!("t1")),
4276                        ("project_id".into(), json!("p1")),
4277                    ]),
4278                    base_version: None,
4279                }])
4280                .unwrap();
4281            let started = std::time::Instant::now();
4282            client
4283                .write_base_row(
4284                    "tasks",
4285                    &vec![
4286                        Some(ColumnValue::String("t1".into())),
4287                        Some(ColumnValue::String("p1".into())),
4288                    ],
4289                    round,
4290                )
4291                .unwrap();
4292            let mut report = SyncReport::default();
4293            client
4294                .handle_push_results(
4295                    &[Frame::PushResult {
4296                        client_commit_id: pending.clone(),
4297                        status: PushStatus::Applied,
4298                        commit_seq: Some(round),
4299                        results: vec![OpResult::Applied { op_index: 0 }],
4300                    }],
4301                    &HashSet::from([pending.as_str()]),
4302                    &HashMap::new(),
4303                    &mut report,
4304                    Some(&pending),
4305                )
4306                .unwrap();
4307            client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4308            elapsed.push(started.elapsed().as_nanos());
4309        }
4310        assert_eq!(
4311            client
4312                .conn
4313                .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
4314                .unwrap(),
4315            0,
4316            "unchanged catalogue must never be copied"
4317        );
4318        assert_eq!(
4319            client
4320                .conn
4321                .query_row(
4322                    "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
4323                    [],
4324                    |r| r.get::<_, i64>(0)
4325                )
4326                .unwrap(),
4327            unchanged_rows
4328        );
4329        assert_eq!(
4330            client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
4331            json!(rounds)
4332        );
4333        elapsed
4334    }
4335
4336    #[test]
4337    fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
4338        let elapsed = measure_scoped_overlay(100_000, 1);
4339        eprintln!(
4340            "table-scoped overlay with 100k unchanged rows: {} ns",
4341            elapsed[0]
4342        );
4343    }
4344
4345    #[test]
4346    #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
4347    fn benchmark_scoped_overlay() {
4348        for rows in [0, 100_000, 200_000] {
4349            let mut elapsed = measure_scoped_overlay(rows, 30);
4350            elapsed.sort_unstable();
4351            eprintln!(
4352                "{}",
4353                json!({"unchangedRows": rows, "rounds": elapsed.len(),
4354                "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
4355                "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
4356            );
4357        }
4358    }
4359
4360    #[test]
4361    fn released_context_discards_in_flight_ack_and_server_rows() {
4362        for barrier in [
4363            "preflight",
4364            "purge",
4365            "rebootstrap",
4366            "subscription",
4367            "failed-purge-replay",
4368            "failed-purge-publish",
4369            "failed-rebootstrap",
4370        ] {
4371            let mut client = client();
4372            client.set_meta(LOG_EPOCH_KEY, "epoch");
4373            client
4374                .subscribe(
4375                    "tasks".into(),
4376                    "tasks".into(),
4377                    vec![("project_id".into(), vec!["p1".into()])],
4378                    None,
4379                )
4380                .unwrap();
4381            let commit = client
4382                .mutate(vec![Mutation::Upsert {
4383                    table: "tasks".into(),
4384                    values: Map::from_iter([
4385                        ("id".into(), json!("local")),
4386                        ("project_id".into(), json!("p1")),
4387                    ]),
4388                    base_version: None,
4389                }])
4390                .unwrap();
4391            let prepared = client.prepare_sync_round(false).unwrap();
4392            client.set_transport_enabled(&mut CountingRealtimeTransport::default(), false);
4393            let payload = encode_row_json(
4394                &client.schema.tables[0],
4395                "server",
4396                &Map::from_iter([
4397                    ("id".into(), json!("server")),
4398                    ("project_id".into(), json!("p1")),
4399                ]),
4400                &client.encryption,
4401            )
4402            .unwrap();
4403            let completed = crate::CompletedSyncRound {
4404                prepared,
4405                exchange: crate::round::ExchangeResult::Reply {
4406                    transport_failed: false,
4407                    downloads: Default::default(),
4408                    response: Ok(Message {
4409                        wire_version: WIRE_VERSION,
4410                        msg_kind: MsgKind::Response,
4411                        frames: vec![
4412                            Frame::RespHeader {
4413                                required_schema_version: None,
4414                                latest_schema_version: None,
4415                                log_epoch: Some("epoch".into()),
4416                                reset_required: Some(false),
4417                            },
4418                            Frame::PushResult {
4419                                client_commit_id: commit.clone(),
4420                                status: PushStatus::Applied,
4421                                commit_seq: Some(1),
4422                                results: vec![OpResult::Applied { op_index: 0 }],
4423                            },
4424                            Frame::SubStart {
4425                                id: "tasks".into(),
4426                                status: SubStatus::Active,
4427                                reason_code: String::new(),
4428                                effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
4429                                bootstrap: true,
4430                            },
4431                            Frame::Commit {
4432                                commit_seq: 1,
4433                                created_at_ms: 0,
4434                                actor_id: "actor".into(),
4435                                tables: vec!["tasks".into()],
4436                                changes: vec![ssp2::model::Change {
4437                                    table_index: 0,
4438                                    row_id: "server".into(),
4439                                    op: Op::Upsert,
4440                                    row_version: Some(1),
4441                                    scopes: vec![("project_id".into(), "p1".into())],
4442                                    row: Some(payload),
4443                                }],
4444                            },
4445                            Frame::SubEnd {
4446                                next_cursor: 1,
4447                                bootstrap_state: None,
4448                            },
4449                        ],
4450                    }),
4451                },
4452            };
4453            match barrier {
4454                "preflight" => client.begin_security_preflight(),
4455                "purge" => {
4456                    client
4457                        .purge_local_data(&LocalDataPurgeInput {
4458                            purge_id: "pending-round".into(),
4459                            targets: vec![LocalDataPurgeTarget {
4460                                table: "tasks".into(),
4461                                selectors: BTreeMap::from([(
4462                                    "project_id".into(),
4463                                    vec!["p1".into()],
4464                                )]),
4465                            }],
4466                        })
4467                        .unwrap();
4468                }
4469                "rebootstrap" => {
4470                    client
4471                        .rebootstrap_local_data(&LocalDataRebootstrapInput {
4472                            rebootstrap_id: "pending-round".into(),
4473                        })
4474                        .unwrap();
4475                }
4476                "failed-purge-replay" | "failed-purge-publish" | "failed-rebootstrap" => {
4477                    let trigger = if barrier == "failed-purge-replay" {
4478                        "CREATE TRIGGER fail_local BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4479                    } else {
4480                        "CREATE TRIGGER fail_local BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4481                    };
4482                    client.conn.execute_batch(trigger).unwrap();
4483                    let revision = client.local_revision();
4484                    if barrier == "failed-rebootstrap" {
4485                        assert!(client
4486                            .rebootstrap_local_data(&LocalDataRebootstrapInput {
4487                                rebootstrap_id: "failed-round".into(),
4488                            })
4489                            .is_err());
4490                    } else {
4491                        assert!(client
4492                            .purge_local_data(&LocalDataPurgeInput {
4493                                purge_id: "failed-round".into(),
4494                                targets: vec![LocalDataPurgeTarget {
4495                                    table: "tasks".into(),
4496                                    selectors: BTreeMap::from([(
4497                                        "project_id".into(),
4498                                        vec!["p1".into()]
4499                                    )]),
4500                                }],
4501                            })
4502                            .is_err());
4503                    }
4504                    assert_eq!(client.local_revision(), revision);
4505                    assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
4506                    client
4507                        .conn
4508                        .execute_batch("DROP TRIGGER fail_local")
4509                        .unwrap();
4510                    let applied = client.apply_sync_round(completed);
4511                    assert!(
4512                        matches!(
4513                            applied,
4514                            crate::AppliedSyncRound::Complete {
4515                                outcome: SyncOutcome::Ok(_),
4516                                ..
4517                            }
4518                        ),
4519                        "{barrier}"
4520                    );
4521                    assert!(client.pending_commit_ids().is_empty());
4522                    assert_eq!(
4523                        client
4524                            .query("SELECT id FROM tasks WHERE id = 'server'", &[])
4525                            .unwrap()
4526                            .len(),
4527                        1
4528                    );
4529                    continue;
4530                }
4531                "subscription" => {
4532                    client.unsubscribe("tasks");
4533                    client
4534                        .subscribe(
4535                            "tasks".into(),
4536                            "tasks".into(),
4537                            vec![("project_id".into(), vec!["p2".into()])],
4538                            None,
4539                        )
4540                        .unwrap();
4541                }
4542                _ => unreachable!(),
4543            }
4544            let pending_before_reply = client.pending_commit_ids();
4545            let revision = client.local_revision();
4546            let applied = client.apply_sync_round(completed);
4547            assert!(
4548                matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
4549            );
4550            assert_eq!(client.local_revision(), revision);
4551            assert!(client
4552                .conn
4553                .query_row(
4554                    "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
4555                    [],
4556                    |r| r.get::<_, bool>(0)
4557                )
4558                .unwrap());
4559            assert_eq!(client.pending_commit_ids(), pending_before_reply);
4560        }
4561    }
4562
4563    #[test]
4564    fn sync_round_deadline_is_anchored_once_per_round_and_carried_across_continues() {
4565        struct DeadlineRecording {
4566            budget: std::time::Duration,
4567            seen: Vec<Option<std::time::Instant>>,
4568        }
4569        impl Transport for DeadlineRecording {
4570            fn round_deadline(&self) -> Option<std::time::Duration> {
4571                Some(self.budget)
4572            }
4573            fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
4574                self.seen.push(deadline);
4575            }
4576            fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4577                Err(TransportError::new("transport.failed", "injected"))
4578            }
4579            fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4580                self.sync(request)
4581            }
4582            fn download_segment(
4583                &mut self,
4584                _: &SegmentRequest,
4585                _: &mut dyn FnMut(u64),
4586            ) -> Result<Vec<u8>, TransportError> {
4587                Err(TransportError::new("transport.failed", "injected"))
4588            }
4589            fn blob_upload_grant(
4590                &mut self,
4591                _: &str,
4592                _: u64,
4593                _: Option<&str>,
4594            ) -> Result<crate::BlobUploadGrant, TransportError> {
4595                Ok(crate::BlobUploadGrant::Present)
4596            }
4597            fn realtime_connect(&mut self) -> Result<(), TransportError> {
4598                Ok(())
4599            }
4600            fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4601                Ok(())
4602            }
4603            fn realtime_close(&mut self) -> Result<(), TransportError> {
4604                Ok(())
4605            }
4606        }
4607
4608        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4609            {"name":"id","type":"string","nullable":false},
4610            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4611        let mut client =
4612            SyncClient::new("deadline-round".into(), &schema, Default::default()).unwrap();
4613        client.set_meta(LOG_EPOCH_KEY, "epoch");
4614        client
4615            .upload_blob(b"pending upload", Some("text/plain".into()), None)
4616            .unwrap();
4617
4618        let mut transport = DeadlineRecording {
4619            budget: std::time::Duration::from_secs(30),
4620            seen: Vec::new(),
4621        };
4622        let prepared = client.prepare_sync_round(false).unwrap();
4623        assert_eq!(prepared.uploads.len(), 1, "one staged upload");
4624        // Upload exchange -> Continue (the pin removal is persisted by apply).
4625        let prepared = match client.apply_sync_round(prepared.exchange(&mut transport)) {
4626            crate::AppliedSyncRound::Continue(prepared) => prepared,
4627            crate::AppliedSyncRound::Complete { .. } => panic!("the upload must continue"),
4628        };
4629        // Main request exchange -> Complete.
4630        let completed = prepared.exchange(&mut transport);
4631        let anchor = completed
4632            .prepared
4633            .round_deadline_at
4634            .expect("the upload round anchors a deadline");
4635        assert!(matches!(
4636            client.apply_sync_round(completed),
4637            crate::AppliedSyncRound::Complete { .. }
4638        ));
4639        assert_eq!(transport.seen.len(), 4, "one scope per exchange");
4640        assert_eq!(
4641            transport.seen[0],
4642            Some(anchor),
4643            "the upload anchors the round"
4644        );
4645        assert_eq!(
4646            transport.seen[1], None,
4647            "the upload exchange clears its scope"
4648        );
4649        assert_eq!(
4650            transport.seen[2],
4651            Some(anchor),
4652            "a continuation reuses the carried anchor instead of refreshing it"
4653        );
4654        assert_eq!(transport.seen[3], None, "the continuation clears its scope");
4655
4656        // A fresh round anchors a new deadline from the transport's (now larger)
4657        // budget and never inherits the old one; the clock granule is irrelevant.
4658        transport.budget = std::time::Duration::from_secs(60);
4659        let prepared = client.prepare_sync_round(false).unwrap();
4660        let completed = prepared.exchange(&mut transport);
4661        let fresh = completed
4662            .prepared
4663            .round_deadline_at
4664            .expect("the next round anchors a deadline");
4665        client.apply_sync_round(completed);
4666        assert!(
4667            fresh >= anchor + std::time::Duration::from_secs(30),
4668            "the next round anchors a fresh deadline from the new budget"
4669        );
4670        assert_eq!(transport.seen[4], Some(fresh));
4671        assert_eq!(
4672            transport.seen[5], None,
4673            "the new round clears its scope too"
4674        );
4675    }
4676
4677    /// Records the round-deadline traffic so a test can prove whether a round
4678    /// asked for a budget and whether it ever scoped an anchor.
4679    #[derive(Default)]
4680    struct RoundDeadlineProbe {
4681        budget: Option<std::time::Duration>,
4682        budget_queries: std::cell::Cell<usize>,
4683        scoped: Vec<Option<std::time::Instant>>,
4684    }
4685
4686    impl Transport for RoundDeadlineProbe {
4687        fn round_deadline(&self) -> Option<std::time::Duration> {
4688            self.budget_queries.set(self.budget_queries.get() + 1);
4689            self.budget
4690        }
4691        fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
4692            self.scoped.push(deadline);
4693        }
4694        fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4695            Err(TransportError::new("transport.failed", "injected"))
4696        }
4697        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4698            self.sync(request)
4699        }
4700        fn download_segment(
4701            &mut self,
4702            _: &SegmentRequest,
4703            _: &mut dyn FnMut(u64),
4704        ) -> Result<Vec<u8>, TransportError> {
4705            Err(TransportError::new("transport.failed", "injected"))
4706        }
4707        fn blob_upload_grant(
4708            &mut self,
4709            _: &str,
4710            _: u64,
4711            _: Option<&str>,
4712        ) -> Result<crate::BlobUploadGrant, TransportError> {
4713            Ok(crate::BlobUploadGrant::Present)
4714        }
4715        fn realtime_connect(&mut self) -> Result<(), TransportError> {
4716            Ok(())
4717        }
4718        fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4719            Ok(())
4720        }
4721        fn realtime_close(&mut self) -> Result<(), TransportError> {
4722            Ok(())
4723        }
4724    }
4725
4726    #[test]
4727    fn an_unusable_upload_body_does_not_anchor_the_round_deadline() {
4728        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4729            {"name":"id","type":"string","nullable":false},
4730            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4731        let mut client =
4732            SyncClient::new("deadline-unusable".into(), &schema, ClientLimits::default()).unwrap();
4733        client.set_meta(LOG_EPOCH_KEY, "epoch");
4734        client
4735            .upload_blob(b"pending upload", Some("text/plain".into()), None)
4736            .unwrap();
4737        client
4738            .conn
4739            .execute_batch("DELETE FROM _syncular_blobs")
4740            .unwrap();
4741        let prepared = client.prepare_sync_round(false).unwrap();
4742        assert!(
4743            prepared.uploads.front().is_some_and(|u| u.bytes.is_err()),
4744            "the staged body must fail locally"
4745        );
4746        let mut transport = RoundDeadlineProbe {
4747            budget: Some(std::time::Duration::from_secs(30)),
4748            ..Default::default()
4749        };
4750        let completed = prepared.exchange(&mut transport);
4751        assert!(matches!(
4752            completed.exchange,
4753            crate::round::ExchangeResult::Upload { result: Err(_), .. }
4754        ));
4755        assert_eq!(
4756            transport.budget_queries.get(),
4757            0,
4758            "a round with no network work never asks for a budget"
4759        );
4760        assert_eq!(transport.scoped, vec![None]);
4761    }
4762
4763    #[test]
4764    fn a_transport_budget_the_clock_cannot_represent_fails_before_network() {
4765        struct HugeBudget;
4766        impl Transport for HugeBudget {
4767            fn round_deadline(&self) -> Option<std::time::Duration> {
4768                Some(std::time::Duration::MAX)
4769            }
4770            fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4771                panic!("no network for an invalid budget")
4772            }
4773            fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4774                self.sync(request)
4775            }
4776            fn download_segment(
4777                &mut self,
4778                _: &SegmentRequest,
4779                _: &mut dyn FnMut(u64),
4780            ) -> Result<Vec<u8>, TransportError> {
4781                panic!("no network for an invalid budget")
4782            }
4783            fn realtime_connect(&mut self) -> Result<(), TransportError> {
4784                Ok(())
4785            }
4786            fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4787                Ok(())
4788            }
4789            fn realtime_close(&mut self) -> Result<(), TransportError> {
4790                Ok(())
4791            }
4792        }
4793
4794        let mut client = client();
4795        client.set_meta(LOG_EPOCH_KEY, "epoch");
4796        let prepared = client.prepare_sync_round(false).unwrap();
4797        let outcome = match client.apply_sync_round(prepared.exchange(&mut HugeBudget)) {
4798            crate::AppliedSyncRound::Complete { outcome, .. } => outcome,
4799            crate::AppliedSyncRound::Continue(_) => {
4800                panic!("an unrepresentable budget must not continue")
4801            }
4802        };
4803        match outcome {
4804            SyncOutcome::Failed {
4805                error_code,
4806                message,
4807                ..
4808            } => {
4809                assert_eq!(error_code, "sync.invalid_request");
4810                assert_eq!(message, "transport round deadline is out of range");
4811            }
4812            _ => panic!("an unrepresentable budget must fail synchronously"),
4813        }
4814    }
4815
4816    #[test]
4817    fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
4818        for failure in ["visible", "revision", "commit"] {
4819            let mut client = client();
4820            let id = client
4821                .mutate(vec![Mutation::Upsert {
4822                    table: "tasks".into(),
4823                    values: Map::from_iter([
4824                        ("id".into(), Value::from("t1")),
4825                        ("project_id".into(), Value::from("pending")),
4826                    ]),
4827                    base_version: None,
4828                }])
4829                .unwrap();
4830            client.drain_change_batches();
4831            let revision = client.local_revision();
4832            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
4833            let trigger = match failure {
4834                "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
4835                "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"),
4836                _ => "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(),
4837            };
4838            client.conn.execute_batch(&trigger).unwrap();
4839            let change = ssp2::model::Change {
4840                table_index: 0,
4841                row_id: "t1".into(),
4842                op: Op::Upsert,
4843                row_version: Some(2),
4844                scopes: vec![],
4845                row: Some(
4846                    encode_row_json(
4847                        client.schema.table("tasks").unwrap(),
4848                        "t1",
4849                        &Map::from_iter([
4850                            ("id".into(), Value::from("t1")),
4851                            ("project_id".into(), Value::from("remote")),
4852                        ]),
4853                        &client.encryption,
4854                    )
4855                    .unwrap(),
4856                ),
4857            };
4858            assert!(client
4859                .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), None)
4860                .is_err());
4861            assert!(client.conn.is_autocommit());
4862            assert!(!client.overlay_dirty.get());
4863            assert_eq!(client.local_revision(), revision);
4864            assert!(client.drain_change_batches().is_empty());
4865            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
4866            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
4867            assert!(client
4868                .query("SELECT * FROM _syncular_base_tasks", &[])
4869                .unwrap()
4870                .is_empty());
4871            client
4872                .conn
4873                .execute_batch("DROP TRIGGER fail_pending")
4874                .unwrap();
4875            client
4876                .apply_commit_frame(&["tasks".into()], &[change], None)
4877                .unwrap();
4878            assert_eq!(client.local_revision(), revision + 1);
4879            assert_eq!(client.pending_commit_ids(), vec![id]);
4880            // The overlay keeps the pending write visible; the row now carries
4881            // the server row's version it is layered on (§7.1).
4882            let mut expected = before[0].clone();
4883            expected.insert("_syncular_version".to_owned(), Value::from(2));
4884            assert_eq!(
4885                client.query("SELECT * FROM tasks", &[]).unwrap(),
4886                vec![expected]
4887            );
4888            assert_eq!(
4889                client
4890                    .conn
4891                    .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
4892                        .get::<_, String>(0))
4893                    .unwrap(),
4894                "remote"
4895            );
4896        }
4897    }
4898
4899    #[test]
4900    fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
4901        let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
4902            "columns": [
4903                {"name":"id", "type":"string", "nullable":false},
4904                {"name":"project_id", "type":"string", "nullable":false},
4905                {"name":"title", "type":"string", "nullable":false}],
4906            "scopes": [{"pattern":"project:{project_id}"}],
4907            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
4908            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
4909        let mut incremental =
4910            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
4911        let mut reference =
4912            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
4913        for instance in [&mut incremental, &mut reference] {
4914            for index in 0..16 {
4915                instance
4916                    .write_base_row(
4917                        "tasks",
4918                        &vec![
4919                            Some(ColumnValue::String(format!("task{index}"))),
4920                            Some(ColumnValue::String("p0".to_owned())),
4921                            Some(ColumnValue::String(format!("base{index}"))),
4922                        ],
4923                        1,
4924                    )
4925                    .unwrap();
4926            }
4927            instance
4928                .rebuild_overlay_if_dirty()
4929                .expect("rebuild overlay");
4930            instance.overlay_rebuild_count.set(0);
4931        }
4932        for index in 0..100 {
4933            let mutations = (0..1 + index % 4)
4934                .map(|offset| {
4935                    let id = format!("task{}", (index + offset) % 24);
4936                    if (index + offset) % 7 == 0 {
4937                        Mutation::Delete {
4938                            table: "tasks".to_owned(),
4939                            row_id: id,
4940                            base_version: None,
4941                        }
4942                    } else {
4943                        Mutation::Upsert {
4944                            table: "tasks".to_owned(),
4945                            values: Map::from_iter([
4946                                ("id".to_owned(), Value::from(id)),
4947                                (
4948                                    "project_id".to_owned(),
4949                                    Value::from(format!("p{}", index % 2)),
4950                                ),
4951                                (
4952                                    "title".to_owned(),
4953                                    Value::from(format!("needle{}", (index + offset) % 24)),
4954                                ),
4955                            ]),
4956                            base_version: None,
4957                        }
4958                    }
4959                })
4960                .collect::<Vec<_>>();
4961            reference.overlay_dirty.set(true);
4962            incremental.mutate(mutations.clone()).unwrap();
4963            reference.mutate(mutations).unwrap();
4964            for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
4965                "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
4966                assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
4967            }
4968            assert_eq!(incremental.local_revision(), reference.local_revision());
4969            assert_eq!(
4970                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4971                serde_json::to_value(reference.drain_change_batches()).unwrap()
4972            );
4973        }
4974        assert_eq!(incremental.outbox.len(), 100);
4975        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
4976        assert_eq!(reference.overlay_rebuild_count.get(), 100);
4977    }
4978
4979    #[test]
4980    fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
4981        for fail_at in ["outbox", "revision"] {
4982            let mut client = client();
4983            let values = Map::from_iter([
4984                ("id".to_owned(), Value::from("t1")),
4985                ("project_id".to_owned(), Value::from("p1")),
4986            ]);
4987            let mutation = Mutation::Upsert {
4988                table: "tasks".to_owned(),
4989                values,
4990                base_version: None,
4991            };
4992            let trigger = if fail_at == "outbox" {
4993                "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
4994            } else {
4995                format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
4996            };
4997            client.conn.execute_batch(&trigger).unwrap();
4998            let revision = client.local_revision();
4999            assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
5000            assert_eq!(client.local_revision(), revision);
5001            assert!(client.outbox.is_empty());
5002            assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
5003            assert_eq!(
5004                client
5005                    .conn
5006                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5007                        .get::<_, i64>(0))
5008                    .unwrap(),
5009                0
5010            );
5011            assert!(client.drain_change_batches().is_empty());
5012            assert!(!client.overlay_dirty.get());
5013            client
5014                .conn
5015                .execute_batch("DROP TRIGGER fail_append")
5016                .unwrap();
5017            client.mutate(vec![mutation]).unwrap();
5018            assert_eq!(client.outbox.len(), 1);
5019            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
5020        }
5021    }
5022
5023    #[test]
5024    fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
5025        let mut client = client();
5026        let tables = vec!["tasks".to_owned(), "missing".to_owned()];
5027        let values = Map::from_iter([
5028            ("id".to_owned(), Value::from("t1")),
5029            ("project_id".to_owned(), Value::from("p1")),
5030        ]);
5031        let payload = encode_row_json(
5032            client.schema.table("tasks").unwrap(),
5033            "t1",
5034            &values,
5035            &client.encryption,
5036        )
5037        .unwrap();
5038        let first = ssp2::model::Change {
5039            table_index: 0,
5040            row_id: "t1".to_owned(),
5041            op: Op::Upsert,
5042            row_version: Some(1),
5043            scopes: vec![("project_id".to_owned(), "p1".to_owned())],
5044            row: Some(payload),
5045        };
5046        let mut invalid = first.clone();
5047        invalid.table_index = 1;
5048        let revision = client.local_revision();
5049        client.begin_observation("failed_mirror").unwrap();
5050        assert!(client
5051            .apply_commit_changes(&tables, &[first, invalid])
5052            .is_err());
5053        client.rollback_observation("failed_mirror");
5054        client.rebuild_overlay_if_dirty().expect("rebuild overlay");
5055        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
5056        assert_eq!(
5057            client
5058                .conn
5059                .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
5060                    .get::<_, i64>(
5061                    0
5062                ))
5063                .unwrap(),
5064            0
5065        );
5066        assert_eq!(client.local_revision(), revision);
5067        assert!(client.drain_change_batches().is_empty());
5068    }
5069
5070    #[test]
5071    fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
5072        for failure in ["journal", "revision", "commit", "later-frame"] {
5073            let mut client = client();
5074            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5075            for id in ["first", "second"] {
5076                client
5077                    .mutate(vec![Mutation::Upsert {
5078                        table: "tasks".to_owned(),
5079                        values: Map::from_iter([
5080                            ("id".to_owned(), Value::from(id)),
5081                            ("project_id".to_owned(), Value::from("p1")),
5082                        ]),
5083                        base_version: None,
5084                    }])
5085                    .unwrap();
5086            }
5087            let (_, meta) = client.build_request(false).unwrap();
5088            // A malicious or stale response must not drain an unsent local commit.
5089            client
5090                .mutate(vec![Mutation::Upsert {
5091                    table: "tasks".to_owned(),
5092                    values: Map::from_iter([
5093                        ("id".to_owned(), Value::from("later")),
5094                        ("project_id".to_owned(), Value::from("p1")),
5095                    ]),
5096                    base_version: None,
5097                }])
5098                .unwrap();
5099            let ids = client.pending_commit_ids();
5100            let rows = client
5101                .query("SELECT * FROM tasks ORDER BY id", &[])
5102                .unwrap();
5103            let revision = client.local_revision();
5104            client.drain_change_batches();
5105            if failure != "later-frame" {
5106                client.conn.execute_batch(match failure {
5107                    "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",
5108                    "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",
5109                    _ => "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",
5110                }).unwrap();
5111            }
5112            let mut response = Message {
5113                wire_version: WIRE_VERSION,
5114                msg_kind: MsgKind::Response,
5115                frames: vec![Frame::RespHeader {
5116                    required_schema_version: None,
5117                    latest_schema_version: None,
5118                    log_epoch: Some("epoch-1".to_owned()),
5119                    reset_required: Some(false),
5120                }],
5121            };
5122            for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
5123                response.frames.push(Frame::PushResult {
5124                    client_commit_id: id.to_owned(),
5125                    status: PushStatus::Applied,
5126                    commit_seq: Some(1),
5127                    results: vec![OpResult::Applied { op_index: 0 }],
5128                });
5129            }
5130            if failure == "later-frame" {
5131                response.frames.push(Frame::Error {
5132                    code: "sync.invalid_request".to_owned(),
5133                    message: "later error".to_owned(),
5134                    category: "protocol".to_owned(),
5135                    retryable: false,
5136                    recommended_action: "retry".to_owned(),
5137                    details: None,
5138                });
5139            }
5140            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5141            let outcome = client.process_response(&mut transport, response.clone(), &meta);
5142            // §1.4 rule 4: the deferred foreign key of "commit" fails the
5143            // response's coalesced COMMIT instead of the run's RELEASE.
5144            let expected = match failure {
5145                "later-frame" => "sync.invalid_request",
5146                "commit" => "storage.failed",
5147                _ => "client.outcome_persistence_failed",
5148            };
5149            assert!(
5150                matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
5151                "{failure}: {outcome:?}"
5152            );
5153            if failure == "later-frame" {
5154                assert_eq!(client.pending_commit_ids(), ids[2..]);
5155                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
5156                assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
5157                let batches = client.drain_change_batches();
5158                assert_eq!(batches.len(), 1);
5159                assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
5160                assert_eq!(client.local_revision(), revision + 1);
5161                response.frames.pop();
5162            } else {
5163                assert_eq!(client.pending_commit_ids(), ids);
5164                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
5165                assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
5166                assert_eq!(client.local_revision(), revision);
5167                assert!(client.drain_change_batches().is_empty());
5168                assert_eq!(
5169                    client
5170                        .query("SELECT * FROM tasks ORDER BY id", &[])
5171                        .unwrap(),
5172                    rows
5173                );
5174                client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
5175            }
5176            for frame in &mut response.frames {
5177                if let Frame::PushResult { status, .. } = frame {
5178                    *status = PushStatus::Cached;
5179                }
5180            }
5181            let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
5182            else {
5183                panic!("retry failed");
5184            };
5185            assert_eq!(
5186                report.applied,
5187                if failure == "later-frame" {
5188                    vec![]
5189                } else {
5190                    ids[..2].to_vec()
5191                }
5192            );
5193            assert_eq!(client.pending_commit_ids(), ids[2..]);
5194            let batches = client.drain_change_batches();
5195            assert_eq!(batches.len(), usize::from(failure != "later-frame"));
5196            if let Some(batch) = batches.first() {
5197                assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
5198            }
5199            assert_eq!(client.local_revision(), revision + 1);
5200        }
5201    }
5202
5203    #[test]
5204    fn failed_push_result_preserves_memory_disk_and_retry_identity() {
5205        for failure in ["revision", "journal", "commit"] {
5206            for status in ["applied", "cached", "rejected", "conflict"] {
5207                let mut client = client();
5208                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5209                for id in ["first", "later"] {
5210                    client
5211                        .mutate(vec![Mutation::Upsert {
5212                            table: "tasks".to_owned(),
5213                            values: Map::from_iter([
5214                                ("id".to_owned(), Value::from(id)),
5215                                ("project_id".to_owned(), Value::from("p1")),
5216                            ]),
5217                            base_version: None,
5218                        }])
5219                        .unwrap();
5220                }
5221                let (_, meta) = client.build_request(false).unwrap();
5222                let ids = client.pending_commit_ids();
5223                let rows = client
5224                    .query("SELECT * FROM tasks ORDER BY id", &[])
5225                    .unwrap();
5226                let revision = client.local_revision();
5227                client.drain_change_batches();
5228                let (push_status, results) = match status {
5229                    "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
5230                    "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
5231                    "conflict" => (
5232                        PushStatus::Rejected,
5233                        vec![OpResult::Conflict {
5234                            op_index: 0,
5235                            code: "sync.version_conflict".to_owned(),
5236                            message: "conflict".to_owned(),
5237                            server_version: 1,
5238                            server_row: encode_row_json(
5239                                client.schema.table("tasks").unwrap(),
5240                                "first",
5241                                &Map::from_iter([
5242                                    ("id".to_owned(), Value::from("first")),
5243                                    ("project_id".to_owned(), Value::from("p1")),
5244                                ]),
5245                                &client.encryption,
5246                            )
5247                            .unwrap(),
5248                            conflict_columns: Vec::new(),
5249                        }],
5250                    ),
5251                    _ => (
5252                        PushStatus::Rejected,
5253                        vec![OpResult::Error {
5254                            op_index: 0,
5255                            code: "sync.validation_failed".to_owned(),
5256                            message: "rejected".to_owned(),
5257                            retryable: false,
5258                        }],
5259                    ),
5260                };
5261                let response = Message {
5262                    wire_version: WIRE_VERSION,
5263                    msg_kind: MsgKind::Response,
5264                    frames: vec![
5265                        Frame::RespHeader {
5266                            required_schema_version: None,
5267                            latest_schema_version: None,
5268                            log_epoch: Some("epoch-1".to_owned()),
5269                            reset_required: Some(false),
5270                        },
5271                        Frame::PushResult {
5272                            client_commit_id: ids[0].clone(),
5273                            status: push_status,
5274                            commit_seq: Some(1),
5275                            results,
5276                        },
5277                    ],
5278                };
5279                client.conn.execute_batch(match failure {
5280                    "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
5281                    "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
5282                    _ => "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",
5283                }).unwrap();
5284                let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5285                let outcome = client.process_response(&mut transport, response.clone(), &meta);
5286                assert_eq!(
5287                    client.pending_commit_ids(),
5288                    ids,
5289                    "{failure}/{status}: pending identity"
5290                );
5291                assert_eq!(
5292                    client.local_revision(),
5293                    revision,
5294                    "{failure}/{status}: revision"
5295                );
5296                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
5297                assert!(client.conflicts().is_empty());
5298                assert!(client.rejections().is_empty());
5299                assert!(client.drain_change_batches().is_empty());
5300                assert_eq!(
5301                    client
5302                        .query("SELECT * FROM tasks ORDER BY id", &[])
5303                        .unwrap(),
5304                    rows
5305                );
5306                // §1.4 rule 4: the deferred foreign key of "commit" fails the
5307                // response's coalesced COMMIT instead of the result's RELEASE.
5308                let expected = if failure == "commit" {
5309                    "storage.failed"
5310                } else {
5311                    "client.outcome_persistence_failed"
5312                };
5313                assert!(
5314                    matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
5315                    "{failure}/{status}: failed round"
5316                );
5317                client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
5318                assert!(matches!(
5319                    client.process_response(&mut transport, response, &meta),
5320                    SyncOutcome::Ok(_)
5321                ));
5322                assert_eq!(client.pending_commit_ids(), ids[1..]);
5323                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
5324                assert_eq!(client.drain_change_batches().len(), 1);
5325            }
5326        }
5327    }
5328
5329    #[test]
5330    fn mixed_push_results_reconcile_each_boundary_and_prune_once_per_response() {
5331        let mut client = client();
5332        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5333        for index in 0..4 {
5334            client
5335                .mutate(vec![Mutation::Upsert {
5336                    table: "tasks".to_owned(),
5337                    values: Map::from_iter([
5338                        ("id".to_owned(), Value::from(format!("task-{index}"))),
5339                        ("project_id".to_owned(), Value::from("project-1")),
5340                    ]),
5341                    base_version: None,
5342                }])
5343                .expect("queue commit");
5344        }
5345
5346        let (_, request_meta) = client.build_request(false).unwrap();
5347        let ids = &request_meta.pushed_ids;
5348        assert_eq!(ids.len(), 4);
5349        let response = Message {
5350            wire_version: WIRE_VERSION,
5351            msg_kind: MsgKind::Response,
5352            frames: vec![
5353                Frame::RespHeader {
5354                    required_schema_version: None,
5355                    latest_schema_version: None,
5356                    log_epoch: Some("epoch-1".to_owned()),
5357                    reset_required: Some(false),
5358                },
5359                Frame::PushResult {
5360                    client_commit_id: ids[0].clone(),
5361                    status: PushStatus::Applied,
5362                    commit_seq: Some(1),
5363                    results: vec![OpResult::Applied { op_index: 0 }],
5364                },
5365                Frame::PushResult {
5366                    client_commit_id: ids[1].clone(),
5367                    status: PushStatus::Cached,
5368                    commit_seq: Some(2),
5369                    results: vec![OpResult::Applied { op_index: 0 }],
5370                },
5371                Frame::PushResult {
5372                    client_commit_id: ids[2].clone(),
5373                    status: PushStatus::Rejected,
5374                    commit_seq: None,
5375                    results: vec![OpResult::Error {
5376                        op_index: 0,
5377                        code: "sync.validation_failed".to_owned(),
5378                        message: "rejected".to_owned(),
5379                        retryable: false,
5380                    }],
5381                },
5382                Frame::PushResult {
5383                    client_commit_id: ids[3].clone(),
5384                    status: PushStatus::Rejected,
5385                    commit_seq: None,
5386                    results: vec![OpResult::Error {
5387                        op_index: 0,
5388                        code: "sync.idempotency_cache_miss".to_owned(),
5389                        message: "retry".to_owned(),
5390                        retryable: true,
5391                    }],
5392                },
5393            ],
5394        };
5395        let mut transport =
5396            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
5397
5398        client.overlay_rebuild_count.set(0);
5399        client.outcome_prune_count.set(0);
5400        let outcome = client.process_response(&mut transport, response, &request_meta);
5401        let SyncOutcome::Ok(report) = outcome else {
5402            panic!("mixed push-result response failed");
5403        };
5404
5405        assert_eq!(report.applied, ids[..2]);
5406        assert_eq!(report.rejected, ids[2..3]);
5407        assert_eq!(report.retryable, ids[3..4]);
5408        assert_eq!(client.pending_commit_ids(), ids[3..4]);
5409        assert_eq!(
5410            client
5411                .query("SELECT id FROM tasks ORDER BY id", &[])
5412                .expect("query visible overlay"),
5413            ["task-0", "task-1", "task-3"]
5414                .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
5415                .to_vec()
5416        );
5417        // The successful ACK run and final rejection have distinct durable boundaries.
5418        assert_eq!(client.overlay_rebuild_count.get(), 2);
5419        assert_eq!(client.outcome_prune_count.get(), 1);
5420    }
5421
5422    fn replay_schema(fts: bool) -> Value {
5423        json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
5424            {"name":"id","type":"string","nullable":false},
5425            {"name":"project_id","type":"string","nullable":false},
5426            {"name":"title","type":"string","nullable":false},
5427            {"name":"done","type":"boolean","nullable":false}
5428        ],"scopes":[{"pattern":"project:{project_id}"}], "ftsIndexes": if fts { json!([{"name":"tasks_fts","columns":["title"],"tokenize":"unicode61"}]) } else { json!([]) }}]})
5429    }
5430
5431    #[test]
5432    fn aborted_schema_and_epoch_resets_restore_memory_and_durable_intent() {
5433        for schema_reset in [false, true] {
5434            let mut schema = replay_schema(false);
5435            let mut client =
5436                SyncClient::new("reset-failure".into(), &schema, ClientLimits::default()).unwrap();
5437            let id = client
5438                .mutate(vec![Mutation::Upsert {
5439                    table: "tasks".into(),
5440                    values: Map::from_iter([
5441                        ("id".into(), json!("queued")),
5442                        ("project_id".into(), json!("p1")),
5443                        ("title".into(), json!("old")),
5444                        ("done".into(), json!(false)),
5445                    ]),
5446                    base_version: None,
5447                }])
5448                .unwrap();
5449            client.set_meta(LOG_EPOCH_KEY, "old");
5450            let revision = client.local_revision();
5451            let needed = client.sync_needed;
5452            client.drain_change_batches();
5453            client.drain_sync_intents();
5454            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();
5455            if schema_reset {
5456                schema["version"] = json!(2);
5457                schema["tables"][0]["columns"]
5458                    .as_array_mut()
5459                    .unwrap()
5460                    .retain(|column| column["name"] != "title");
5461                client.schema = parse_schema_json(&schema).unwrap();
5462                assert!(client.run_schema_reset().is_err());
5463            } else {
5464                assert!(client.run_log_epoch_reset("new").is_err());
5465            }
5466            assert!(!client.upgrading);
5467            assert_eq!(client.sync_needed, needed);
5468            assert_eq!(client.local_revision(), revision);
5469            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5470            assert!(client.rejections.is_empty());
5471            assert!(client.commit_outcome(&id).unwrap().is_none());
5472            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("old"));
5473            assert_eq!(
5474                client
5475                    .query("SELECT title FROM tasks WHERE id='queued'", &[])
5476                    .unwrap()[0]["title"],
5477                "old"
5478            );
5479            assert_eq!(
5480                client
5481                    .conn
5482                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5483                        .get::<_, i64>(0))
5484                    .unwrap(),
5485                1
5486            );
5487            assert!(client.drain_change_batches().is_empty());
5488            assert!(client.drain_sync_intents().is_empty());
5489            assert!(client.conn.is_autocommit());
5490        }
5491    }
5492
5493    #[test]
5494    fn cached_blob_cleanup_propagates_delete_failures() {
5495        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
5496            {"name":"id","type":"string","nullable":false},
5497            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
5498        let mut client =
5499            SyncClient::new("blob-cleanup".into(), &schema, ClientLimits::default()).unwrap();
5500        client.conn.execute_batch("CREATE TRIGGER refuse_blob_cleanup BEFORE DELETE ON _syncular_blobs BEGIN SELECT RAISE(ABORT,'blob cleanup failed'); END;").unwrap();
5501        client.conn.execute("INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,created_at_ms) VALUES ('test',X'01',1,0)", []).unwrap();
5502        client
5503            .subscribe("attachments".into(), "attachments".into(), vec![], None)
5504            .unwrap();
5505        client.subs[0].effective = Some(vec![]);
5506        client.persist_sub(&client.subs[0]).unwrap();
5507        let revision = client.local_revision();
5508        let (_, meta) = client.build_request(false).unwrap();
5509        let mut transport = CountingRealtimeTransport::default();
5510        let mut report = SyncReport::default();
5511        assert!(client
5512            .process_section(
5513                &mut transport,
5514                "attachments",
5515                SubStatus::Revoked,
5516                "sync.scope_revoked",
5517                vec![],
5518                vec![],
5519                Some((0, None)),
5520                &meta,
5521                &mut report
5522            )
5523            .is_err());
5524        assert_eq!(client.subs[0].state, SubState::Active);
5525        assert_eq!(client.local_revision(), revision);
5526        assert!(report.revoked.is_empty());
5527        assert!(client.conn.is_autocommit());
5528        client
5529            .conn
5530            .execute_batch("DROP TRIGGER refuse_blob_cleanup")
5531            .unwrap();
5532        assert!(client
5533            .process_section(
5534                &mut transport,
5535                "attachments",
5536                SubStatus::Revoked,
5537                "sync.scope_revoked",
5538                vec![],
5539                vec![],
5540                Some((0, None)),
5541                &meta,
5542                &mut report
5543            )
5544            .is_ok());
5545        assert_eq!(client.subs[0].state, SubState::Revoked);
5546        assert_eq!(
5547            client
5548                .conn
5549                .query_row("SELECT count(*) FROM _syncular_blobs", [], |row| row
5550                    .get::<_, i64>(0))
5551                .unwrap(),
5552            0
5553        );
5554    }
5555
5556    #[test]
5557    fn overlay_replay_trigger_refuses_reopen_and_preserves_durable_intent() {
5558        let path =
5559            std::env::temp_dir().join(format!("syncular-replay-{}.db", uuid::Uuid::new_v4()));
5560        let schema = replay_schema(false);
5561        let mut client = SyncClient::open_path(
5562            "replay".into(),
5563            &schema,
5564            ClientLimits::default(),
5565            path.to_str().unwrap(),
5566        )
5567        .unwrap();
5568        let ids = ["early", "bad"].map(|id| {
5569            client
5570                .mutate(vec![Mutation::Upsert {
5571                    table: "tasks".into(),
5572                    values: Map::from_iter([
5573                        ("id".into(), json!(id)),
5574                        ("project_id".into(), json!("p1")),
5575                        ("title".into(), json!(id)),
5576                        ("done".into(), json!(false)),
5577                    ]),
5578                    base_version: None,
5579                }])
5580                .unwrap()
5581        });
5582        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();
5583        let revision = client.local_revision();
5584        drop(client);
5585        let error = SyncClient::open_path(
5586            "replay".into(),
5587            &schema,
5588            ClientLimits::default(),
5589            path.to_str().unwrap(),
5590        )
5591        .err()
5592        .expect("reopen must fail");
5593        assert!(error.contains("replay failed"));
5594        let conn = Connection::open(&path).unwrap();
5595        assert_eq!(
5596            conn.query_row("SELECT count(*) FROM tasks", [], |row| row.get::<_, i64>(0))
5597                .unwrap(),
5598            0
5599        );
5600        assert_eq!(
5601            conn.query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5602                .get::<_, i64>(0))
5603                .unwrap(),
5604            2
5605        );
5606        assert_eq!(
5607            meta_get(&conn, LOCAL_REVISION_KEY),
5608            Some(revision.to_string())
5609        );
5610        conn.execute_batch("DROP TRIGGER fail_replay").unwrap();
5611        drop(conn);
5612        let reopened = SyncClient::open_path(
5613            "replay".into(),
5614            &schema,
5615            ClientLimits::default(),
5616            path.to_str().unwrap(),
5617        )
5618        .unwrap();
5619        assert_eq!(reopened.pending_commit_ids(), ids);
5620        assert_eq!(
5621            reopened
5622                .query("SELECT id FROM tasks ORDER BY id", &[])
5623                .unwrap()
5624                .len(),
5625            2
5626        );
5627        drop(reopened);
5628        std::fs::remove_file(path).unwrap();
5629    }
5630
5631    #[test]
5632    fn overlay_replay_failure_rolls_back_push_and_pull_apply_boundaries() {
5633        let schema = replay_schema(false);
5634        let mut client =
5635            SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
5636        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5637        client
5638            .write_base_row(
5639                "tasks",
5640                &vec![
5641                    Some(ColumnValue::String("queued".into())),
5642                    Some(ColumnValue::String("p1".into())),
5643                    Some(ColumnValue::String("server".into())),
5644                    Some(ColumnValue::Boolean(false)),
5645                ],
5646                1,
5647            )
5648            .unwrap();
5649        client.rebuild_overlay().unwrap();
5650        let id = client
5651            .patch(
5652                "tasks",
5653                "queued",
5654                Map::from_iter([("title".into(), json!("local"))]),
5655                Some(1),
5656            )
5657            .unwrap();
5658        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
5659        let base = client
5660            .query("SELECT * FROM _syncular_base_tasks", &[])
5661            .unwrap();
5662        let revision = client.local_revision();
5663        client.drain_change_batches();
5664        client.conn.execute_batch("CREATE TRIGGER fail_replay BEFORE INSERT ON tasks WHEN new.title='local' BEGIN SELECT RAISE(ABORT,'replay failed'); END").unwrap();
5665        let remote = vec![
5666            Some(ColumnValue::String("queued".into())),
5667            Some(ColumnValue::String("p1".into())),
5668            Some(ColumnValue::String("remote".into())),
5669            Some(ColumnValue::Boolean(false)),
5670        ];
5671        let bytes = crate::values::encode_row_json(
5672            client.schema.table("tasks").unwrap(),
5673            "queued",
5674            &Map::from_iter(
5675                client
5676                    .schema
5677                    .table("tasks")
5678                    .unwrap()
5679                    .columns
5680                    .iter()
5681                    .zip(&remote)
5682                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value))),
5683            ),
5684            &client.encryption,
5685        )
5686        .unwrap();
5687        let change = ssp2::model::Change {
5688            table_index: 0,
5689            op: Op::Upsert,
5690            row_id: "queued".into(),
5691            row_version: Some(2),
5692            scopes: vec![("project_id".into(), "p1".into())],
5693            row: Some(bytes),
5694        };
5695        assert!(client
5696            .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), Some(2))
5697            .is_err());
5698        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
5699        assert_eq!(
5700            client
5701                .query("SELECT * FROM _syncular_base_tasks", &[])
5702                .unwrap(),
5703            base
5704        );
5705        assert_eq!(client.local_revision(), revision);
5706        assert!(client.drain_change_batches().is_empty());
5707        let response = Message {
5708            wire_version: 2,
5709            msg_kind: MsgKind::Response,
5710            frames: vec![
5711                Frame::RespHeader {
5712                    required_schema_version: None,
5713                    latest_schema_version: None,
5714                    log_epoch: Some("epoch-1".into()),
5715                    reset_required: Some(false),
5716                },
5717                Frame::PushResult {
5718                    client_commit_id: id.clone(),
5719                    status: PushStatus::Applied,
5720                    commit_seq: Some(2),
5721                    results: vec![OpResult::Applied { op_index: 0 }],
5722                },
5723            ],
5724        };
5725        let (_, meta) = client.build_request(false).unwrap();
5726        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5727        assert!(
5728            matches!(client.process_response(&mut transport,response.clone(),&meta),SyncOutcome::Failed { error_code,.. } if error_code=="client.outcome_persistence_failed")
5729        );
5730        assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5731        assert!(client.commit_outcome(&id).unwrap().is_none());
5732        assert_eq!(
5733            client
5734                .conn
5735                .query_row("SELECT count(*) FROM _syncular_acked_rows", [], |row| row
5736                    .get::<_, i64>(
5737                    0
5738                ))
5739                .unwrap(),
5740            0
5741        );
5742        assert_eq!(
5743            client
5744                .conn
5745                .query_row("SELECT count(*) FROM _syncular_row_deliveries", [], |row| {
5746                    row.get::<_, i64>(0)
5747                })
5748                .unwrap(),
5749            0
5750        );
5751        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
5752        assert_eq!(client.local_revision(), revision);
5753        assert!(client.drain_change_batches().is_empty());
5754        client
5755            .conn
5756            .execute_batch("DROP TRIGGER fail_replay")
5757            .unwrap();
5758        assert!(matches!(
5759            client.process_response(&mut transport, response, &meta),
5760            SyncOutcome::Ok(_)
5761        ));
5762        client
5763            .apply_commit_frame(&["tasks".into()], &[change], Some(2))
5764            .unwrap();
5765        assert!(client.pending_commit_ids().is_empty());
5766        assert_eq!(
5767            client.query("SELECT title FROM tasks", &[]).unwrap()[0]["title"],
5768            json!("remote")
5769        );
5770    }
5771
5772    #[test]
5773    fn overlay_replay_distinguishes_absence_from_sql_and_value_decode_errors() {
5774        let client = SyncClient::new(
5775            "replay".into(),
5776            &replay_schema(false),
5777            ClientLimits::default(),
5778        )
5779        .unwrap();
5780        let table = client.schema.table("tasks").unwrap();
5781        let patch = OutboxOp {
5782            table: "tasks".into(),
5783            row_id: "absent".into(),
5784            upsert: true,
5785            base_version: Some(1),
5786            values: Some(Map::from_iter([
5787                ("id".into(), json!("absent")),
5788                ("title".into(), json!("patch")),
5789            ])),
5790        };
5791        assert!(client.visible_row(table, "absent").unwrap().is_none());
5792        client
5793            .apply_outbox_ops([&patch])
5794            .expect("sparse replay over truly absent row");
5795        client
5796            .conn
5797            .execute_batch("INSERT INTO tasks VALUES ('bad','p1','title','invalid',1)")
5798            .unwrap();
5799        assert!(client
5800            .visible_row(table, "bad")
5801            .unwrap_err()
5802            .starts_with("sync.local_corrupt:"));
5803        let mut invalid = patch.clone();
5804        invalid.values = Some(Map::from_iter([
5805            ("id".into(), json!("bad-value")),
5806            ("project_id".into(), json!("p1")),
5807            ("title".into(), json!("title")),
5808            ("done".into(), json!({"bad":true})),
5809        ]));
5810        assert!(client
5811            .apply_outbox_ops([&invalid])
5812            .unwrap_err()
5813            .starts_with("sync.local_corrupt:"));
5814        client.conn.execute_batch("DROP TABLE tasks").unwrap();
5815        assert!(
5816            client.apply_outbox_ops([&patch]).is_err(),
5817            "SQL failure must not count as absence"
5818        );
5819    }
5820
5821    #[test]
5822    fn overlay_replay_keeps_secondary_unique_deferral_and_lazy_base_precedence() {
5823        let mut schema = replay_schema(false);
5824        schema["tables"][0]["indexes"] =
5825            json!([{"name":"tasks_project_title", "columns":["project_id","title"],"unique":true}]);
5826        let mut client =
5827            SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
5828        let id = client
5829            .mutate(vec![Mutation::Upsert {
5830                table: "tasks".into(),
5831                values: Map::from_iter([
5832                    ("id".into(), json!("queued")),
5833                    ("project_id".into(), json!("p1")),
5834                    ("title".into(), json!("occupied")),
5835                    ("done".into(), json!(false)),
5836                ]),
5837                base_version: None,
5838            }])
5839            .unwrap();
5840        client
5841            .write_base_row(
5842                "tasks",
5843                &vec![
5844                    Some(ColumnValue::String("server".into())),
5845                    Some(ColumnValue::String("p1".into())),
5846                    Some(ColumnValue::String("occupied".into())),
5847                    Some(ColumnValue::Boolean(false)),
5848                ],
5849                1,
5850            )
5851            .unwrap();
5852        client.rebuild_overlay_if_dirty().unwrap();
5853        assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5854        assert!(client
5855            .visible_row(client.schema.table("tasks").unwrap(), "queued")
5856            .unwrap()
5857            .is_none());
5858        assert!(client.rejections().is_empty());
5859        client.delete_base_row("tasks", "server").unwrap();
5860        client.rebuild_overlay_if_dirty().unwrap();
5861        assert!(client
5862            .visible_row(client.schema.table("tasks").unwrap(), "queued")
5863            .unwrap()
5864            .is_some());
5865        client
5866            .write_base_row(
5867                "tasks",
5868                &vec![
5869                    Some(ColumnValue::String("base".into())),
5870                    Some(ColumnValue::String("p1".into())),
5871                    Some(ColumnValue::String("base-title".into())),
5872                    Some(ColumnValue::Boolean(false)),
5873                ],
5874                1,
5875            )
5876            .unwrap();
5877        client.conn.execute_batch("DROP TABLE tasks").unwrap();
5878        client
5879            .retain_failed_commit(
5880                "retained",
5881                &[OutboxOp {
5882                    table: "tasks".into(),
5883                    row_id: "base".into(),
5884                    upsert: true,
5885                    base_version: Some(1),
5886                    values: Some(Map::from_iter([
5887                        ("id".into(), json!("base")),
5888                        ("title".into(), json!("patch")),
5889                    ])),
5890                }],
5891            )
5892            .expect("an existing base must not read the unavailable visible table");
5893    }
5894
5895    #[test]
5896    fn overlay_replay_fts_and_savepoint_release_failures_roll_back_all_visible_changes() {
5897        for failure in ["fts", "release"] {
5898            let mut client = SyncClient::new(
5899                "replay".into(),
5900                &replay_schema(true),
5901                ClientLimits::default(),
5902            )
5903            .unwrap();
5904            client
5905                .mutate(vec![Mutation::Upsert {
5906                    table: "tasks".into(),
5907                    values: Map::from_iter([
5908                        ("id".into(), json!("queued")),
5909                        ("project_id".into(), json!("p1")),
5910                        ("title".into(), json!("needle")),
5911                        ("done".into(), json!(false)),
5912                    ]),
5913                    base_version: None,
5914                }])
5915                .unwrap();
5916            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
5917            let fts_before = client
5918                .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
5919                .unwrap();
5920            let triggers_before: Vec<String> = client
5921                .conn
5922                .prepare("SELECT sql FROM sqlite_master WHERE type='trigger' ORDER BY name")
5923                .unwrap()
5924                .query_map([], |row| row.get(0))
5925                .unwrap()
5926                .collect::<Result<_, _>>()
5927                .unwrap();
5928            if failure == "fts" {
5929                client.conn.execute_batch("CREATE TRIGGER fail_fts BEFORE INSERT ON _syncular_fts_tasks_fts BEGIN SELECT RAISE(ABORT,'fts replay failed'); END;").unwrap();
5930            } else {
5931                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();
5932            }
5933            client.overlay_dirty.set(true);
5934            assert!(client.rebuild_overlay().is_err());
5935            assert!(client.overlay_dirty.get());
5936            assert!(client.conn.is_autocommit());
5937            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
5938            assert_eq!(
5939                client
5940                    .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
5941                    .unwrap(),
5942                fts_before
5943            );
5944            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();
5945            assert_eq!(original_triggers, triggers_before);
5946            assert_eq!(client.pending_commit_ids().len(), 1);
5947            client
5948                .conn
5949                .execute_batch(if failure == "fts" {
5950                    "DROP TRIGGER fail_fts"
5951                } else {
5952                    "DROP TRIGGER fail_release"
5953                })
5954                .unwrap();
5955            client
5956                .rebuild_overlay()
5957                .expect("retry after removing the fault");
5958        }
5959    }
5960
5961    #[test]
5962    fn secondary_unique_collision_preserves_existing_synced_row() {
5963        let client = SyncClient::new(
5964            "unique-upsert-test".to_owned(),
5965            &json!({
5966                "version": 1,
5967                "tables": [{
5968                    "name": "tasks",
5969                    "primaryKey": "id",
5970                    "columns": [
5971                        { "name": "id", "type": "string", "nullable": false },
5972                        { "name": "project_id", "type": "string", "nullable": false },
5973                        { "name": "title", "type": "string", "nullable": false }
5974                    ],
5975                    "scopes": [{ "pattern": "project:{project_id}" }],
5976                    "indexes": [{
5977                        "name": "tasks_by_project_title",
5978                        "columns": ["project_id", "title"],
5979                        "unique": true
5980                    }]
5981                }]
5982            }),
5983            ClientLimits::default(),
5984        )
5985        .expect("test client");
5986        let table = client.schema.table("tasks").expect("tasks table");
5987        let sql = client.insert_row_sql(&base_table("tasks"), table);
5988
5989        client
5990            .conn
5991            .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
5992            .expect("insert first row");
5993        client
5994            .conn
5995            .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
5996            .expect("update same primary key");
5997        client
5998            .conn
5999            .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
6000            .expect("insert second row");
6001        assert!(client
6002            .conn
6003            .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
6004            .is_err());
6005
6006        let rows = client
6007            .conn
6008            .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
6009            .expect("prepare rows")
6010            .query_map([], |row| {
6011                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6012            })
6013            .expect("query rows")
6014            .collect::<Result<Vec<_>, _>>()
6015            .expect("collect rows");
6016        assert_eq!(
6017            rows,
6018            vec![
6019                ("t1".to_owned(), "updated".to_owned()),
6020                ("t2".to_owned(), "original".to_owned())
6021            ]
6022        );
6023    }
6024
6025    #[test]
6026    fn reopening_active_subscriptions_emits_a_catch_up_intent() {
6027        let path = std::env::temp_dir().join(format!(
6028            "syncular-startup-intent-{}.db",
6029            uuid::Uuid::new_v4()
6030        ));
6031        let schema = json!({
6032            "version": 1,
6033            "tables": [{
6034                "name": "tasks",
6035                "primaryKey": "id",
6036                "columns": [
6037                    { "name": "id", "type": "string", "nullable": false },
6038                    { "name": "project_id", "type": "string", "nullable": false }
6039                ],
6040                "scopes": [{ "pattern": "project:{project_id}" }]
6041            }]
6042        });
6043
6044        {
6045            let mut first = SyncClient::open_path_with_identity(
6046                None,
6047                &schema,
6048                ClientLimits::default(),
6049                path.to_str().expect("UTF-8 temp path"),
6050            )
6051            .expect("first open");
6052            first
6053                .set_window(
6054                    &WindowBase {
6055                        table: "tasks".to_owned(),
6056                        variable: "project_id".to_owned(),
6057                        fixed_scopes: Vec::new(),
6058                        params: None,
6059                    },
6060                    &["persisted".to_owned()],
6061                )
6062                .expect("persist window");
6063        }
6064
6065        let mut reopened = SyncClient::open_path_with_identity(
6066            None,
6067            &schema,
6068            ClientLimits::default(),
6069            path.to_str().expect("UTF-8 temp path"),
6070        )
6071        .expect("reopen");
6072        assert!(reopened.sync_needed());
6073        assert!(matches!(
6074            reopened.drain_sync_intents().as_slice(),
6075            [SyncIntent::Interactive]
6076        ));
6077        drop(reopened);
6078        std::fs::remove_file(path).expect("remove temp database");
6079    }
6080
6081    #[test]
6082    fn reopening_preserves_immutable_subscription_identity_and_progress() {
6083        let path = std::env::temp_dir().join(format!(
6084            "syncular-subscription-identity-{}.db",
6085            uuid::Uuid::new_v4()
6086        ));
6087        let schema = json!({
6088            "version": 1,
6089            "tables": [
6090                {
6091                    "name": "tasks",
6092                    "primaryKey": "id",
6093                    "columns": [
6094                        { "name": "id", "type": "string", "nullable": false },
6095                        { "name": "project_id", "type": "string", "nullable": false }
6096                    ],
6097                    "scopes": [{ "pattern": "project:{project_id}" }]
6098                },
6099                {
6100                    "name": "docs",
6101                    "primaryKey": "id",
6102                    "columns": [
6103                        { "name": "id", "type": "string", "nullable": false },
6104                        { "name": "org_id", "type": "string", "nullable": false },
6105                        { "name": "project_id", "type": "string", "nullable": false }
6106                    ],
6107                    "scopes": [
6108                        { "pattern": "org:{org_id}" },
6109                        { "pattern": "project:{projectId}", "column": "project_id" }
6110                    ]
6111                }
6112            ]
6113        });
6114
6115        {
6116            let mut first = SyncClient::open_path_with_identity(
6117                None,
6118                &schema,
6119                ClientLimits::default(),
6120                path.to_str().expect("UTF-8 temp path"),
6121            )
6122            .expect("first open");
6123            first
6124                .subscribe(
6125                    "stable-subscription".to_owned(),
6126                    "tasks".to_owned(),
6127                    vec![(
6128                        "project_id".to_owned(),
6129                        vec!["p2".to_owned(), "p1".to_owned()],
6130                    )],
6131                    Some(r#"{"view":"v1"}"#.to_owned()),
6132                )
6133                .expect("persist subscription");
6134            let persisted = {
6135                let subscription = first
6136                    .subs
6137                    .iter_mut()
6138                    .find(|subscription| subscription.id == "stable-subscription")
6139                    .expect("subscription");
6140                subscription.cursor = 41;
6141                subscription.bootstrap_state = Some("resume-token".to_owned());
6142                subscription.effective = Some(vec![(
6143                    "project_id".to_owned(),
6144                    vec!["p1".to_owned(), "p2".to_owned()],
6145                )]);
6146                subscription.synced_once = true;
6147                subscription.clone()
6148            };
6149            first.persist_sub(&persisted).unwrap();
6150        }
6151
6152        let mut reopened = SyncClient::open_path_with_identity(
6153            None,
6154            &schema,
6155            ClientLimits::default(),
6156            path.to_str().expect("UTF-8 temp path"),
6157        )
6158        .expect("reopen");
6159        let progress = reopened
6160            .subscription_state("stable-subscription")
6161            .expect("persisted state");
6162        assert_eq!(progress.cursor, 41);
6163        assert!(progress.has_resume_token);
6164
6165        reopened
6166            .subscribe(
6167                "stable-subscription".to_owned(),
6168                "tasks".to_owned(),
6169                vec![(
6170                    "project_id".to_owned(),
6171                    vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
6172                )],
6173                Some(r#"{"view":"v1"}"#.to_owned()),
6174            )
6175            .expect("canonical intent is idempotent");
6176        assert_eq!(
6177            reopened
6178                .subscription_state("stable-subscription")
6179                .expect("unchanged state")
6180                .cursor,
6181            progress.cursor
6182        );
6183
6184        for (table, scopes, params) in [
6185            (
6186                "tasks",
6187                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6188                Some(r#"{"view":"v1"}"#.to_owned()),
6189            ),
6190            (
6191                "tasks",
6192                vec![(
6193                    "project_id".to_owned(),
6194                    vec!["p2".to_owned(), "p1".to_owned()],
6195                )],
6196                Some(r#"{"view":"v2"}"#.to_owned()),
6197            ),
6198            (
6199                "docs",
6200                vec![
6201                    ("org_id".to_owned(), vec!["o1".to_owned()]),
6202                    ("projectId".to_owned(), vec!["p1".to_owned()]),
6203                ],
6204                Some(r#"{"view":"v1"}"#.to_owned()),
6205            ),
6206        ] {
6207            let error = reopened
6208                .subscribe(
6209                    "stable-subscription".to_owned(),
6210                    table.to_owned(),
6211                    scopes,
6212                    params,
6213                )
6214                .expect_err("identity rebind must fail");
6215            assert!(error.starts_with("client.subscription_intent_mismatch:"));
6216            assert_eq!(
6217                reopened
6218                    .subscription_state("stable-subscription")
6219                    .expect("unchanged state")
6220                    .cursor,
6221                progress.cursor
6222            );
6223        }
6224
6225        drop(reopened);
6226        std::fs::remove_file(path).expect("remove temp database");
6227    }
6228
6229    #[test]
6230    fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
6231        let path = std::env::temp_dir().join(format!(
6232            "syncular-satisfied-schema-floor-{}.db",
6233            uuid::Uuid::new_v4()
6234        ));
6235        let schema = json!({
6236            "version": 23,
6237            "tables": [{
6238                "name": "tasks",
6239                "primaryKey": "id",
6240                "columns": [
6241                    { "name": "id", "type": "string", "nullable": false },
6242                    { "name": "project_id", "type": "string", "nullable": false }
6243                ],
6244                "scopes": [{ "pattern": "project:{project_id}" }]
6245            }]
6246        });
6247
6248        {
6249            let mut first = SyncClient::open_path_with_identity(
6250                None,
6251                &schema,
6252                ClientLimits::default(),
6253                path.to_str().expect("UTF-8 temp path"),
6254            )
6255            .expect("first open");
6256            first
6257                .subscribe(
6258                    "tasks".to_owned(),
6259                    "tasks".to_owned(),
6260                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6261                    None,
6262                )
6263                .expect("persist subscription");
6264            first.set_schema_floor(Some(SchemaFloor {
6265                required_schema_version: Some(22),
6266                latest_schema_version: Some(22),
6267            }));
6268        }
6269
6270        let mut reopened = SyncClient::open_path_with_identity(
6271            None,
6272            &schema,
6273            ClientLimits::default(),
6274            path.to_str().expect("UTF-8 temp path"),
6275        )
6276        .expect("reopen");
6277        assert!(reopened.schema_floor().is_none());
6278        assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
6279        assert!(reopened.sync_needed());
6280        assert!(matches!(
6281            reopened.drain_sync_intents().as_slice(),
6282            [SyncIntent::Interactive]
6283        ));
6284        drop(reopened);
6285        std::fs::remove_file(path).expect("remove temp database");
6286    }
6287
6288    #[test]
6289    fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
6290        let path = std::env::temp_dir().join(format!(
6291            "syncular-unsatisfied-schema-floor-{}.db",
6292            uuid::Uuid::new_v4()
6293        ));
6294        let schema = json!({
6295            "version": 1,
6296            "tables": [{
6297                "name": "tasks",
6298                "primaryKey": "id",
6299                "columns": [
6300                    { "name": "id", "type": "string", "nullable": false },
6301                    { "name": "project_id", "type": "string", "nullable": false }
6302                ],
6303                "scopes": [{ "pattern": "project:{project_id}" }]
6304            }]
6305        });
6306
6307        {
6308            let mut first = SyncClient::open_path_with_identity(
6309                None,
6310                &schema,
6311                ClientLimits::default(),
6312                path.to_str().expect("UTF-8 temp path"),
6313            )
6314            .expect("first open");
6315            first
6316                .subscribe(
6317                    "tasks".to_owned(),
6318                    "tasks".to_owned(),
6319                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6320                    None,
6321                )
6322                .expect("persist subscription");
6323            first.set_schema_floor(Some(SchemaFloor {
6324                required_schema_version: Some(2),
6325                latest_schema_version: Some(2),
6326            }));
6327        }
6328
6329        let reopened = SyncClient::open_path_with_identity(
6330            None,
6331            &schema,
6332            ClientLimits::default(),
6333            path.to_str().expect("UTF-8 temp path"),
6334        )
6335        .expect("reopen");
6336        assert_eq!(
6337            reopened.schema_floor(),
6338            Some(&SchemaFloor {
6339                required_schema_version: Some(2),
6340                latest_schema_version: Some(2),
6341            })
6342        );
6343        assert!(!reopened.sync_needed());
6344        drop(reopened);
6345        std::fs::remove_file(path).expect("remove temp database");
6346    }
6347
6348    #[test]
6349    fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
6350        let path = std::env::temp_dir().join(format!(
6351            "syncular-indexed-column-bump-{}.db",
6352            uuid::Uuid::new_v4()
6353        ));
6354        let old_schema = json!({
6355            "version": 1,
6356            "tables": [
6357                {
6358                    "name": "tasks",
6359                    "primaryKey": "id",
6360                    "columns": [
6361                        { "name": "id", "type": "string", "nullable": false },
6362                        { "name": "project_id", "type": "string", "nullable": false }
6363                    ],
6364                    "scopes": [{ "pattern": "project:{project_id}" }]
6365                },
6366                {
6367                    "name": "legacy",
6368                    "primaryKey": "id",
6369                    "columns": [
6370                        { "name": "id", "type": "string", "nullable": false },
6371                        { "name": "project_id", "type": "string", "nullable": false }
6372                    ],
6373                    "scopes": [{ "pattern": "project:{project_id}" }]
6374                }
6375            ]
6376        });
6377        {
6378            let mut first = SyncClient::open_path_with_identity(
6379                None,
6380                &old_schema,
6381                ClientLimits::default(),
6382                path.to_str().expect("UTF-8 temp path"),
6383            )
6384            .expect("open old schema");
6385            first
6386                .subscribe(
6387                    "legacy-sub".to_owned(),
6388                    "legacy".to_owned(),
6389                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6390                    None,
6391                )
6392                .expect("persist legacy subscription");
6393        }
6394
6395        let new_schema = json!({
6396            "version": 2,
6397            "tables": [{
6398                "name": "tasks",
6399                "primaryKey": "id",
6400                "columns": [
6401                    { "name": "id", "type": "string", "nullable": false },
6402                    { "name": "project_id", "type": "string", "nullable": false },
6403                    { "name": "facility_membership_id", "type": "string", "nullable": true }
6404                ],
6405                "scopes": [{ "pattern": "project:{project_id}" }],
6406                "indexes": [{
6407                    "name": "tasks_by_membership",
6408                    "columns": ["project_id", "facility_membership_id"],
6409                    "unique": false
6410                }]
6411            }]
6412        });
6413        let upgraded = SyncClient::open_path_with_identity(
6414            None,
6415            &new_schema,
6416            ClientLimits::default(),
6417            path.to_str().expect("UTF-8 temp path"),
6418        )
6419        .expect("open upgraded schema");
6420        let columns = upgraded
6421            .conn
6422            .prepare("PRAGMA table_info(tasks)")
6423            .expect("prepare columns")
6424            .query_map([], |row| row.get::<_, String>(1))
6425            .expect("query columns")
6426            .collect::<Result<Vec<_>, _>>()
6427            .expect("collect columns");
6428        assert!(columns.contains(&"facility_membership_id".to_owned()));
6429        let index_count: i64 = upgraded
6430            .conn
6431            .query_row(
6432                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
6433                [],
6434                |row| row.get(0),
6435            )
6436            .expect("query index");
6437        assert_eq!(index_count, 1);
6438        assert!(upgraded.subscription_state("legacy-sub").is_none());
6439        drop(upgraded);
6440        std::fs::remove_file(path).expect("remove temp database");
6441    }
6442
6443    #[test]
6444    fn migrates_pre_envelope_outcome_journal_additively() {
6445        let path = std::env::temp_dir().join(format!(
6446            "syncular-outcome-migration-{}.db",
6447            uuid::Uuid::new_v4()
6448        ));
6449        let conn = Connection::open(&path).expect("open legacy database");
6450        conn.execute_batch(
6451            "CREATE TABLE _syncular_commit_outcomes (
6452               seq INTEGER PRIMARY KEY AUTOINCREMENT,
6453               client_commit_id TEXT NOT NULL UNIQUE,
6454               status TEXT NOT NULL,
6455               recorded_at_ms INTEGER NOT NULL,
6456               results_json TEXT NOT NULL,
6457               resolution TEXT NOT NULL DEFAULT 'active',
6458               resolved_at_ms INTEGER,
6459               replacement_client_commit_id TEXT);",
6460        )
6461        .expect("create legacy outcome journal");
6462        drop(conn);
6463        let schema = json!({
6464            "version": 1,
6465            "tables": [{
6466                "name": "tasks",
6467                "primaryKey": "id",
6468                "columns": [
6469                    { "name": "id", "type": "string", "nullable": false },
6470                    { "name": "project_id", "type": "string", "nullable": false }
6471                ],
6472                "scopes": [{ "pattern": "project:{project_id}" }]
6473            }]
6474        });
6475        let client = SyncClient::open_path(
6476            "migration-native".to_owned(),
6477            &schema,
6478            ClientLimits::default(),
6479            path.to_str().expect("UTF-8 temp path"),
6480        )
6481        .expect("migrate database");
6482        let has_operations = client
6483            .conn
6484            .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
6485            .expect("prepare table info")
6486            .query_map([], |row| row.get::<_, String>(1))
6487            .expect("query table info")
6488            .filter_map(Result::ok)
6489            .any(|column| column == "operations_json");
6490        assert!(has_operations);
6491        drop(client);
6492        std::fs::remove_file(path).expect("remove temp database");
6493    }
6494
6495    #[test]
6496    fn retained_rejection_survives_file_reopen_and_security_purge() {
6497        let path =
6498            std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
6499        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
6500            { "name": "id", "type": "string", "nullable": false },
6501            { "name": "project_id", "type": "string", "nullable": false },
6502            { "name": "title", "type": "string", "nullable": false }
6503        ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
6504        {
6505            let mut first = SyncClient::open_path(
6506                "retained-native".to_owned(),
6507                &schema,
6508                ClientLimits::default(),
6509                path.to_str().unwrap(),
6510            )
6511            .unwrap();
6512            first
6513                .write_base_row(
6514                    "tasks",
6515                    &vec![
6516                        Some(ColumnValue::String("t1".into())),
6517                        Some(ColumnValue::String("p1".into())),
6518                        Some(ColumnValue::String("server".into())),
6519                    ],
6520                    1,
6521                )
6522                .unwrap();
6523            first.rebuild_overlay().expect("rebuild overlay");
6524            let operation = OutboxOp {
6525                table: "tasks".into(),
6526                row_id: "t1".into(),
6527                upsert: true,
6528                base_version: Some(1),
6529                values: Some(Map::from_iter([
6530                    ("id".into(), json!("t1")),
6531                    ("title".into(), json!("mine")),
6532                ])),
6533            };
6534            first
6535                .retain_failed_commit("retained", std::slice::from_ref(&operation))
6536                .unwrap();
6537            first
6538                .persist_commit_outcome(
6539                    "retained",
6540                    CommitOutcomeStatus::Rejected,
6541                    &[CommitOperationOutcome::Error {
6542                        rejection: RejectionRecord {
6543                            client_commit_id: "retained".into(),
6544                            op_index: 0,
6545                            code: "validation.denied".into(),
6546                            message: "denied".into(),
6547                            retryable: false,
6548                            details: None,
6549                            operation: None,
6550                        },
6551                    }],
6552                    Some(&[operation]),
6553                )
6554                .unwrap();
6555            first.rebuild_overlay().expect("rebuild overlay");
6556        }
6557        {
6558            let mut reopened = SyncClient::open_path(
6559                "retained-native".to_owned(),
6560                &schema,
6561                ClientLimits::default(),
6562                path.to_str().unwrap(),
6563            )
6564            .unwrap();
6565            let row = reopened
6566                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6567                .expect("read visible row")
6568                .unwrap();
6569            assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
6570            assert_eq!(
6571                reopened
6572                    .commit_outcome("retained")
6573                    .unwrap()
6574                    .unwrap()
6575                    .retained_rows
6576                    .unwrap()[0]
6577                    .server_row
6578                    .as_ref()
6579                    .unwrap()["title"],
6580                json!("server")
6581            );
6582            reopened
6583                .conn
6584                .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
6585                .unwrap();
6586            reopened.rebuild_overlay().expect("rebuild overlay");
6587            assert!(reopened
6588                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6589                .expect("read visible row")
6590                .is_none());
6591            let retained = reopened.commit_outcome("retained").unwrap().unwrap();
6592            assert!(retained.retained_rows.is_some());
6593            assert_eq!(
6594                retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
6595                json!("mine")
6596            );
6597            reopened
6598                .purge_local_data(&LocalDataPurgeInput {
6599                    purge_id: "protected-purge".into(),
6600                    targets: vec![LocalDataPurgeTarget {
6601                        table: "tasks".into(),
6602                        selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
6603                    }],
6604                })
6605                .unwrap();
6606            assert!(reopened
6607                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6608                .expect("read visible row")
6609                .is_none());
6610            assert!(reopened
6611                .commit_outcome("retained")
6612                .unwrap()
6613                .unwrap()
6614                .retained_rows
6615                .is_none());
6616        }
6617        std::fs::remove_file(path).unwrap();
6618    }
6619
6620    #[test]
6621    fn durable_conflict_outcome_and_resolution_survive_reopen() {
6622        let path = std::env::temp_dir().join(format!(
6623            "syncular-durable-outcome-{}.db",
6624            uuid::Uuid::new_v4()
6625        ));
6626        let schema = json!({
6627            "version": 1,
6628            "tables": [{
6629                "name": "tasks",
6630                "primaryKey": "id",
6631                "columns": [
6632                    { "name": "id", "type": "string", "nullable": false },
6633                    { "name": "project_id", "type": "string", "nullable": false }
6634                ],
6635                "scopes": [{ "pattern": "project:{project_id}" }]
6636            }]
6637        });
6638        let conflict = ConflictRecord {
6639            client_commit_id: "losing-commit".to_owned(),
6640            op_index: 0,
6641            table: "tasks".to_owned(),
6642            row_id: "t1".to_owned(),
6643            code: "sync.version_conflict".to_owned(),
6644            message: "stale base version".to_owned(),
6645            server_version: 2,
6646            server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
6647            conflict_columns: vec!["project_id".to_owned()],
6648            operation: Some(CommitOperation {
6649                table: "tasks".to_owned(),
6650                row_id: "t1".to_owned(),
6651                op: "upsert".to_owned(),
6652                base_version: Some(1),
6653                values: Some(Map::from_iter([
6654                    ("id".into(), json!("t1")),
6655                    ("project_id".into(), json!("p1")),
6656                ])),
6657            }),
6658        };
6659        let failed_operations = vec![
6660            OutboxOp {
6661                upsert: true,
6662                table: "tasks".to_owned(),
6663                row_id: "t1".to_owned(),
6664                base_version: Some(1),
6665                values: Some(Map::from_iter([
6666                    ("id".into(), json!("t1")),
6667                    ("project_id".into(), json!("p1")),
6668                ])),
6669            },
6670            OutboxOp {
6671                upsert: true,
6672                table: "tasks".to_owned(),
6673                row_id: "status-event-1".to_owned(),
6674                base_version: Some(0),
6675                values: Some(Map::from_iter([
6676                    ("id".into(), json!("status-event-1")),
6677                    ("project_id".into(), json!("p1")),
6678                ])),
6679            },
6680        ];
6681
6682        {
6683            let mut first = SyncClient::open_path(
6684                "durable-native".to_owned(),
6685                &schema,
6686                ClientLimits::default(),
6687                path.to_str().expect("UTF-8 temp path"),
6688            )
6689            .expect("first open");
6690            first
6691                .begin_observation("test_outcome")
6692                .expect("begin outcome");
6693            first
6694                .persist_commit_outcome(
6695                    "losing-commit",
6696                    CommitOutcomeStatus::Conflict,
6697                    &[CommitOperationOutcome::Conflict {
6698                        conflict: conflict.clone(),
6699                    }],
6700                    Some(&failed_operations),
6701                )
6702                .expect("persist outcome");
6703            first.conflicts.push(conflict);
6704            first
6705                .finish_observation(
6706                    "test_outcome",
6707                    ChangeAccumulator {
6708                        conflicts: true,
6709                        outcomes: true,
6710                        ..ChangeAccumulator::default()
6711                    },
6712                )
6713                .expect("commit outcome");
6714        }
6715
6716        {
6717            let mut reopened = SyncClient::open_path(
6718                "durable-native".to_owned(),
6719                &schema,
6720                ClientLimits::default(),
6721                path.to_str().expect("UTF-8 temp path"),
6722            )
6723            .expect("reopen");
6724            assert_eq!(reopened.conflicts().len(), 1);
6725            assert_eq!(
6726                reopened.conflicts()[0].conflict_columns,
6727                vec!["project_id".to_owned()],
6728                "conflictColumns survives the journal round trip"
6729            );
6730            let outcome = reopened
6731                .commit_outcome("losing-commit")
6732                .expect("read outcome")
6733                .expect("outcome");
6734            assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
6735            let operations = outcome.operations.expect("aggregate envelope");
6736            assert_eq!(operations.len(), 2);
6737            assert_eq!(operations[1].row_id, "status-event-1");
6738            let resolved = reopened
6739                .resolve_commit_outcome(ResolveCommitOutcomeInput {
6740                    client_commit_id: "losing-commit".to_owned(),
6741                    resolution: CommitOutcomeResolution::ResolvedKeepServer,
6742                    replacement_client_commit_id: None,
6743                })
6744                .expect("resolve");
6745            assert_eq!(
6746                resolved.resolution,
6747                CommitOutcomeResolution::ResolvedKeepServer
6748            );
6749            assert!(reopened.conflicts().is_empty());
6750        }
6751
6752        let reopened = SyncClient::open_path(
6753            "durable-native".to_owned(),
6754            &schema,
6755            ClientLimits::default(),
6756            path.to_str().expect("UTF-8 temp path"),
6757        )
6758        .expect("second reopen");
6759        assert!(reopened.conflicts().is_empty());
6760        assert_eq!(
6761            reopened
6762                .commit_outcome("losing-commit")
6763                .expect("read outcome")
6764                .expect("outcome")
6765                .resolution,
6766            CommitOutcomeResolution::ResolvedKeepServer
6767        );
6768        drop(reopened);
6769        std::fs::remove_file(path).expect("remove temp database");
6770    }
6771
6772    #[test]
6773    fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
6774        let path =
6775            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6776        let schema = json!({
6777            "version": 1,
6778            "tables": [{
6779                "name": "tasks",
6780                "primaryKey": "id",
6781                "columns": [
6782                    { "name": "id", "type": "string", "nullable": false },
6783                    { "name": "project_id", "type": "string", "nullable": false }
6784                ],
6785                "scopes": [{ "pattern": "project:{project_id}" }]
6786            }]
6787        });
6788        let mut client = SyncClient::open_path_with_identity(
6789            Some("sidecar-client".to_owned()),
6790            &schema,
6791            ClientLimits::default(),
6792            path.to_str().expect("UTF-8 temp path"),
6793        )
6794        .expect("open owner");
6795        let base = WindowBase {
6796            table: "tasks".to_owned(),
6797            variable: "project_id".to_owned(),
6798            fixed_scopes: Vec::new(),
6799            params: None,
6800        };
6801        client
6802            .set_window(&base, &["one".to_owned()])
6803            .expect("set window");
6804        client
6805            .mutate(vec![Mutation::Upsert {
6806                table: "tasks".to_owned(),
6807                values: Map::from_iter([
6808                    ("id".to_owned(), Value::from("t1")),
6809                    ("project_id".to_owned(), Value::from("one")),
6810                ]),
6811                base_version: None,
6812            }])
6813            .expect("local mutate");
6814
6815        let coverage = [WindowCoverage {
6816            base,
6817            units: vec!["one".to_owned(), "missing".to_owned()],
6818        }];
6819        let owner = client
6820            .query_snapshot(
6821                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
6822                &[],
6823                &coverage,
6824                None,
6825            )
6826            .expect("owner snapshot");
6827        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6828        let sidecar = reader
6829            .query_snapshot(
6830                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
6831                &[],
6832                &coverage,
6833            )
6834            .expect("sidecar snapshot");
6835
6836        assert_eq!(sidecar.revision, owner.revision);
6837        assert_eq!(sidecar.rows, owner.rows);
6838        assert_eq!(
6839            serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
6840            serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
6841        );
6842        assert_eq!(sidecar.revision, "2");
6843        assert_eq!(sidecar.rows[0]["id"], "t1");
6844        assert_eq!(sidecar.rows[0]["server_version"], -1);
6845        assert!(!sidecar.coverage.complete);
6846        assert_eq!(sidecar.coverage.pending.len(), 1);
6847        assert_eq!(sidecar.coverage.missing.len(), 1);
6848
6849        drop(reader);
6850        drop(client);
6851        std::fs::remove_file(path).expect("remove temp database");
6852    }
6853
6854    #[test]
6855    fn file_snapshot_reader_reports_catchup_delivery_and_multi_statement() {
6856        let path =
6857            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6858        let schema = json!({
6859            "version": 1,
6860            "tables": [{
6861                "name": "tasks",
6862                "primaryKey": "id",
6863                "columns": [
6864                    { "name": "id", "type": "string", "nullable": false },
6865                    { "name": "project_id", "type": "string", "nullable": false }
6866                ],
6867                "scopes": [{ "pattern": "project:{project_id}" }]
6868            }]
6869        });
6870        let mut client = SyncClient::open_path_with_identity(
6871            Some("sidecar-catchup".to_owned()),
6872            &schema,
6873            ClientLimits::default(),
6874            path.to_str().expect("UTF-8 temp path"),
6875        )
6876        .expect("open owner");
6877        client
6878            .subscribe(
6879                "s1".into(),
6880                "tasks".into(),
6881                vec![("project_id".into(), vec!["one".into()])],
6882                None,
6883            )
6884            .expect("subscribe");
6885        let commit_id = client
6886            .mutate(vec![Mutation::Upsert {
6887                table: "tasks".to_owned(),
6888                values: Map::from_iter([
6889                    ("id".to_owned(), Value::from("t1")),
6890                    ("project_id".to_owned(), Value::from("one")),
6891                ]),
6892                base_version: None,
6893            }])
6894            .expect("mutate");
6895        let revision = client.local_revision().to_string();
6896
6897        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6898        assert_eq!(reader.local_revision().expect("revision"), revision);
6899
6900        let read = reader
6901            .snapshot_read(&SnapshotReadRequest {
6902                statements: vec![
6903                    SnapshotStatement {
6904                        sql: "SELECT 1 AS n",
6905                        params: &[],
6906                    },
6907                    SnapshotStatement {
6908                        sql: "SELECT id FROM tasks ORDER BY id",
6909                        params: &[],
6910                    },
6911                ],
6912                coverage: &[],
6913                subscriptions: vec!["s1".to_owned(), "absent".to_owned()],
6914                commit_ids: vec![commit_id.clone(), "never-known".to_owned()],
6915            })
6916            .expect("snapshot read");
6917        assert_eq!(read.revision, revision);
6918        assert_eq!(read.queries.len(), 2);
6919        assert_eq!(read.queries[0][0]["n"], 1);
6920        assert_eq!(read.queries[1][0]["id"], "t1");
6921        match &read.subscriptions[0] {
6922            SubscriptionCatchup::Known {
6923                status,
6924                cursor,
6925                bootstrap_complete,
6926                known_pending_pages,
6927                ..
6928            } => {
6929                assert_eq!(status, "active");
6930                assert_eq!(*cursor, -1);
6931                assert!(!bootstrap_complete);
6932                assert!(known_pending_pages);
6933            }
6934            other => panic!("expected a known subscription: {other:?}"),
6935        }
6936        assert!(matches!(
6937            &read.subscriptions[1],
6938            SubscriptionCatchup::Unknown { id } if id.as_str() == "absent"
6939        ));
6940        assert!(matches!(
6941            &read.deliveries[0],
6942            CommitDelivery::Pending { client_commit_id } if client_commit_id == &commit_id
6943        ));
6944        assert!(matches!(
6945            &read.deliveries[1],
6946            CommitDelivery::Unknown { client_commit_id } if client_commit_id.as_str() == "never-known"
6947        ));
6948
6949        // A bad statement rolls the whole read back and releases the connection.
6950        assert!(reader
6951            .query_snapshot("SELECT * FROM does_not_exist", &[], &[])
6952            .is_err());
6953        assert_eq!(
6954            reader.local_revision().expect("revision after rollback"),
6955            revision
6956        );
6957        // The read-only guard refuses a write on any statement.
6958        assert!(reader
6959            .query_snapshot("DELETE FROM tasks", &[], &[])
6960            .is_err());
6961
6962        // A corrupt revision marker fails typed, not as an invented zero.
6963        client
6964            .conn
6965            .execute(
6966                "UPDATE _syncular_meta SET value = '+1' WHERE key = 'localRevision'",
6967                [],
6968            )
6969            .expect("corrupt revision");
6970        let failure = reader.local_revision().expect_err("corrupt revision fails");
6971        assert_eq!(failure.code, Some("sync.local_corrupt"));
6972
6973        drop(reader);
6974        drop(client);
6975        std::fs::remove_file(path).expect("remove temp database");
6976    }
6977
6978    #[test]
6979    fn snapshot_delivery_is_the_persisted_journal_view_without_retained_rows() {
6980        let path =
6981            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6982        let schema = json!({
6983            "version": 1,
6984            "tables": [{
6985                "name": "tasks",
6986                "primaryKey": "id",
6987                "columns": [
6988                    { "name": "id", "type": "string", "nullable": false },
6989                    { "name": "project_id", "type": "string", "nullable": false }
6990                ],
6991                "scopes": [{ "pattern": "project:{project_id}" }]
6992            }]
6993        });
6994        let mut client = SyncClient::open_path_with_identity(
6995            Some("sidecar-delivery".to_owned()),
6996            &schema,
6997            ClientLimits::default(),
6998            path.to_str().expect("UTF-8 temp path"),
6999        )
7000        .expect("open owner");
7001        client.begin_observation("test_delivery").expect("observe");
7002        let operations = [OutboxOp {
7003            upsert: true,
7004            table: "tasks".to_owned(),
7005            row_id: "t1".to_owned(),
7006            base_version: Some(1),
7007            values: Some(Map::from_iter([
7008                ("id".into(), json!("t1")),
7009                ("project_id".into(), json!("p1")),
7010            ])),
7011        }];
7012        client
7013            .persist_commit_outcome(
7014                "conflict-commit",
7015                CommitOutcomeStatus::Conflict,
7016                &[CommitOperationOutcome::Conflict {
7017                    conflict: ConflictRecord {
7018                        client_commit_id: "conflict-commit".to_owned(),
7019                        op_index: 0,
7020                        table: "tasks".to_owned(),
7021                        row_id: "t1".to_owned(),
7022                        code: "sync.version_conflict".to_owned(),
7023                        message: "stale base version".to_owned(),
7024                        server_version: 2,
7025                        server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
7026                        conflict_columns: vec!["project_id".to_owned()],
7027                        operation: None,
7028                    },
7029                }],
7030                Some(&operations),
7031            )
7032            .expect("persist conflict");
7033        client
7034            .persist_commit_outcome(
7035                "rejected-commit",
7036                CommitOutcomeStatus::Rejected,
7037                &[CommitOperationOutcome::Error {
7038                    rejection: RejectionRecord {
7039                        client_commit_id: "rejected-commit".to_owned(),
7040                        op_index: 0,
7041                        code: "sync.unknown_table".to_owned(),
7042                        message: "unknown table".to_owned(),
7043                        retryable: false,
7044                        details: None,
7045                        operation: None,
7046                    },
7047                }],
7048                Some(&operations),
7049            )
7050            .expect("persist rejection");
7051        client
7052            .finish_observation(
7053                "test_delivery",
7054                ChangeAccumulator {
7055                    conflicts: true,
7056                    outcomes: true,
7057                    ..ChangeAccumulator::default()
7058                },
7059            )
7060            .expect("commit outcomes");
7061
7062        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7063        match reader.commit_delivery("conflict-commit").expect("conflict") {
7064            CommitDelivery::Known { outcome, .. } => {
7065                assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
7066                assert!(matches!(
7067                    outcome.results.as_slice(),
7068                    [CommitOperationOutcome::Conflict { conflict }]
7069                        if conflict.code == "sync.version_conflict"
7070                            && conflict.server_version == 2
7071                ));
7072                assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
7073                assert!(
7074                    outcome.retained_rows.is_none(),
7075                    "the sidecar reports the persisted view, not owner-derived retained rows"
7076                );
7077            }
7078            other => panic!("expected a known conflict: {other:?}"),
7079        }
7080        match reader
7081            .commit_delivery("rejected-commit")
7082            .expect("rejection")
7083        {
7084            CommitDelivery::Known { outcome, .. } => {
7085                assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
7086                assert!(matches!(
7087                    outcome.results.as_slice(),
7088                    [CommitOperationOutcome::Error { rejection }]
7089                        if rejection.code == "sync.unknown_table"
7090                ));
7091                assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
7092                assert!(outcome.retained_rows.is_none());
7093            }
7094            other => panic!("expected a known rejection: {other:?}"),
7095        }
7096        drop(reader);
7097        drop(client);
7098        std::fs::remove_file(path).expect("remove temp database");
7099    }
7100
7101    #[test]
7102    fn snapshot_read_isolates_a_concurrent_writer_commit() {
7103        use std::sync::mpsc::channel;
7104        let path = std::env::temp_dir().join(format!(
7105            "syncular-read-concurrent-{}.db",
7106            uuid::Uuid::new_v4()
7107        ));
7108        let schema = json!({
7109            "version": 1,
7110            "tables": [{
7111                "name": "tasks",
7112                "primaryKey": "id",
7113                "columns": [
7114                    { "name": "id", "type": "string", "nullable": false },
7115                    { "name": "project_id", "type": "string", "nullable": false }
7116                ],
7117                "scopes": [{ "pattern": "project:{project_id}" }]
7118            }]
7119        });
7120        let mut client = SyncClient::open_path_with_identity(
7121            Some("sidecar-concurrent".to_owned()),
7122            &schema,
7123            ClientLimits::default(),
7124            path.to_str().expect("UTF-8 temp path"),
7125        )
7126        .expect("open owner");
7127        client
7128            .mutate(vec![Mutation::Upsert {
7129                table: "tasks".to_owned(),
7130                values: Map::from_iter([
7131                    ("id".to_owned(), Value::from("t1")),
7132                    ("project_id".to_owned(), Value::from("one")),
7133                ]),
7134                base_version: None,
7135            }])
7136            .expect("seed row");
7137        let revision_before = client.local_revision().to_string();
7138
7139        // A writer on a second connection commits while the reader is between
7140        // statements; the hook rendezvous is a barrier, not a sleep.
7141        let (ready_tx, ready_rx) = channel::<()>();
7142        let (done_tx, done_rx) = channel::<()>();
7143        let writer_path = path.to_string_lossy().into_owned();
7144        let writer_schema = schema.clone();
7145        let writer = std::thread::spawn(move || {
7146            let mut writer = SyncClient::open_path_with_identity(
7147                Some("sidecar-concurrent".to_owned()),
7148                &writer_schema,
7149                ClientLimits::default(),
7150                &writer_path,
7151            )
7152            .expect("open writer");
7153            ready_rx.recv().expect("reader signalled");
7154            writer
7155                .mutate(vec![Mutation::Upsert {
7156                    table: "tasks".to_owned(),
7157                    values: Map::from_iter([
7158                        ("id".to_owned(), Value::from("t2")),
7159                        ("project_id".to_owned(), Value::from("one")),
7160                    ]),
7161                    base_version: None,
7162                }])
7163                .expect("writer commit");
7164            let _ = done_tx.send(());
7165        });
7166        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7167        SNAPSHOT_READ_HOOK.with(|slot| {
7168            *slot.borrow_mut() = Some(Box::new(move || {
7169                ready_tx.send(()).expect("signal writer");
7170                done_rx.recv().expect("writer finished");
7171            }));
7172        });
7173        let read = reader
7174            .snapshot_read(&SnapshotReadRequest {
7175                statements: vec![
7176                    SnapshotStatement {
7177                        sql: "SELECT count(*) AS n FROM tasks",
7178                        params: &[],
7179                    },
7180                    SnapshotStatement {
7181                        sql: "SELECT count(*) AS n FROM tasks",
7182                        params: &[],
7183                    },
7184                ],
7185                coverage: &[],
7186                subscriptions: Vec::new(),
7187                commit_ids: Vec::new(),
7188            })
7189            .expect("snapshot read");
7190        assert_eq!(
7191            read.queries[0][0]["n"], 1,
7192            "first statement sees the pre-commit row"
7193        );
7194        assert_eq!(
7195            read.queries[1][0]["n"], 1,
7196            "a later statement keeps the one read snapshot"
7197        );
7198        assert_eq!(
7199            read.revision, revision_before,
7200            "the read reports the snapshot revision, not the writer's"
7201        );
7202        writer.join().expect("join writer");
7203
7204        assert_ne!(
7205            reader.local_revision().expect("revision after commit"),
7206            revision_before,
7207            "the next read observes the committed revision"
7208        );
7209        let after = reader
7210            .query_snapshot("SELECT count(*) AS n FROM tasks", &[], &[])
7211            .expect("count after commit");
7212        assert_eq!(
7213            after.rows[0]["n"], 2,
7214            "the committed row is visible afterwards"
7215        );
7216
7217        drop(reader);
7218        drop(client);
7219        std::fs::remove_file(path).expect("remove temp database");
7220    }
7221
7222    #[test]
7223    fn snapshot_read_rejects_every_non_canonical_revision() {
7224        let path = std::env::temp_dir().join(format!(
7225            "syncular-read-revision-{}.db",
7226            uuid::Uuid::new_v4()
7227        ));
7228        let schema = json!({
7229            "version": 1,
7230            "tables": [{
7231                "name": "tasks",
7232                "primaryKey": "id",
7233                "columns": [
7234                    { "name": "id", "type": "string", "nullable": false },
7235                    { "name": "project_id", "type": "string", "nullable": false }
7236                ],
7237                "scopes": [{ "pattern": "project:{project_id}" }]
7238            }]
7239        });
7240        let client = SyncClient::open_path_with_identity(
7241            Some("sidecar-revision".to_owned()),
7242            &schema,
7243            ClientLimits::default(),
7244            path.to_str().expect("UTF-8 temp path"),
7245        )
7246        .expect("open owner");
7247        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7248        for bad in [
7249            "+1",
7250            "01",
7251            "-1",
7252            " 1",
7253            "1 ",
7254            "18446744073709551616",
7255            "1.0",
7256            "abc",
7257            "",
7258        ] {
7259            client
7260                .conn
7261                .execute(
7262                    "UPDATE _syncular_meta SET value = ?1 WHERE key = 'localRevision'",
7263                    rusqlite::params![bad],
7264                )
7265                .expect("corrupt revision");
7266            let failure = reader.local_revision().expect_err("corrupt marker fails");
7267            assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad:?}");
7268        }
7269        for value in ["X'31'", "CAST(X'FF' AS TEXT)"] {
7270            client
7271                .conn
7272                .execute_batch(&format!(
7273                    "UPDATE _syncular_meta SET value={value} WHERE key='localRevision'"
7274                ))
7275                .expect("corrupt stored revision type");
7276            assert_eq!(
7277                reader.local_revision().expect_err("invalid SQL field").code,
7278                Some("sync.local_corrupt")
7279            );
7280        }
7281        // The absent marker is the legacy zero, not corruption.
7282        client
7283            .conn
7284            .execute("DELETE FROM _syncular_meta WHERE key = 'localRevision'", [])
7285            .expect("delete marker");
7286        assert_eq!(reader.local_revision().expect("legacy zero"), "0");
7287
7288        drop(reader);
7289        drop(client);
7290        std::fs::remove_file(path).expect("remove temp database");
7291    }
7292
7293    #[test]
7294    fn snapshot_read_rejects_corrupt_subscription_state() {
7295        let path = std::env::temp_dir().join(format!(
7296            "syncular-read-substate-{}.db",
7297            uuid::Uuid::new_v4()
7298        ));
7299        let schema = json!({
7300            "version": 1,
7301            "tables": [{
7302                "name": "tasks",
7303                "primaryKey": "id",
7304                "columns": [
7305                    { "name": "id", "type": "string", "nullable": false },
7306                    { "name": "project_id", "type": "string", "nullable": false }
7307                ],
7308                "scopes": [{ "pattern": "project:{project_id}" }]
7309            }]
7310        });
7311        let mut client = SyncClient::open_path_with_identity(
7312            Some("sidecar-substate".to_owned()),
7313            &schema,
7314            ClientLimits::default(),
7315            path.to_str().expect("UTF-8 temp path"),
7316        )
7317        .expect("open owner");
7318        client
7319            .subscribe(
7320                "s1".into(),
7321                "tasks".into(),
7322                vec![("project_id".into(), vec!["one".into()])],
7323                None,
7324            )
7325            .expect("subscribe");
7326        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7327        for bad in [
7328            "not json".to_owned(),
7329            "{}".to_owned(),
7330            json!({ "status": "bogus", "cursor": 0 }).to_string(),
7331            json!({ "status": "active", "cursor": "x" }).to_string(),
7332            json!({ "status": "active", "cursor": 0, "bootstrapState": 7 }).to_string(),
7333            json!({ "status": "active", "cursor": 0, "reasonCode": 7 }).to_string(),
7334        ] {
7335            client
7336                .conn
7337                .execute(
7338                    "UPDATE _syncular_subscriptions SET state_json = ?1 WHERE id = 's1'",
7339                    rusqlite::params![bad],
7340                )
7341                .expect("corrupt subscription state");
7342            let failure = reader
7343                .subscription_catchup("s1")
7344                .expect_err("corrupt state fails");
7345            assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad}");
7346        }
7347
7348        for assignment in [
7349            "tbl=X'7461736b73'",
7350            "state_json=X'7b7d'",
7351            "state_json=CAST(X'FF' AS TEXT)",
7352        ] {
7353            client.conn.execute_batch(&format!(
7354                "UPDATE _syncular_subscriptions SET tbl='tasks',state_json='{{\"status\":\"active\",\"cursor\":0}}'; UPDATE _syncular_subscriptions SET {assignment} WHERE id='s1'"
7355            )).expect("corrupt stored subscription type");
7356            assert_eq!(
7357                reader
7358                    .subscription_catchup("s1")
7359                    .expect_err("invalid SQL field")
7360                    .code,
7361                Some("sync.local_corrupt")
7362            );
7363        }
7364
7365        drop(reader);
7366        drop(client);
7367        std::fs::remove_file(path).expect("remove temp database");
7368    }
7369
7370    #[test]
7371    fn snapshot_read_rolls_back_a_later_invalid_statement_and_guards_every_write() {
7372        let path = std::env::temp_dir().join(format!(
7373            "syncular-read-rollback-{}.db",
7374            uuid::Uuid::new_v4()
7375        ));
7376        let schema = json!({
7377            "version": 1,
7378            "tables": [{
7379                "name": "tasks",
7380                "primaryKey": "id",
7381                "columns": [
7382                    { "name": "id", "type": "string", "nullable": false },
7383                    { "name": "project_id", "type": "string", "nullable": false }
7384                ],
7385                "scopes": [{ "pattern": "project:{project_id}" }]
7386            }]
7387        });
7388        let mut client = SyncClient::open_path_with_identity(
7389            Some("sidecar-rollback".to_owned()),
7390            &schema,
7391            ClientLimits::default(),
7392            path.to_str().expect("UTF-8 temp path"),
7393        )
7394        .expect("open owner");
7395        let commit_id = client
7396            .mutate(vec![Mutation::Upsert {
7397                table: "tasks".to_owned(),
7398                values: Map::from_iter([
7399                    ("id".to_owned(), Value::from("t1")),
7400                    ("project_id".to_owned(), Value::from("one")),
7401                ]),
7402                base_version: None,
7403            }])
7404            .expect("seed row");
7405        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7406        let revision = reader.local_revision().expect("revision");
7407
7408        // The second statement fails: the whole read rolls back and the
7409        // connection releases, so the same reader still works afterwards.
7410        let failure = reader
7411            .snapshot_read(&SnapshotReadRequest {
7412                statements: vec![
7413                    SnapshotStatement {
7414                        sql: "SELECT id FROM tasks",
7415                        params: &[],
7416                    },
7417                    SnapshotStatement {
7418                        sql: "SELECT * FROM does_not_exist",
7419                        params: &[],
7420                    },
7421                ],
7422                coverage: &[],
7423                subscriptions: Vec::new(),
7424                commit_ids: vec![commit_id.clone()],
7425            })
7426            .expect_err("second statement fails");
7427        assert_eq!(failure.code, None);
7428        assert_eq!(
7429            reader.local_revision().expect("revision after rollback"),
7430            revision,
7431            "a rolled-back read consumes no revision"
7432        );
7433        assert!(
7434            !matches!(
7435                reader
7436                    .commit_delivery(&commit_id)
7437                    .expect("delivery after rollback"),
7438                CommitDelivery::Unknown { .. }
7439            ),
7440            "the same reader serves the next read after releasing the connection"
7441        );
7442
7443        // The read-only guard rejects a write in the first position too.
7444        let guard = reader
7445            .snapshot_read(&SnapshotReadRequest {
7446                statements: vec![SnapshotStatement {
7447                    sql: "DELETE FROM tasks",
7448                    params: &[],
7449                }],
7450                coverage: &[],
7451                subscriptions: Vec::new(),
7452                commit_ids: Vec::new(),
7453            })
7454            .expect_err("write guard");
7455        assert!(guard.message.contains("read-only"), "{}", guard.message);
7456
7457        drop(reader);
7458        drop(client);
7459        std::fs::remove_file(path).expect("remove temp database");
7460    }
7461
7462    #[test]
7463    fn owned_query_failures_classify_order_bound_and_clear() {
7464        let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7465            rusqlite::ffi::Error::new(266),
7466            Some("private SQLite prose".to_owned()),
7467        ));
7468        assert_eq!(io.code, Some("client.storage_io"));
7469        assert_eq!(io.sqlite_code, Some(266));
7470        assert_eq!(io.message, "local SQLite storage I/O failed");
7471        let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7472            rusqlite::ffi::Error::new(26),
7473            None,
7474        ));
7475        assert_eq!(corrupt.code, Some("client.storage_corrupt"));
7476        assert!(!io.retryable());
7477        assert!(!corrupt.retryable());
7478
7479        // SQLITE_BUSY (5) and SQLITE_LOCKED (6), including extended codes, are
7480        // transient lock contention: classified busy and retryable.
7481        for (raw, extended) in [(5, 5), (5 | (1 << 8), 261), (6, 6), (6 | (1 << 8), 262)] {
7482            let busy = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7483                rusqlite::ffi::Error::new(raw),
7484                Some("database is locked".to_owned()),
7485            ));
7486            assert_eq!(busy.code, Some("client.storage_busy"));
7487            assert_eq!(busy.sqlite_code, Some(extended));
7488            assert_eq!(busy.message, "local SQLite storage is busy");
7489            assert!(busy.retryable());
7490        }
7491        let unrelated = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7492            rusqlite::ffi::Error::new(1),
7493            Some("generic SQLite prose".to_owned()),
7494        ));
7495        assert_eq!(unrelated.code, None);
7496        assert!(!unrelated.retryable());
7497
7498        let mut client = client();
7499        let generic = QueryReadFailure::from("private query prose".to_owned());
7500        client.record_query_read(
7501            QueryOwner {
7502                id: "first",
7503                tables: &["tasks", "tasks"],
7504            },
7505            Some(&generic),
7506        );
7507        client.record_query_read(
7508            QueryOwner {
7509                id: "second",
7510                tables: &["tasks"],
7511            },
7512            Some(&generic),
7513        );
7514        client.record_query_read(
7515            QueryOwner {
7516                id: "first",
7517                tables: &["docs"],
7518            },
7519            Some(&io),
7520        );
7521        let failures = &client
7522            .diagnostics_snapshot(&Default::default())
7523            .expect("diagnostics")
7524            .query_failures;
7525        assert_eq!(
7526            failures
7527                .iter()
7528                .map(|entry| entry.id.as_str())
7529                .collect::<Vec<_>>(),
7530            vec!["second", "first"]
7531        );
7532        assert_eq!(failures[1].tables, vec!["docs"]);
7533        assert_eq!(failures[1].code, "client.storage_io");
7534
7535        for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
7536            client.record_query_read(
7537                QueryOwner {
7538                    id: &format!("query-{index}"),
7539                    tables: &["tasks"],
7540                },
7541                Some(&generic),
7542            );
7543        }
7544        let failures = &client
7545            .diagnostics_snapshot(&Default::default())
7546            .expect("bounded diagnostics")
7547            .query_failures;
7548        assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
7549        assert_eq!(failures.last().expect("last failure").id, "query-256");
7550
7551        client
7552            .query_snapshot(
7553                "SELECT id FROM tasks",
7554                &[],
7555                &[],
7556                Some(QueryOwner {
7557                    id: "query-256",
7558                    tables: &["tasks"],
7559                }),
7560            )
7561            .expect("successful owned read");
7562        assert!(client
7563            .diagnostics_snapshot(&Default::default())
7564            .expect("cleared diagnostics")
7565            .query_failures
7566            .iter()
7567            .all(|entry| entry.id != "query-256"));
7568    }
7569
7570    #[test]
7571    fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
7572        let mut client = client();
7573        let image = Connection::open_in_memory().unwrap();
7574        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
7575            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7576            INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
7577            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7578            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
7579        let table = client.schema.table("tasks").unwrap().clone();
7580        let scopes = vec![("project_id".into(), vec!["p1".into()])];
7581        assert!(client
7582            .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
7583            .is_err());
7584        assert!(client.conn.is_autocommit());
7585        assert_eq!(
7586            client
7587                .query("SELECT count(*) AS n FROM tasks", &[])
7588                .unwrap()[0]["n"],
7589            1024
7590        );
7591        assert_eq!(client.drain_change_batches().len(), 1);
7592        image
7593            .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
7594            .unwrap();
7595        assert!(matches!(
7596            client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
7597            Ok(2050)
7598        ));
7599        assert_eq!(
7600            client
7601                .query("SELECT count(*) AS n FROM tasks", &[])
7602                .unwrap()[0]["n"],
7603            2050
7604        );
7605        assert_eq!(client.drain_change_batches().len(), 3);
7606    }
7607
7608    #[test]
7609    fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
7610        let mut client = client();
7611        let base = WindowBase {
7612            table: "tasks".into(),
7613            variable: "project_id".into(),
7614            fixed_scopes: Vec::new(),
7615            params: None,
7616        };
7617        client
7618            .set_window(&base, &["p1".into(), "p2".into()])
7619            .unwrap();
7620        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7621            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7622            INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
7623            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7624            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
7625        client.drain_change_batches();
7626        assert!(client.set_window(&base, &["p2".into()]).is_err());
7627        assert!(client.conn.is_autocommit());
7628        assert_eq!(
7629            client
7630                .query("SELECT count(*) AS n FROM tasks", &[])
7631                .unwrap()[0]["n"],
7632            1027
7633        );
7634        assert_eq!(client.window_state(&base).units, vec!["p2"]);
7635        assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
7636        assert_eq!(client.drain_change_batches().len(), 1);
7637        client
7638            .conn
7639            .execute_batch("DROP TRIGGER interrupt_evict")
7640            .unwrap();
7641        client.drain_pending_evictions().unwrap();
7642        assert!(client.load_pending_evictions().unwrap().is_empty());
7643        assert_eq!(
7644            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
7645            "held"
7646        );
7647        assert_eq!(client.drain_change_batches().len(), 2);
7648    }
7649
7650    #[test]
7651    fn eviction_marker_failures_roll_back_window_changes() {
7652        let mut client = client();
7653        let base = WindowBase {
7654            table: "tasks".into(),
7655            variable: "project_id".into(),
7656            fixed_scopes: Vec::new(),
7657            params: None,
7658        };
7659        client.set_window(&base, &["p1".into()]).unwrap();
7660        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7661            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7662            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7663            CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
7664        let revision = client.local_revision();
7665        assert!(client.set_window(&base, &[]).is_err());
7666        assert!(client.conn.is_autocommit());
7667        assert_eq!(client.local_revision(), revision);
7668        assert_eq!(
7669            client
7670                .query("SELECT count(*) AS n FROM tasks", &[])
7671                .unwrap()[0]["n"],
7672            2050
7673        );
7674        assert_eq!(client.window_state(&base).units, vec!["p1"]);
7675        client.conn.execute_batch("DROP TRIGGER fail_marker;
7676            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
7677        assert!(client.set_window(&base, &[]).is_err());
7678        client.conn.execute_batch("DROP TRIGGER interrupt_evict;
7679            CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
7680        assert!(client.drain_pending_evictions().is_err());
7681        assert!(client.conn.is_autocommit());
7682        assert_eq!(
7683            client
7684                .query("SELECT count(*) AS n FROM tasks", &[])
7685                .unwrap()[0]["n"],
7686            2
7687        );
7688        assert!(client.set_window(&base, &["p1".into()]).is_err());
7689        assert!(client.conn.is_autocommit());
7690        assert!(client.window_state(&base).units.is_empty());
7691        assert!(client.subs.is_empty());
7692        assert_eq!(
7693            client
7694                .query(
7695                    "SELECT count(*) AS n FROM _syncular_window_pending_evict",
7696                    &[]
7697                )
7698                .unwrap()[0]["n"],
7699            1
7700        );
7701        client
7702            .conn
7703            .execute_batch("DROP TRIGGER fail_marker")
7704            .unwrap();
7705        client.set_window(&base, &["p1".into()]).unwrap();
7706        assert_eq!(client.window_state(&base).units, vec!["p1"]);
7707    }
7708
7709    #[test]
7710    fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
7711        let mut client = client();
7712        client
7713            .conn
7714            .execute(
7715                "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
7716                [],
7717            )
7718            .unwrap();
7719        assert_eq!(
7720            client.drain_pending_evictions().unwrap_err(),
7721            "sync.local_corrupt: invalid pending eviction scopes"
7722        );
7723        assert!(client.conn.is_autocommit());
7724        assert!(client.enqueue_startup_sync_if_needed().is_err());
7725    }
7726
7727    #[test]
7728    fn image_chunks_reject_null_primary_keys() {
7729        let mut client = client();
7730        let image = Connection::open_in_memory().unwrap();
7731        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7732            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
7733            INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
7734            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7735            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
7736        let table = client.schema.table("tasks").unwrap().clone();
7737        assert!(client
7738            .apply_sqlite_image(
7739                &image,
7740                &table,
7741                true,
7742                &[("project_id".into(), vec!["p1".into()])],
7743                1025,
7744                7,
7745                "digest"
7746            )
7747            .is_err());
7748        assert!(client.conn.is_autocommit());
7749        assert_eq!(
7750            client
7751                .query("SELECT count(*) AS n FROM tasks", &[])
7752                .unwrap()[0]["n"],
7753            0
7754        );
7755    }
7756
7757    #[test]
7758    fn import_chunks_with_unique_constraints_match_full_pending_replay() {
7759        for images in [false, true] {
7760            let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
7761                "name":name,"primaryKey":"id","columns":[
7762                    {"name":"id","type":"string","nullable":false},
7763                    {"name":"project_id","type":"string","nullable":false},
7764                    {"name":"title","type":"string","nullable":false}],
7765                "scopes":[{"pattern":"project:{project_id}"}],
7766                "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
7767                "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
7768            })))});
7769            let mut actual =
7770                SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
7771            let mut reference =
7772                SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
7773            let scopes = vec![("project_id".into(), vec!["p1".into()])];
7774            for client in [&mut actual, &mut reference] {
7775                client
7776                    .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
7777                    .unwrap();
7778                client.subs[0].effective = Some(scopes.clone());
7779                for (table, id, title) in [
7780                    ("tasks", "a", "original"),
7781                    ("tasks", "b", "occupied"),
7782                    ("tasks", "c", "deleted"),
7783                    ("notes", "n", "untouched"),
7784                ] {
7785                    client
7786                        .write_base_row(
7787                            table,
7788                            &vec![
7789                                Some(ColumnValue::String(id.into())),
7790                                Some(ColumnValue::String("p1".into())),
7791                                Some(ColumnValue::String(title.into())),
7792                            ],
7793                            1,
7794                        )
7795                        .unwrap();
7796                }
7797                client.rebuild_overlay_if_dirty().expect("rebuild overlay");
7798                client
7799                    .mutate(vec![
7800                        Mutation::Upsert {
7801                            table: "tasks".into(),
7802                            values: Map::from_iter([
7803                                ("id".into(), Value::from("a")),
7804                                ("project_id".into(), Value::from("p1")),
7805                                ("title".into(), Value::from("available")),
7806                            ]),
7807                            base_version: None,
7808                        },
7809                        Mutation::Delete {
7810                            table: "tasks".into(),
7811                            row_id: "c".into(),
7812                            base_version: None,
7813                        },
7814                        Mutation::Upsert {
7815                            table: "tasks".into(),
7816                            values: Map::from_iter([
7817                                ("id".into(), Value::from("local")),
7818                                ("project_id".into(), Value::from("p1")),
7819                                ("title".into(), Value::from("local value")),
7820                            ]),
7821                            base_version: None,
7822                        },
7823                        Mutation::Upsert {
7824                            table: "notes".into(),
7825                            values: Map::from_iter([
7826                                ("id".into(), Value::from("n")),
7827                                ("project_id".into(), Value::from("p1")),
7828                                ("title".into(), Value::from("pending note")),
7829                            ]),
7830                            base_version: None,
7831                        },
7832                    ])
7833                    .unwrap();
7834            }
7835            let pending = actual.pending_commit_ids();
7836            actual.overlay_rebuild_count.set(0);
7837            for (id, title) in [
7838                ("b", "available"),
7839                ("b", "occupied"),
7840                ("a", "server original"),
7841                ("d", "local value"),
7842                ("d", "released"),
7843                ("c", "redelivered"),
7844            ] {
7845                let table = actual.schema.table("tasks").unwrap().clone();
7846                let values = vec![
7847                    Some(ColumnValue::String(id.into())),
7848                    Some(ColumnValue::String("p1".into())),
7849                    Some(ColumnValue::String(title.into())),
7850                ];
7851                if images {
7852                    let image = Connection::open_in_memory().unwrap();
7853                    image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7854                        CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7855                        INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
7856                    image
7857                        .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
7858                        .unwrap();
7859                    assert!(matches!(
7860                        actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
7861                        Ok(1)
7862                    ));
7863                } else {
7864                    let segment = RowsSegment {
7865                        table: "tasks".into(),
7866                        schema_version: 1,
7867                        columns: table.wire_columns.clone(),
7868                        blocks: vec![vec![ssp2::segment::SegmentRow {
7869                            server_version: 2,
7870                            values: values.clone(),
7871                        }]],
7872                    };
7873                    assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
7874                }
7875                reference.write_base_row("tasks", &values, 2).unwrap();
7876                reference.rebuild_overlay().expect("rebuild overlay");
7877                for table in ["tasks", "notes"] {
7878                    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")] {
7879                        assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
7880                    }
7881                }
7882                assert_eq!(actual.pending_commit_ids(), pending);
7883            }
7884            assert_eq!(
7885                actual.overlay_rebuild_count.get(),
7886                0,
7887                "each chunk must leave unrelated tables alone"
7888            );
7889            let before = actual
7890                .query("SELECT * FROM tasks ORDER BY id", &[])
7891                .unwrap();
7892            let fts_before = actual
7893                .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
7894                .unwrap();
7895            let revision = actual.local_revision();
7896            actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
7897            let table = actual.schema.table("tasks").unwrap().clone();
7898            let segment = RowsSegment {
7899                table: "tasks".into(),
7900                schema_version: 1,
7901                columns: table.wire_columns.clone(),
7902                blocks: vec![vec![ssp2::segment::SegmentRow {
7903                    server_version: 3,
7904                    values: vec![
7905                        Some(ColumnValue::String("b".into())),
7906                        Some(ColumnValue::String("p1".into())),
7907                        Some(ColumnValue::String("fresh".into())),
7908                    ],
7909                }]],
7910            };
7911            assert!(actual.apply_segment(0, &segment, false).is_err());
7912            assert!(actual.conn.is_autocommit());
7913            assert_eq!(
7914                actual
7915                    .query("SELECT * FROM tasks ORDER BY id", &[])
7916                    .unwrap(),
7917                before
7918            );
7919            assert_eq!(
7920                actual
7921                    .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
7922                    .unwrap(),
7923                fts_before
7924            );
7925            assert_eq!(actual.local_revision(), revision);
7926            assert_eq!(actual.pending_commit_ids(), pending);
7927        }
7928    }
7929
7930    #[test]
7931    fn import_reconciliation_preserves_typed_pending_primary_keys() {
7932        for (kind, a, b) in [
7933            ("string", json!("a"), json!("b")),
7934            ("integer", json!(1), json!(2)),
7935            ("boolean", json!(true), json!(false)),
7936            ("json", json!("1"), json!("2")),
7937        ] {
7938            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}]}]});
7939            for images in [false, true] {
7940                let mut client =
7941                    SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
7942                let table = client.schema.table("tasks").unwrap().clone();
7943                client
7944                    .subscribe("tasks".into(), "tasks".into(), vec![], None)
7945                    .unwrap();
7946                for (id, title) in [(&a, "original"), (&b, "occupied")] {
7947                    client
7948                        .write_base_row(
7949                            "tasks",
7950                            &vec![
7951                                json_to_column_value(&table.columns[0], Some(id)).unwrap(),
7952                                Some(ColumnValue::String(title.into())),
7953                            ],
7954                            1,
7955                        )
7956                        .unwrap();
7957                }
7958                client.rebuild_overlay_if_dirty().expect("rebuild overlay");
7959                client
7960                    .mutate(vec![Mutation::Upsert {
7961                        table: "tasks".into(),
7962                        values: Map::from_iter([
7963                            ("id".into(), a.clone()),
7964                            ("title".into(), json!("available")),
7965                        ]),
7966                        base_version: None,
7967                    }])
7968                    .unwrap();
7969                client.overlay_rebuild_count.set(0);
7970                for (title, expected) in [("available", "original"), ("occupied", "available")] {
7971                    let values = vec![
7972                        json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
7973                        Some(ColumnValue::String(title.into())),
7974                    ];
7975                    if images {
7976                        let image = Connection::open_in_memory().unwrap();
7977                        image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7978                            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7979                            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
7980                        image
7981                            .execute(
7982                                "INSERT INTO tasks VALUES (?1,?2,2)",
7983                                rusqlite::params![RowParam::Cell(&values[0]), title],
7984                            )
7985                            .unwrap();
7986                        assert!(
7987                            matches!(
7988                                client.apply_sqlite_image(
7989                                    &image,
7990                                    &table,
7991                                    false,
7992                                    &[],
7993                                    1,
7994                                    7,
7995                                    "digest"
7996                                ),
7997                                Ok(1)
7998                            ),
7999                            "{kind}, images={images}"
8000                        );
8001                    } else {
8002                        let segment = RowsSegment {
8003                            table: "tasks".into(),
8004                            schema_version: 1,
8005                            columns: table.wire_columns.clone(),
8006                            blocks: vec![vec![ssp2::segment::SegmentRow {
8007                                server_version: 2,
8008                                values,
8009                            }]],
8010                        };
8011                        assert!(
8012                            matches!(client.apply_segment(0, &segment, false), Ok(1)),
8013                            "{kind}"
8014                        );
8015                    }
8016                    let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
8017                    let actual: String = client
8018                        .conn
8019                        .query_row(
8020                            "SELECT title FROM tasks WHERE id=?1",
8021                            [RowParam::Cell(&key)],
8022                            |row| row.get(0),
8023                        )
8024                        .unwrap();
8025                    assert_eq!(actual, expected, "{kind}, images={images}");
8026                }
8027                assert_eq!(client.overlay_rebuild_count.get(), 0);
8028            }
8029        }
8030    }
8031
8032    fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
8033        let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
8034            "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
8035            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
8036            "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
8037        })))});
8038        let mut client =
8039            SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
8040        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();
8041        client.rebuild_overlay().expect("rebuild overlay");
8042        client
8043            .mutate(vec![Mutation::Upsert {
8044                table: "tasks".into(),
8045                values: Map::from_iter([
8046                    ("id".into(), json!("offline")),
8047                    ("project_id".into(), json!("p1")),
8048                    ("title".into(), json!("pending edit")),
8049                ]),
8050                base_version: None,
8051            }])
8052            .unwrap();
8053        let image = Connection::open_in_memory().unwrap();
8054        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8055            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
8056        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();
8057        image
8058            .execute(
8059                "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
8060                [rows],
8061            )
8062            .unwrap();
8063        client.overlay_rebuild_count.set(0);
8064        let table = client.schema.table("tasks").unwrap().clone();
8065        (client, image, table)
8066    }
8067
8068    #[test]
8069    fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
8070        let (mut client, image, table) = unique_import_fixture(1);
8071        image
8072            .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
8073            .unwrap();
8074        client
8075            .conn
8076            .execute_batch("PRAGMA max_page_count = 1")
8077            .unwrap();
8078        let effective = [("project_id".into(), vec!["p1".into()])];
8079        assert!(client
8080            .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
8081            .is_err());
8082        let failure = client.storage_failure.borrow().clone().unwrap();
8083        assert_eq!(failure.code, Some("client.storage_full"));
8084        assert_eq!(failure.sqlite_code, Some(13));
8085        assert!(!failure.message.contains("rollback"));
8086        assert!(client.conn.is_autocommit());
8087        client
8088            .conn
8089            .execute_batch("PRAGMA max_page_count = 1073741823")
8090            .unwrap();
8091        assert!(matches!(
8092            client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
8093            Ok(1)
8094        ));
8095        assert!(client.conn.is_autocommit());
8096    }
8097
8098    #[test]
8099    fn authoring_storage_failures_are_structured_and_scoped() {
8100        let path = std::env::temp_dir().join(format!(
8101            "syncular-authoring-failure-{}.db",
8102            uuid::Uuid::new_v4()
8103        ));
8104        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8105            {"name":"id","type":"string","nullable":false},
8106            {"name":"project_id","type":"string","nullable":false},
8107            {"name":"title","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
8108        let mut client = SyncClient::open_path(
8109            "authoring".into(),
8110            &schema,
8111            ClientLimits::default(),
8112            path.to_str().unwrap(),
8113        )
8114        .expect("open client");
8115        client.create_synced_tables().expect("create synced tables");
8116        // Return BUSY immediately rather than waiting out the file timeout.
8117        client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
8118
8119        let upsert = |id: &str| {
8120            vec![Mutation::Upsert {
8121                table: "tasks".into(),
8122                values: Map::from_iter([
8123                    ("id".into(), json!(id)),
8124                    ("project_id".into(), json!("p1")),
8125                    ("title".into(), json!("first")),
8126                ]),
8127                base_version: None,
8128            }]
8129        };
8130
8131        let locker = Connection::open(&path).expect("second connection");
8132        locker.execute_batch("BEGIN IMMEDIATE").unwrap();
8133        let busy = client.mutate(upsert("t1")).expect_err("busy write");
8134        assert_eq!(busy.code, "client.storage_busy");
8135        assert!(busy.retryable);
8136        assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
8137        assert!(client.pending_commit_ids().is_empty());
8138        assert_eq!(client.local_revision(), 0);
8139
8140        // A stale classified failure must not classify a later unrelated one.
8141        let invalid = client.mutate(Vec::new()).expect_err("empty commit");
8142        assert_eq!(invalid.code, "sync.invalid_request");
8143        assert_eq!(invalid.message, "the authoring request is invalid");
8144        assert!(
8145            invalid.details.as_ref().unwrap()["legacyCause"]
8146                .as_str()
8147                .unwrap()
8148                .contains("at least one operation"),
8149            "{invalid:?}"
8150        );
8151
8152        locker.execute_batch("ROLLBACK").unwrap();
8153        let id = client.mutate(upsert("t1")).expect("retry after busy");
8154        assert_eq!(client.pending_commit_ids(), vec![id]);
8155        assert_eq!(client.local_revision(), 1);
8156
8157        // A later storage failure reports its own code and details.
8158        locker.execute_batch("BEGIN IMMEDIATE").unwrap();
8159        let busy_again = client.mutate(upsert("t2")).expect_err("busy write again");
8160        assert_eq!(busy_again.code, "client.storage_busy");
8161        assert!(busy_again.retryable);
8162        locker.execute_batch("ROLLBACK").unwrap();
8163
8164        // Non-retryable classified failures keep their sqlite evidence too.
8165        let full = ClientError::from(QueryReadFailure::from(rusqlite::Error::SqliteFailure(
8166            rusqlite::ffi::Error::new(13),
8167            Some("database or disk is full".to_owned()),
8168        )));
8169        assert_eq!(full.code, "client.storage_full");
8170        assert!(!full.retryable);
8171        assert_eq!(full.details.as_ref().unwrap()["sqliteCode"], 13);
8172
8173        drop(locker);
8174        drop(client);
8175        let _ = std::fs::remove_file(&path);
8176    }
8177
8178    #[test]
8179    fn caller_value_failures_are_invalid_request_and_leave_no_outbox_entry() {
8180        let path = std::env::temp_dir().join(format!(
8181            "syncular-caller-values-{}.db",
8182            uuid::Uuid::new_v4()
8183        ));
8184        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8185            {"name":"id","type":"string","nullable":false},
8186            {"name":"project_id","type":"string","nullable":false},
8187            {"name":"title","type":"string","nullable":false},
8188            {"name":"priority","type":"integer","nullable":false},
8189            {"name":"payload","type":"bytes","nullable":false},
8190            {"name":"extra","type":"bytes","nullable":true}],"scopes":[{"pattern":"project:{project_id}"}]}]});
8191        let mut client = SyncClient::open_path(
8192            "caller-values".into(),
8193            &schema,
8194            ClientLimits::default(),
8195            path.to_str().unwrap(),
8196        )
8197        .expect("open client");
8198        client.create_synced_tables().expect("create synced tables");
8199
8200        let values = |id: &str| {
8201            Map::from_iter([
8202                ("id".to_owned(), json!(id)),
8203                ("project_id".to_owned(), json!("p1")),
8204                ("title".to_owned(), json!("first")),
8205                ("priority".to_owned(), json!(1)),
8206                ("payload".to_owned(), json!({"$bytes": "0a"})),
8207            ])
8208        };
8209        let upsert = |values: Map<String, Value>| {
8210            vec![Mutation::Upsert {
8211                table: "tasks".into(),
8212                values,
8213                base_version: None,
8214            }]
8215        };
8216        let seed = client.mutate(upsert(values("t1"))).expect("seed row");
8217        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
8218
8219        // §6.1/§7.1: every caller value the authoring seam rejects reports the
8220        // same stable identity instead of the internal-failure default, keeps
8221        // the dynamic cause in `details.legacyCause`, and leaves the durable
8222        // outbox, the local revision and the projection untouched.
8223        for (label, override_value, cause) in [
8224            ("unknown column", ("nope", json!(1)), "unknown column"),
8225            (
8226                "internal sync column",
8227                ("_sync_version", json!(1)),
8228                "internal sync column",
8229            ),
8230            ("wrong value type", ("title", json!(7)), "expected a string"),
8231            (
8232                "malformed bytes envelope",
8233                ("payload", json!({"$bytes": "zz"})),
8234                "expected exactly",
8235            ),
8236            (
8237                "odd-length bytes envelope",
8238                ("payload", json!({"$bytes": "abc"})),
8239                "expected exactly",
8240            ),
8241            (
8242                "plus-sign bytes envelope",
8243                ("payload", json!({"$bytes": "+a"})),
8244                "expected exactly",
8245            ),
8246            (
8247                "bytes envelope with a second key",
8248                ("payload", json!({"$bytes": "0a", "extra": 1})),
8249                "expected exactly",
8250            ),
8251            (
8252                "no bytes envelope",
8253                ("payload", json!({"nested": true})),
8254                "expected exactly",
8255            ),
8256        ] {
8257            let mut full = values("t2");
8258            full.insert(override_value.0.to_owned(), override_value.1);
8259            let error = client.mutate(upsert(full)).expect_err(label);
8260            assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
8261            assert_eq!(error.message, "the authoring request is invalid", "{label}");
8262            assert!(!error.retryable, "{label}");
8263            let legacy = error.details.as_ref().unwrap()["legacyCause"]
8264                .as_str()
8265                .unwrap();
8266            assert!(
8267                legacy.starts_with("sync.invalid_request: ") && legacy.contains(cause),
8268                "{label}: {legacy}"
8269            );
8270            assert_eq!(client.pending_commit_ids(), vec![seed.clone()], "{label}");
8271            assert_eq!(client.local_revision(), 1, "{label}");
8272            assert_eq!(
8273                client.query("SELECT * FROM tasks", &[]).unwrap(),
8274                rows,
8275                "{label}"
8276            );
8277        }
8278
8279        // A full-row upsert presents every column, so a missing required one is
8280        // the caller's failure too.
8281        let mut missing = values("t3");
8282        missing.remove("payload");
8283        let missing_error = client.mutate(upsert(missing)).expect_err("missing column");
8284        assert_eq!(missing_error.code, "sync.invalid_request");
8285        assert!(missing_error.details.as_ref().unwrap()["legacyCause"]
8286            .as_str()
8287            .unwrap()
8288            .contains("is not nullable"));
8289
8290        // A primary key the wire cannot render is rejected before any op is
8291        // recorded.
8292        let mut bad_row_id = values("t4");
8293        bad_row_id.insert("id".to_owned(), json!({"nested": true}));
8294        let row_id_error = client.mutate(upsert(bad_row_id)).expect_err("bad rowId");
8295        assert_eq!(row_id_error.code, "sync.invalid_request");
8296
8297        // The sparse route classifies the same value failures.
8298        for (label, partial, cause) in [
8299            (
8300                "patch wrong value type",
8301                ("priority", json!("high")),
8302                "expected an integer",
8303            ),
8304            ("patch unknown column", ("nope", json!(1)), "unknown column"),
8305        ] {
8306            let error = client
8307                .patch(
8308                    "tasks",
8309                    "t1",
8310                    Map::from_iter([(partial.0.to_owned(), partial.1)]),
8311                    None,
8312                )
8313                .expect_err(label);
8314            assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
8315            assert_eq!(error.message, "the authoring request is invalid", "{label}");
8316            assert!(
8317                error.details.as_ref().unwrap()["legacyCause"]
8318                    .as_str()
8319                    .unwrap()
8320                    .contains(cause),
8321                "{label}"
8322            );
8323        }
8324        assert_eq!(client.pending_commit_ids(), vec![seed.clone()]);
8325        assert_eq!(client.local_revision(), 1);
8326        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
8327
8328        // Nullable explicit null, an empty envelope, and uppercase digits stay
8329        // accepted; the stored payload is the decoded byte sequence.
8330        for (label, payload) in [
8331            ("explicit null", json!(null)),
8332            ("empty envelope", json!({"$bytes": ""})),
8333            ("uppercase envelope", json!({"$bytes": "0AFF"})),
8334        ] {
8335            let mut accepted = values("t-ok");
8336            accepted.insert("extra".to_owned(), payload);
8337            let encoded = client.mutate(upsert(accepted)).unwrap_or_else(|error| {
8338                panic!("{label} must be accepted: {error:?}");
8339            });
8340            assert!(!encoded.is_empty(), "{label}");
8341        }
8342
8343        // Nothing is poisoned: the next valid authoring call succeeds.
8344        let next = client
8345            .mutate(upsert(values("t5")))
8346            .expect("valid after failures");
8347        assert_ne!(next, seed);
8348
8349        drop(client);
8350        let _ = std::fs::remove_file(&path);
8351    }
8352
8353    #[test]
8354    fn a_legacy_outbox_value_the_codec_refuses_is_dropped_instead_of_wedging() {
8355        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8356            {"name":"id","type":"string","nullable":false},
8357            {"name":"project_id","type":"string","nullable":false},
8358            {"name":"payload","type":"bytes","nullable":true}],
8359            "scopes":[{"pattern":"project:{project_id}"}]}]});
8360        let open = |path: &std::path::Path| {
8361            SyncClient::open_path(
8362                "legacy-values".into(),
8363                &schema,
8364                ClientLimits::default(),
8365                path.to_str().unwrap(),
8366            )
8367            .expect("open client")
8368        };
8369        let row = |id: &str| {
8370            Map::from_iter([
8371                ("id".to_owned(), json!(id)),
8372                ("project_id".to_owned(), json!("p1")),
8373                ("payload".to_owned(), json!({"$bytes": "0a"})),
8374            ])
8375        };
8376        let upsert = |values| {
8377            vec![Mutation::Upsert {
8378                table: "tasks".into(),
8379                values,
8380                base_version: None,
8381            }]
8382        };
8383        for (label, stored_cell, representable) in [
8384            ("plus-sign envelope", json!({"$bytes": "+a"}), false),
8385            ("non-hex envelope", json!({"$bytes": "zz"}), false),
8386            ("odd-length envelope", json!({"$bytes": "abc"}), false),
8387            ("no envelope", json!({"nested": true}), false),
8388            // A wrong declared type and a stored null for a non-nullable column
8389            // are journalable as stored, so their rejection keeps the operation
8390            // envelope.
8391            ("wrong declared type", json!("high"), true),
8392            ("stored null for a non-nullable column", json!(null), true),
8393        ] {
8394            let path = std::env::temp_dir().join(format!(
8395                "syncular-legacy-values-{}-{}.db",
8396                label.replace(' ', "-"),
8397                uuid::Uuid::new_v4()
8398            ));
8399            let mut client = open(&path);
8400            client.create_synced_tables().expect("create synced tables");
8401            let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
8402            let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8403            drop(client);
8404
8405            // The stored intent an earlier version accepted.
8406            let connection = Connection::open(&path).expect("raw connection");
8407            let (column, cell) = if stored_cell.is_null() {
8408                ("project_id", stored_cell.clone())
8409            } else {
8410                ("payload", stored_cell.clone())
8411            };
8412            let mut planted_values = json!({
8413                "id": "t1", "project_id": "p1", "payload": {"$bytes": "0a"}
8414            });
8415            planted_values[column] = cell;
8416            let planted = json!([{
8417                "op": "upsert",
8418                "table": "tasks",
8419                "rowId": "t1",
8420                "baseVersion": null,
8421                "values": planted_values
8422            }]);
8423            connection
8424                .execute(
8425                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8426                    rusqlite::params![planted.to_string(), stale],
8427                )
8428                .expect("plant legacy intent");
8429            drop(connection);
8430
8431            // Reopen: the startup reconciliation recovers the commit before any
8432            // replay, so the observable phase matches the browser core.
8433            let reopened = open(&path);
8434            assert_eq!(
8435                reopened.pending_commit_ids(),
8436                vec![successor.clone()],
8437                "{label}"
8438            );
8439            let rejection = reopened
8440                .rejections()
8441                .iter()
8442                .find(|rejection| rejection.client_commit_id == stale)
8443                .unwrap_or_else(|| panic!("{label}: rejection"));
8444            assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8445            assert_eq!(
8446                rejection.message, "the persisted commit carries values the current codec refuses",
8447                "{label}"
8448            );
8449            assert_eq!(rejection.operation.is_some(), representable, "{label}");
8450            assert_eq!(
8451                rejection
8452                    .details
8453                    .as_ref()
8454                    .and_then(|details| details.reason.as_deref()),
8455                Some(INVALID_STORED_VALUES_REASON),
8456                "{label}"
8457            );
8458            // The rejection stays readable after a further reopen.
8459            let journal = reopened
8460                .commit_outcome(&stale)
8461                .expect("read outcome")
8462                .expect("journal entry");
8463            assert!(matches!(journal.status, CommitOutcomeStatus::Rejected));
8464            drop(reopened);
8465            let mut reopened = open(&path);
8466            let reread = reopened
8467                .commit_outcome(&stale)
8468                .expect("read outcome")
8469                .expect("journal entry");
8470            assert!(matches!(reread.status, CommitOutcomeStatus::Rejected));
8471
8472            // The legacy row's optimistic projection is gone, the successor
8473            // survives and still encodes, and the round path keeps the queue.
8474            assert_eq!(
8475                reopened
8476                    .query("SELECT id FROM tasks ORDER BY id", &[])
8477                    .unwrap(),
8478                vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8479                "{label}"
8480            );
8481            assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8482            assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
8483            assert!(!reopened
8484                .drop_unencodable_outbox(false, None)
8485                .expect("idempotent prepass"));
8486            let mut transport = CountingRealtimeTransport::default();
8487            let outcome = reopened.sync(&mut transport);
8488            assert!(matches!(outcome, SyncOutcome::Failed { .. }), "{outcome:?}");
8489            assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8490            let next = reopened.mutate(upsert(row("t3"))).expect("after the drop");
8491            assert_eq!(reopened.pending_commit_ids(), vec![successor, next]);
8492            assert_eq!(reopened.pending_payloads().len(), 2, "{label}");
8493            drop(reopened);
8494            let _ = std::fs::remove_file(&path);
8495        }
8496    }
8497
8498    #[test]
8499    fn two_legacy_values_on_the_same_row_recover_in_fifo_order() {
8500        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8501            {"name":"id","type":"string","nullable":false},
8502            {"name":"project_id","type":"string","nullable":false},
8503            {"name":"payload","type":"bytes","nullable":true}],
8504            "scopes":[{"pattern":"project:{project_id}"}]}]});
8505        for (label, column, planted) in [
8506            ("malformed envelope", "payload", json!({"$bytes": "zz"})),
8507            (
8508                "stored null for a non-nullable column",
8509                "project_id",
8510                json!(null),
8511            ),
8512            ("wrong declared type", "payload", json!(7)),
8513        ] {
8514            let path = std::env::temp_dir().join(format!(
8515                "syncular-shared-row-{}-{}.db",
8516                label.replace(' ', "-"),
8517                uuid::Uuid::new_v4()
8518            ));
8519            let open = |path: &std::path::Path| {
8520                SyncClient::open_path(
8521                    "shared-row".into(),
8522                    &schema,
8523                    ClientLimits::default(),
8524                    path.to_str().unwrap(),
8525                )
8526                .expect("open client")
8527            };
8528            let row = |id: &str| {
8529                Map::from_iter([
8530                    ("id".to_owned(), json!(id)),
8531                    ("project_id".to_owned(), json!("p1")),
8532                    ("payload".to_owned(), json!({"$bytes": "0a"})),
8533                ])
8534            };
8535            let upsert = |values| {
8536                vec![Mutation::Upsert {
8537                    table: "tasks".into(),
8538                    values,
8539                    base_version: None,
8540                }]
8541            };
8542            let mut client = open(&path);
8543            client.create_synced_tables().expect("create synced tables");
8544            let older = client.mutate(upsert(row("t1"))).expect("older commit");
8545            let newer = client.mutate(upsert(row("t1"))).expect("newer commit");
8546            let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8547            drop(client);
8548
8549            let connection = Connection::open(&path).expect("raw connection");
8550            for commit_id in [&older, &newer] {
8551                let mut values = row("t1");
8552                values.insert(column.to_owned(), planted.clone());
8553                let planted_ops = json!([{
8554                    "op": "upsert",
8555                    "table": "tasks",
8556                    "rowId": "t1",
8557                    "baseVersion": null,
8558                    "values": values
8559                }]);
8560                connection
8561                    .execute(
8562                        "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8563                        rusqlite::params![planted_ops.to_string(), commit_id],
8564                    )
8565                    .expect("plant legacy intent");
8566            }
8567            drop(connection);
8568
8569            // Startup recovery drops both, FIFO, and publishes one batch.
8570            let mut reopened = open(&path);
8571            assert_eq!(
8572                reopened.pending_commit_ids(),
8573                vec![successor.clone()],
8574                "{label}"
8575            );
8576            assert_eq!(
8577                reopened
8578                    .rejections()
8579                    .iter()
8580                    .map(|rejection| rejection.client_commit_id.clone())
8581                    .collect::<Vec<_>>(),
8582                vec![older.clone(), newer.clone()],
8583                "{label}"
8584            );
8585            assert!(
8586                reopened
8587                    .rejections()
8588                    .iter()
8589                    .all(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE
8590                        && rejection.details.as_ref().and_then(|d| d.reason.as_deref())
8591                            == Some(INVALID_STORED_VALUES_REASON)),
8592                "{label}"
8593            );
8594            for commit_id in [&older, &newer] {
8595                assert!(
8596                    matches!(
8597                        reopened
8598                            .commit_outcome(commit_id)
8599                            .expect("outcome")
8600                            .map(|o| o.status),
8601                        Some(CommitOutcomeStatus::Rejected)
8602                    ),
8603                    "{label}"
8604                );
8605            }
8606            assert_eq!(
8607                reopened
8608                    .query("SELECT id FROM tasks ORDER BY id", &[])
8609                    .unwrap(),
8610                vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8611                "{label}"
8612            );
8613            let revision = reopened.local_revision();
8614            let batches = reopened.drain_change_batches();
8615            assert!(
8616                batches.iter().any(|batch| batch.rejections_changed
8617                    && batch.outcomes_changed
8618                    && batch.tables.iter().any(|change| change.table == "tasks")
8619                    && batch.revision == revision.to_string()),
8620                "{label}: {batches:?}"
8621            );
8622            drop(reopened);
8623
8624            // An ordinary reopen with nothing to recover publishes nothing and
8625            // leaves the revision alone.
8626            let mut stable = open(&path);
8627            assert_eq!(stable.local_revision(), revision, "{label}");
8628            assert!(stable.drain_change_batches().is_empty(), "{label}");
8629            assert_eq!(stable.pending_commit_ids(), vec![successor], "{label}");
8630            drop(stable);
8631            let _ = std::fs::remove_file(&path);
8632        }
8633    }
8634
8635    #[test]
8636    fn recovery_omits_a_legacy_operation_the_journal_reader_rejects() {
8637        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8638            {"name":"id","type":"string","nullable":false},
8639            {"name":"project_id","type":"string","nullable":false},
8640            {"name":"payload","type":"bytes","nullable":true}],
8641            "scopes":[{"pattern":"project:{project_id}"}]}]});
8642        for (label, second_op) in [
8643            (
8644                "delete carrying values",
8645                json!({"op": "delete", "table": "tasks", "rowId": "t9", "values": {}}),
8646            ),
8647            (
8648                "baseVersion beyond the safe bound",
8649                json!({"op": "upsert", "table": "tasks", "rowId": "t9",
8650                    "baseVersion": MAX_JS_SAFE_INTEGER + 1,
8651                    "values": {"id": "t9", "project_id": "p1", "payload": {"$bytes": "0a"}}}),
8652            ),
8653        ] {
8654            let path = std::env::temp_dir().join(format!(
8655                "syncular-journal-shape-{}-{}.db",
8656                label.replace(' ', "-"),
8657                uuid::Uuid::new_v4()
8658            ));
8659            let open = |path: &std::path::Path| {
8660                SyncClient::open_path(
8661                    "journal-shape".into(),
8662                    &schema,
8663                    ClientLimits::default(),
8664                    path.to_str().unwrap(),
8665                )
8666                .expect("open client")
8667            };
8668            let values = |id: &str| {
8669                Map::from_iter([
8670                    ("id".to_owned(), json!(id)),
8671                    ("project_id".to_owned(), json!("p1")),
8672                    ("payload".to_owned(), json!({"$bytes": "0a"})),
8673                ])
8674            };
8675            let upsert = |values| {
8676                vec![Mutation::Upsert {
8677                    table: "tasks".into(),
8678                    values,
8679                    base_version: None,
8680                }]
8681            };
8682            let mut client = open(&path);
8683            client.create_synced_tables().expect("create synced tables");
8684            let stale = client.mutate(upsert(values("t1"))).expect("stale commit");
8685            drop(client);
8686
8687            let connection = Connection::open(&path).expect("raw connection");
8688            // The first operation triggers the drop with canonical cells (a
8689            // stored null for a non-nullable column); the second one is the
8690            // operation shape the journal reader refuses.
8691            let planted = json!([
8692                {
8693                    "op": "upsert",
8694                    "table": "tasks",
8695                    "rowId": "t1",
8696                    "baseVersion": null,
8697                    "values": {"id": "t1", "project_id": null,
8698                        "payload": {"$bytes": "0a"}}
8699                },
8700                second_op
8701            ]);
8702            connection
8703                .execute(
8704                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8705                    rusqlite::params![planted.to_string(), stale],
8706                )
8707                .expect("plant legacy intent");
8708            drop(connection);
8709
8710            let reopened = open(&path);
8711            let journal = reopened
8712                .commit_outcome(&stale)
8713                .unwrap_or_else(|error| panic!("{label}: journal read: {error}"))
8714                .unwrap_or_else(|| panic!("{label}: journal entry"));
8715            assert!(
8716                matches!(journal.status, CommitOutcomeStatus::Rejected),
8717                "{label}"
8718            );
8719            assert!(journal.operations.is_none(), "{label}");
8720            assert!(
8721                reopened
8722                    .commit_outcomes(CommitOutcomeQuery::default())
8723                    .is_ok(),
8724                "{label}: outcome list query"
8725            );
8726            assert!(reopened.pending_commit_ids().is_empty(), "{label}");
8727            drop(reopened);
8728            let _ = std::fs::remove_file(&path);
8729        }
8730    }
8731
8732    #[test]
8733    fn legacy_values_recover_at_the_recreate_and_bump_reset_boundaries() {
8734        let schema = |version: i32, extra: bool| {
8735            let mut columns = vec![
8736                json!({"name":"id","type":"string","nullable":false}),
8737                json!({"name":"project_id","type":"string","nullable":false}),
8738                json!({"name":"payload","type":"bytes","nullable":true}),
8739            ];
8740            if extra {
8741                columns.push(json!({"name":"later","type":"string","nullable":true}));
8742            }
8743            json!({"version":version,"tables":[{"name":"tasks","primaryKey":"id","columns":columns,
8744                "scopes":[{"pattern":"project:{project_id}"}]}]})
8745        };
8746        for (label, bump) in [
8747            ("same-version recreation", false),
8748            ("version-bump reset", true),
8749            ("version-only bump reset", true),
8750        ] {
8751            let path = std::env::temp_dir().join(format!(
8752                "syncular-legacy-reset-{}-{}.db",
8753                label.replace(' ', "-"),
8754                uuid::Uuid::new_v4()
8755            ));
8756            let v1 = schema(1, false);
8757            let open = |schema: &Value, path: &std::path::Path| {
8758                SyncClient::open_path(
8759                    "legacy-reset".into(),
8760                    schema,
8761                    ClientLimits::default(),
8762                    path.to_str().unwrap(),
8763                )
8764                .expect("open client")
8765            };
8766            let row = |id: &str| {
8767                Map::from_iter([
8768                    ("id".to_owned(), json!(id)),
8769                    ("project_id".to_owned(), json!("p1")),
8770                    ("payload".to_owned(), json!({"$bytes": "0a"})),
8771                ])
8772            };
8773            let upsert = |values| {
8774                vec![Mutation::Upsert {
8775                    table: "tasks".into(),
8776                    values,
8777                    base_version: None,
8778                }]
8779            };
8780            let mut client = open(&v1, &path);
8781            client.create_synced_tables().expect("create synced tables");
8782            let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
8783            let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8784            drop(client);
8785
8786            let connection = Connection::open(&path).expect("raw connection");
8787            let planted = json!([{
8788                "op": "upsert",
8789                "table": "tasks",
8790                "rowId": "t1",
8791                "baseVersion": null,
8792                "values": {"id": "t1", "project_id": 7, "payload": {"$bytes": "0a"}}
8793            }]);
8794            connection
8795                .execute(
8796                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8797                    rusqlite::params![planted.to_string(), stale],
8798                )
8799                .expect("plant legacy intent");
8800            drop(connection);
8801
8802            let target = if bump {
8803                schema(2, label == "version-bump reset")
8804            } else {
8805                v1.clone()
8806            };
8807            let reopened = open(&target, &path);
8808            let rejection = reopened
8809                .rejections()
8810                .iter()
8811                .find(|rejection| rejection.client_commit_id == stale)
8812                .unwrap_or_else(|| panic!("{label}: rejection"));
8813            assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8814            assert_eq!(
8815                rejection
8816                    .details
8817                    .as_ref()
8818                    .and_then(|details| details.reason.as_deref()),
8819                Some(INVALID_STORED_VALUES_REASON),
8820                "{label}"
8821            );
8822            assert!(rejection.operation.is_some(), "{label}");
8823            assert_eq!(
8824                reopened.pending_commit_ids(),
8825                vec![successor.clone()],
8826                "{label}"
8827            );
8828            // The successor survives and still encodes. A bump that adds a
8829            // column leaves the older full-row upsert partial over the empty
8830            // base, so its visible row appears when the server answers it
8831            // (§7.1); the same-version recreation replays it locally.
8832            assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
8833            if label == "version-bump reset" {
8834                assert_eq!(
8835                    reopened
8836                        .query("SELECT id FROM tasks ORDER BY id", &[])
8837                        .unwrap(),
8838                    Vec::<Map<String, Value>>::new(),
8839                    "{label}"
8840                );
8841                // The guarded bump reset owns the publication, so its change
8842                // batch carries the recovery's rejection, outcome, and table
8843                // domains at the revision it advanced to.
8844                let mut next = reopened;
8845                let revision = next.local_revision();
8846                let batches = next.drain_change_batches();
8847                assert!(
8848                    batches.iter().any(|batch| batch.rejections_changed
8849                        && batch.outcomes_changed
8850                        && batch.tables.iter().any(|change| change.table == "tasks")
8851                        && batch.revision == revision.to_string()),
8852                    "{label}: {batches:?}"
8853                );
8854                next.mutate(upsert(Map::from_iter([
8855                    ("id".to_owned(), json!("t3")),
8856                    ("project_id".to_owned(), json!("p1")),
8857                    ("payload".to_owned(), json!({"$bytes": "0b"})),
8858                    ("later".to_owned(), json!("v2")),
8859                ])))
8860                .expect("v2 authoring after the reset");
8861                assert_eq!(next.pending_commit_ids().len(), 2, "{label}");
8862                assert_eq!(next.pending_payloads().len(), 2, "{label}");
8863                drop(next);
8864            } else {
8865                assert_eq!(
8866                    reopened
8867                        .query("SELECT id FROM tasks ORDER BY id", &[])
8868                        .unwrap(),
8869                    vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8870                    "{label}"
8871                );
8872                drop(reopened);
8873            }
8874
8875            // Queue and corrupt a new commit while the client stays open so only
8876            // recreation can recover it.
8877            if !bump {
8878                let mut recreated = open(&v1, &path);
8879                let fresh = recreated
8880                    .mutate(upsert(row("t3")))
8881                    .expect("fresh commit while open");
8882                recreated.drain_change_batches();
8883                let planted_fresh = json!([{
8884                    "op": "upsert",
8885                    "table": "tasks",
8886                    "rowId": "t3",
8887                    "baseVersion": null,
8888                    "values": {"id": "t3", "project_id": "p1",
8889                        "payload": {"$bytes": "zz"}}
8890                }]);
8891                let connection = Connection::open(&path).expect("raw connection");
8892                let affected = connection
8893                    .execute(
8894                        "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8895                        rusqlite::params![planted_fresh.to_string(), fresh],
8896                    )
8897                    .expect("plant fresh legacy intent");
8898                assert_eq!(
8899                    affected, 1,
8900                    "{label}: the in-place plant must hit the queued commit"
8901                );
8902                drop(connection);
8903                let revision_before = recreated.local_revision();
8904                recreated
8905                    .recreate_with_schema(&v1)
8906                    .unwrap_or_else(|error| panic!("{label}: recreation failed: {error}"));
8907                assert_eq!(recreated.pending_commit_ids(), vec![successor], "{label}");
8908                let fresh_rejection = recreated
8909                    .rejections()
8910                    .iter()
8911                    .find(|rejection| rejection.client_commit_id == fresh)
8912                    .expect("the in-place recovery rejection");
8913                assert_eq!(fresh_rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8914                assert_eq!(
8915                    fresh_rejection
8916                        .details
8917                        .as_ref()
8918                        .and_then(|details| details.reason.as_deref()),
8919                    Some(INVALID_STORED_VALUES_REASON),
8920                    "{label}"
8921                );
8922                assert!(
8923                    matches!(
8924                        recreated
8925                            .commit_outcome(&fresh)
8926                            .expect("outcome read")
8927                            .map(|outcome| outcome.status),
8928                        Some(CommitOutcomeStatus::Rejected)
8929                    ),
8930                    "{label}"
8931                );
8932                assert!(recreated.local_revision() > revision_before, "{label}");
8933                let revision = recreated.local_revision();
8934                let batches = recreated.drain_change_batches();
8935                assert_eq!(batches.len(), 1, "{label}: one recovery batch");
8936                assert!(
8937                    batches.iter().any(|batch| batch.rejections_changed
8938                        && batch.outcomes_changed
8939                        && batch.tables.iter().any(|change| change.table == "tasks")
8940                        && batch.revision == revision.to_string()),
8941                    "{label}: {batches:?}"
8942                );
8943                assert_eq!(recreated.pending_payloads().len(), 1, "{label}");
8944                assert_eq!(
8945                    recreated
8946                        .query("SELECT id FROM tasks ORDER BY id", &[])
8947                        .unwrap(),
8948                    vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8949                    "{label}: the recovered commit leaves no projection"
8950                );
8951                drop(recreated);
8952            }
8953            let _ = std::fs::remove_file(&path);
8954        }
8955    }
8956
8957    #[test]
8958    fn legacy_values_recover_over_a_synced_authoritative_row() {
8959        let path = std::env::temp_dir().join(format!(
8960            "syncular-authoritative-row-{}.db",
8961            uuid::Uuid::new_v4()
8962        ));
8963        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8964            {"name":"id","type":"string","nullable":false},
8965            {"name":"project_id","type":"string","nullable":false},
8966            {"name":"title","type":"string","nullable":false},
8967            {"name":"payload","type":"bytes","nullable":true}],
8968            "scopes":[{"pattern":"project:{project_id}"}]}]});
8969        let open = |path: &std::path::Path| {
8970            SyncClient::open_path(
8971                "authoritative-row".into(),
8972                &schema,
8973                ClientLimits::default(),
8974                path.to_str().unwrap(),
8975            )
8976            .expect("open client")
8977        };
8978        let row = |title: &str| {
8979            Map::from_iter([
8980                ("id".to_owned(), json!("t1")),
8981                ("project_id".to_owned(), json!("p1")),
8982                ("title".to_owned(), json!(title)),
8983                ("payload".to_owned(), json!({"$bytes": "0a"})),
8984            ])
8985        };
8986        let upsert = |values| {
8987            vec![Mutation::Upsert {
8988                table: "tasks".into(),
8989                values,
8990                base_version: None,
8991            }]
8992        };
8993        let mut client = open(&path);
8994        client.create_synced_tables().expect("create synced tables");
8995        // A server-confirmed row: base and visible carry the authoritative
8996        // values at version 1.
8997        client
8998            .conn
8999            .execute_batch(
9000                "INSERT INTO _syncular_base_tasks VALUES ('t1','p1','authoritative',x'0a',1);
9001                 INSERT INTO tasks VALUES ('t1','p1','authoritative',x'0a',1);",
9002            )
9003            .expect("seed authoritative row");
9004        let older = client.mutate(upsert(row("older"))).expect("older");
9005        let newer = client.mutate(upsert(row("newer"))).expect("newer");
9006        let successor = client.mutate(upsert(row("successor"))).expect("successor");
9007        drop(client);
9008
9009        let connection = Connection::open(&path).expect("raw connection");
9010        for commit_id in [&older, &newer] {
9011            let planted = json!([{
9012                "op": "upsert",
9013                "table": "tasks",
9014                "rowId": "t1",
9015                "baseVersion": null,
9016                "values": {"id": "t1", "project_id": "p1", "title": "legacy",
9017                    "payload": {"$bytes": "zz"}}
9018            }]);
9019            connection
9020                .execute(
9021                    "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9022                    rusqlite::params![planted.to_string(), commit_id],
9023                )
9024                .expect("plant legacy intent");
9025        }
9026        drop(connection);
9027
9028        let reopened = open(&path);
9029        assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
9030        assert_eq!(
9031            reopened
9032                .rejections()
9033                .iter()
9034                .map(|rejection| rejection.client_commit_id.clone())
9035                .collect::<Vec<_>>(),
9036            vec![older.clone(), newer.clone()]
9037        );
9038        // The pending successor overlays the restored authoritative row, which
9039        // keeps its server version.
9040        assert_eq!(
9041            reopened.query("SELECT id,title,_syncular_version FROM tasks", &[]),
9042            Ok(vec![Map::from_iter([
9043                ("id".to_owned(), json!("t1")),
9044                ("title".to_owned(), json!("successor")),
9045                ("_syncular_version".to_owned(), json!(1)),
9046            ])])
9047        );
9048
9049        // A later rejection of the surviving successor restores the
9050        // authoritative values and version exactly.
9051        drop(reopened);
9052        let connection = Connection::open(&path).expect("raw connection");
9053        connection
9054            .execute(
9055                "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9056                rusqlite::params![
9057                    json!([{
9058                        "op": "upsert",
9059                        "table": "tasks",
9060                        "rowId": "t1",
9061                        "baseVersion": null,
9062                        "values": {"id": "t1", "project_id": "p1", "title": "legacy",
9063                            "payload": {"$bytes": "zz"}}
9064                    }])
9065                    .to_string(),
9066                    successor
9067                ],
9068            )
9069            .expect("plant successor intent");
9070        drop(connection);
9071        let late = open(&path);
9072        assert!(late.pending_commit_ids().is_empty());
9073        assert_eq!(
9074            late.query("SELECT id,title,_syncular_version FROM tasks", &[]),
9075            Ok(vec![Map::from_iter([
9076                ("id".to_owned(), json!("t1")),
9077                ("title".to_owned(), json!("authoritative")),
9078                ("_syncular_version".to_owned(), json!(1)),
9079            ])])
9080        );
9081        drop(late);
9082        let _ = std::fs::remove_file(&path);
9083    }
9084
9085    #[test]
9086    fn a_failed_value_recovery_commit_restores_every_side_effect() {
9087        let path = std::env::temp_dir().join(format!(
9088            "syncular-value-recovery-commit-{}.db",
9089            uuid::Uuid::new_v4()
9090        ));
9091        let path_str = path.to_str().expect("path");
9092        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9093            {"name":"id","type":"string","nullable":false},
9094            {"name":"project_id","type":"string","nullable":false},
9095            {"name":"payload","type":"bytes","nullable":true}],
9096            "scopes":[{"pattern":"project:{project_id}"}]}]});
9097        // A rollback-journal replica, so a second read connection can hold
9098        // SHARED across the recovery transaction's COMMIT.
9099        let conn = Connection::open(path_str).expect("replica connection");
9100        let mut client = SyncClient::with_connection(
9101            "value-recovery-commit".into(),
9102            &schema,
9103            ClientLimits::default(),
9104            conn,
9105        )
9106        .expect("replica");
9107        assert_eq!(
9108            client
9109                .conn
9110                .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
9111                .expect("journal mode"),
9112            "delete"
9113        );
9114        client.create_synced_tables().expect("create synced tables");
9115        let stale = client
9116            .mutate(vec![Mutation::Upsert {
9117                table: "tasks".into(),
9118                values: Map::from_iter([
9119                    ("id".to_owned(), json!("t1")),
9120                    ("project_id".to_owned(), json!("p1")),
9121                    ("payload".to_owned(), json!({"$bytes": "0a"})),
9122                ]),
9123                base_version: None,
9124            }])
9125            .expect("queued write");
9126        client.drain_change_batches();
9127        let affected = client
9128            .conn
9129            .execute(
9130                "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9131                rusqlite::params![
9132                    json!([{
9133                        "op": "upsert",
9134                        "table": "tasks",
9135                        "rowId": "t1",
9136                        "baseVersion": null,
9137                        "values": {"id": "t1", "project_id": "p1",
9138                            "payload": {"$bytes": "zz"}}
9139                    }])
9140                    .to_string(),
9141                    stale
9142                ],
9143            )
9144            .expect("plant legacy intent");
9145        assert_eq!(affected, 1, "the planted row must exist");
9146        let revision = client.local_revision();
9147        let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
9148        let prior_outbox = client.pending_commit_ids();
9149        let prior_rejections = client.rejections().len();
9150        let prior_changes = client.change_queue.len();
9151        let prior_intents = client.sync_intent_queue.len();
9152        let prior_failed = client.failed_commits.len();
9153        // The reader holds SHARED for the whole attempt: the recovery runs to
9154        // its publication and the COMMIT fails.
9155        let reader = Connection::open(path_str).expect("reader");
9156        reader
9157            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
9158            .expect("reader snapshot");
9159        client
9160            .conn
9161            .busy_timeout(std::time::Duration::ZERO)
9162            .expect("busy timeout");
9163        let error = client
9164            .recreate_with_schema(&schema)
9165            .expect_err("a blocked COMMIT must refuse the recovery");
9166        assert!(error.starts_with("client.storage_busy:"), "{error}");
9167        assert!(client.conn.is_autocommit(), "the transaction is released");
9168        assert_eq!(client.local_revision(), revision);
9169        assert_eq!(client.pending_commit_ids(), prior_outbox);
9170        assert_eq!(client.rejections().len(), prior_rejections);
9171        assert_eq!(client.change_queue.len(), prior_changes);
9172        assert_eq!(client.sync_intent_queue.len(), prior_intents);
9173        assert_eq!(client.failed_commits.len(), prior_failed);
9174        assert_eq!(
9175            client.query("SELECT * FROM tasks", &[]).expect("rows"),
9176            rows
9177        );
9178        assert!(
9179            client
9180                .commit_outcome(&stale)
9181                .expect("outcome read")
9182                .is_none(),
9183            "the rolled-back recovery kept no outcome"
9184        );
9185        // The retry runs the same publication branch to completion.
9186        reader.execute_batch("ROLLBACK").expect("release reader");
9187        client
9188            .recreate_with_schema(&schema)
9189            .expect("retry after the blocked commit");
9190        assert!(client.pending_commit_ids().is_empty());
9191        assert_eq!(client.rejections().len(), prior_rejections + 1);
9192        assert!(client.local_revision() > revision);
9193        let batches = client.drain_change_batches();
9194        assert!(
9195            batches.iter().any(|batch| batch.rejections_changed
9196                && batch.outcomes_changed
9197                && batch.tables.iter().any(|change| change.table == "tasks")),
9198            "{batches:?}"
9199        );
9200        drop(client);
9201        let _ = std::fs::remove_file(&path);
9202    }
9203
9204    #[cfg(feature = "e2ee")]
9205    #[test]
9206    fn a_failed_recovery_restores_retained_failed_commits() {
9207        let path = std::env::temp_dir().join(format!(
9208            "syncular-value-recovery-retention-{}.db",
9209            uuid::Uuid::new_v4()
9210        ));
9211        let path_str = path.to_str().expect("path");
9212        let schema = json!({"version":1,"tables":[{"name":"secrets","primaryKey":"id","columns":[
9213            {"name":"id","type":"string","nullable":false},
9214            {"name":"project_id","type":"string","nullable":false},
9215            {"name":"note","type":"bytes","nullable":true,"encrypted":true,"declaredType":"string"}],
9216            "scopes":[{"pattern":"project:{project_id}"}]}]});
9217        let conn = Connection::open(path_str).expect("replica connection");
9218        let mut client = SyncClient::with_connection(
9219            "value-recovery-retention".into(),
9220            &schema,
9221            ClientLimits::default(),
9222            conn,
9223        )
9224        .expect("replica");
9225        client.create_synced_tables().expect("create synced tables");
9226        client.set_retain_failed_commits(true).expect("retention");
9227        // No keys configured: the §5.11 seal fails, so retention is exercised.
9228        client.set_encryption(crate::values::EncryptionConfig::default());
9229        client
9230            .mutate(vec![Mutation::Upsert {
9231                table: "secrets".into(),
9232                values: Map::from_iter([
9233                    ("id".to_owned(), json!("s1")),
9234                    ("project_id".to_owned(), json!("p1")),
9235                    ("note".to_owned(), json!("secret")),
9236                ]),
9237                base_version: None,
9238            }])
9239            .expect("queued write");
9240        client.drain_change_batches();
9241        let revision = client.local_revision();
9242        let prior_outbox = client.pending_commit_ids();
9243        let prior_rejections = client.rejections().len();
9244        let prior_failed = client.failed_commits.len();
9245        let reader = Connection::open(path_str).expect("reader");
9246        reader
9247            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
9248            .expect("reader snapshot");
9249        client
9250            .conn
9251            .busy_timeout(std::time::Duration::ZERO)
9252            .expect("busy timeout");
9253        let error = client
9254            .drop_unencodable_outbox(true, None)
9255            .expect_err("a blocked COMMIT must refuse the recovery");
9256        assert!(error.starts_with("client.storage_busy:"), "{error}");
9257        assert_eq!(client.failed_commits.len(), prior_failed);
9258        assert_eq!(client.pending_commit_ids(), prior_outbox);
9259        assert_eq!(client.rejections().len(), prior_rejections);
9260        assert_eq!(client.local_revision(), revision);
9261        // The retry retains the failed commit and drops it from the outbox.
9262        reader.execute_batch("ROLLBACK").expect("release reader");
9263        client
9264            .drop_unencodable_outbox(true, None)
9265            .expect("retry after the blocked commit");
9266        assert_eq!(client.failed_commits.len(), prior_failed + 1);
9267        assert!(client.pending_commit_ids().is_empty());
9268        assert_eq!(client.rejections().len(), prior_rejections + 1);
9269        drop(client);
9270        let _ = std::fs::remove_file(&path);
9271    }
9272
9273    #[test]
9274    fn client_error_maps_legacy_codes_and_static_messages() {
9275        let code_only = ClientError::from("sync.unknown_table".to_owned());
9276        assert_eq!(code_only.code, "sync.unknown_table");
9277        assert_eq!(code_only.message, "the commit targets an unknown table");
9278        assert!(code_only.details.is_none());
9279
9280        let dynamic = ClientError::from(
9281            "sync.invalid_request: table \"tasks\": patch cannot write scope column".to_owned(),
9282        );
9283        assert_eq!(dynamic.code, "sync.invalid_request");
9284        assert_eq!(dynamic.message, "the authoring request is invalid");
9285        assert!(dynamic.details.as_ref().unwrap()["legacyCause"]
9286            .as_str()
9287            .unwrap()
9288            .contains("patch cannot write scope column"));
9289
9290        let unclassified =
9291            ClientError::from("a commit must contain at least one operation".to_owned());
9292        assert_eq!(unclassified.code, "client.failed");
9293        assert_eq!(unclassified.message, "the local authoring operation failed");
9294        assert!(unclassified.details.as_ref().unwrap()["legacyCause"]
9295            .as_str()
9296            .unwrap()
9297            .contains("at least one operation"));
9298
9299        // An unclassified QueryReadFailure keeps its numeric SQLite evidence.
9300        let metadata = ClientError::from(QueryReadFailure {
9301            code: None,
9302            sqlite_code: Some(1),
9303            sqlite_message: Some("generic SQLite prose".to_owned()),
9304            rollback_failure: Some(Box::new(QueryReadFailure::from(
9305                rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(1), None),
9306            ))),
9307            message: "unclassified read".to_owned(),
9308        });
9309        assert_eq!(metadata.code, "client.failed");
9310        assert_eq!(metadata.message, "the local authoring operation failed");
9311        let details = metadata.details.as_ref().unwrap();
9312        assert_eq!(details["sqliteCode"], 1);
9313        assert_eq!(details["sqliteMessage"], "generic SQLite prose");
9314        assert_eq!(details["rollbackFailure"]["sqliteCode"], 1);
9315        assert_eq!(details["legacyCause"], "unclassified read");
9316    }
9317
9318    #[test]
9319    fn schema_marker_read_keeps_storage_classification() {
9320        let busy = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
9321            rusqlite::ffi::Error::new(5),
9322            Some("database is locked".to_owned()),
9323        ));
9324        assert!(busy.starts_with("client.storage_busy:"), "{busy}");
9325        let invalid = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
9326            rusqlite::ffi::Error::new(1),
9327            Some("unreadable".to_owned()),
9328        ));
9329        assert_eq!(
9330            invalid,
9331            "sync.local_corrupt: persisted local schema marker is unreadable or invalid"
9332        );
9333
9334        // A real exclusive lock on the marker read surfaces BUSY, not corrupt,
9335        // and the guard writes nothing.
9336        let path =
9337            std::env::temp_dir().join(format!("syncular-marker-busy-{}.db", uuid::Uuid::new_v4()));
9338        let setup = Connection::open(&path).expect("setup connection");
9339        setup
9340            .execute_batch(
9341                "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value TEXT);\n                 INSERT INTO _syncular_meta VALUES ('localSchemaVersion','1');",
9342            )
9343            .unwrap();
9344        let locker = Connection::open(&path).expect("locker connection");
9345        locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
9346        let reader = Connection::open(&path).expect("reader connection");
9347        reader.busy_timeout(std::time::Duration::ZERO).unwrap();
9348        let locked = read_local_schema_version(&reader, 1).expect_err("locked marker read");
9349        assert!(locked.starts_with("client.storage_busy:"), "{locked}");
9350        locker.execute_batch("ROLLBACK").unwrap();
9351        assert_eq!(read_local_schema_version(&reader, 1).unwrap(), Some(1));
9352        let rows = setup
9353            .query_row("SELECT count(*) FROM _syncular_meta", [], |row| {
9354                row.get::<_, i64>(0)
9355            })
9356            .unwrap();
9357        assert_eq!(rows, 1, "the guard performed no writes");
9358        drop(reader);
9359        drop(locker);
9360        drop(setup);
9361        let _ = std::fs::remove_file(&path);
9362    }
9363
9364    #[cfg(feature = "crdt-yjs")]
9365    #[test]
9366    fn crdt_authoring_pre_read_preserves_storage_classification() {
9367        let path =
9368            std::env::temp_dir().join(format!("syncular-crdt-busy-{}.db", uuid::Uuid::new_v4()));
9369        let schema = json!({"version":1,"tables":[{"name":"notes","primaryKey":"id","columns":[
9370            {"name":"id","type":"string","nullable":false},
9371            {"name":"project_id","type":"string","nullable":false},
9372            {"name":"doc","type":"crdt","nullable":true,"crdtType":"yjs-doc"}],
9373            "scopes":[{"pattern":"project:{project_id}"}]}]});
9374        let mut client = SyncClient::open_path(
9375            "crdt-busy".into(),
9376            &schema,
9377            ClientLimits::default(),
9378            path.to_str().unwrap(),
9379        )
9380        .expect("open client");
9381        client.create_synced_tables().expect("create synced tables");
9382        client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
9383
9384        let locker = Connection::open(&path).expect("locker connection");
9385        locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
9386        let busy = client
9387            .crdt_apply_update("notes", "n1", "doc", &[])
9388            .expect_err("busy crdt pre-read");
9389        assert_eq!(busy.code, "client.storage_busy");
9390        assert!(busy.retryable);
9391        assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
9392        assert!(client.pending_commit_ids().is_empty());
9393        assert_eq!(client.local_revision(), 0);
9394
9395        locker.execute_batch("ROLLBACK").unwrap();
9396        drop(locker);
9397        drop(client);
9398        let _ = std::fs::remove_file(&path);
9399    }
9400
9401    #[test]
9402    fn large_unique_import_copies_no_unrelated_tables() {
9403        let (mut client, image, table) = unique_import_fixture(10240);
9404        let pending = client.pending_commit_ids();
9405        client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
9406            CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
9407        assert!(matches!(
9408            client.apply_sqlite_image(
9409                &image,
9410                &table,
9411                true,
9412                &[("project_id".into(), vec!["p1".into()])],
9413                10240,
9414                7,
9415                "digest"
9416            ),
9417            Ok(10240)
9418        ));
9419        assert_eq!(client.overlay_rebuild_count.get(), 0);
9420        assert_eq!(client.pending_commit_ids(), pending);
9421        assert_eq!(
9422            client
9423                .query("SELECT count(*) AS n FROM tasks", &[])
9424                .unwrap()[0]["n"],
9425            10241
9426        );
9427        assert_eq!(
9428            client
9429                .query("SELECT count(*) AS n FROM tasks_fts", &[])
9430                .unwrap()[0]["n"],
9431            10241
9432        );
9433        assert_eq!(
9434            client
9435                .query("SELECT count(*) AS n FROM notes_fts", &[])
9436                .unwrap()[0]["n"],
9437            8192
9438        );
9439    }
9440
9441    #[test]
9442    #[ignore = "manual scaling benchmark; see bench/README.md"]
9443    fn benchmark_unique_image_import() {
9444        let mut samples = Vec::new();
9445        for rows in [10240, 51200, 102400] {
9446            for trial in 0..4 {
9447                let (mut client, image, table) = unique_import_fixture(rows);
9448                let start = std::time::Instant::now();
9449                assert!(
9450                    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)
9451                );
9452                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
9453                assert_eq!(
9454                    client
9455                        .query("SELECT count(*) AS n FROM tasks", &[])
9456                        .unwrap()[0]["n"],
9457                    rows + 1
9458                );
9459                assert_eq!(
9460                    client
9461                        .query("SELECT count(*) AS n FROM tasks_fts", &[])
9462                        .unwrap()[0]["n"],
9463                    rows + 1
9464                );
9465                assert_eq!(
9466                    client
9467                        .query("SELECT count(*) AS n FROM notes_fts", &[])
9468                        .unwrap()[0]["n"],
9469                    8192
9470                );
9471                assert_eq!(client.pending_commit_ids().len(), 1);
9472                if trial > 0 {
9473                    samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
9474                }
9475            }
9476        }
9477        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});
9478        let output = serde_json::to_string_pretty(&artifact).unwrap();
9479        if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
9480            use std::io::Write;
9481            let mut file = std::fs::OpenOptions::new()
9482                .write(true)
9483                .create_new(true)
9484                .open(path)
9485                .unwrap();
9486            file.write_all(output.as_bytes()).unwrap();
9487        } else {
9488            println!("{output}");
9489        }
9490    }
9491
9492    #[test]
9493    fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
9494        let schema = json!({
9495            "version": 1,
9496            "tables": [{
9497                "name": "catalogue_codes",
9498                "primaryKey": "id",
9499                "columns": [
9500                    { "name": "id", "type": "string", "nullable": false },
9501                    { "name": "release_id", "type": "string", "nullable": false },
9502                    { "name": "code", "type": "string", "nullable": false },
9503                    { "name": "title", "type": "string", "nullable": false }
9504                ],
9505                "scopes": [{ "pattern": "release:{release_id}" }],
9506                "ftsIndexes": [{
9507                    "name": "catalogue_codes_fts",
9508                    "columns": ["code", "title"],
9509                    "tokenize": "unicode61 remove_diacritics 2"
9510                }]
9511            }]
9512        });
9513        let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
9514            .expect("FTS5 client");
9515        let insert_trigger: String = client
9516            .conn
9517            .query_row(
9518                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
9519                [],
9520                |row| row.get(0),
9521            )
9522            .expect("insert trigger");
9523        let replace_guard: String = client
9524            .conn
9525            .query_row(
9526                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
9527                [],
9528                |row| row.get(0),
9529            )
9530            .expect("replace guard");
9531        assert!(!insert_trigger.contains("DELETE FROM"));
9532        assert!(replace_guard.contains("BEFORE INSERT"));
9533        assert!(replace_guard.contains("WHEN EXISTS"));
9534        let search = |client: &SyncClient, query: &str| {
9535            client
9536                .query(
9537                    "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",
9538                    &[Value::from(query)],
9539                )
9540                .expect("FTS query")
9541        };
9542
9543        client
9544            .mutate(vec![Mutation::Upsert {
9545                table: "catalogue_codes".to_owned(),
9546                values: Map::from_iter([
9547                    ("id".to_owned(), Value::from("c1")),
9548                    ("release_id".to_owned(), Value::from("r1")),
9549                    ("code".to_owned(), Value::from("A01")),
9550                    ("title".to_owned(), Value::from("Cholera")),
9551                ]),
9552                base_version: None,
9553            }])
9554            .expect("insert code");
9555        assert_eq!(search(&client, "cholera").len(), 1);
9556        let prior = client
9557            .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9558            .unwrap();
9559        client
9560            .conn
9561            .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
9562            .unwrap();
9563        client.create_synced_tables().unwrap();
9564        assert_eq!(
9565            client
9566                .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9567                .unwrap(),
9568            prior
9569        );
9570        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();
9571        let rendered = serde_json::to_string(&plan).unwrap();
9572        assert!(rendered.contains("COVERING INDEX"));
9573        assert!(rendered.contains("INDEX 0:="));
9574        client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
9575        assert_eq!(search(&client, "cholera").len(), 1);
9576
9577        client
9578            .mutate(vec![Mutation::Upsert {
9579                table: "catalogue_codes".to_owned(),
9580                values: Map::from_iter([
9581                    ("id".to_owned(), Value::from("c1")),
9582                    ("release_id".to_owned(), Value::from("r1")),
9583                    ("code".to_owned(), Value::from("A01")),
9584                    ("title".to_owned(), Value::from("Enteric infection")),
9585                ]),
9586                base_version: None,
9587            }])
9588            .expect("update code");
9589        assert!(search(&client, "cholera").is_empty());
9590        assert_eq!(search(&client, "enteric").len(), 1);
9591
9592        client
9593            .mutate(vec![Mutation::Delete {
9594                table: "catalogue_codes".to_owned(),
9595                row_id: "c1".to_owned(),
9596                base_version: None,
9597            }])
9598            .expect("delete code");
9599        assert!(search(&client, "enteric").is_empty());
9600    }
9601
9602    #[test]
9603    fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
9604        let schema = json!({
9605            "version": 1,
9606            "tables": [{
9607                "name": "patient_notes",
9608                "primaryKey": "id",
9609                "columns": [
9610                    { "name": "id", "type": "string", "nullable": false },
9611                    { "name": "practice_id", "type": "string", "nullable": false },
9612                    { "name": "encryption_key_id", "type": "string", "nullable": false },
9613                    { "name": "title", "type": "string", "nullable": false }
9614                ],
9615                "scopes": [{ "pattern": "practice:{practice_id}" }],
9616                "ftsIndexes": [{
9617                    "name": "patient_notes_fts",
9618                    "columns": ["title"],
9619                    "tokenize": "unicode61 remove_diacritics 2"
9620                }]
9621            }]
9622        });
9623        let mut client = SyncClient::new(
9624            "local-purge-test".to_owned(),
9625            &schema,
9626            ClientLimits::default(),
9627        )
9628        .expect("local purge client");
9629        client
9630            .conn
9631            .execute(
9632                "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
9633                 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
9634                 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
9635                [],
9636            )
9637            .expect("seed base rows");
9638        client.overlay_dirty.set(true);
9639        client.rebuild_overlay_if_dirty().expect("rebuild overlay");
9640
9641        let note = |id: &str, key_id: &str, title: &str| {
9642            Map::from_iter([
9643                ("id".to_owned(), Value::from(id)),
9644                ("practice_id".to_owned(), Value::from("practice-1")),
9645                ("encryption_key_id".to_owned(), Value::from(key_id)),
9646                ("title".to_owned(), Value::from(title)),
9647            ])
9648        };
9649        let doomed = client
9650            .mutate(vec![
9651                Mutation::Upsert {
9652                    table: "patient_notes".to_owned(),
9653                    values: note("target", "key-revoked", "Target changed"),
9654                    base_version: None,
9655                },
9656                Mutation::Upsert {
9657                    table: "patient_notes".to_owned(),
9658                    values: note("unrelated", "key-held", "Unrelated changed"),
9659                    base_version: None,
9660                },
9661            ])
9662            .expect("doomed commit");
9663        let kept = client
9664            .mutate(vec![Mutation::Upsert {
9665                table: "patient_notes".to_owned(),
9666                values: note("kept", "key-held", "Kept optimistic"),
9667                base_version: None,
9668            }])
9669            .expect("kept commit");
9670        client.drain_change_batches();
9671
9672        let input = LocalDataPurgeInput {
9673            purge_id: "purge-001".to_owned(),
9674            targets: vec![LocalDataPurgeTarget {
9675                table: "patient_notes".to_owned(),
9676                selectors: BTreeMap::from([(
9677                    "encryption_key_id".to_owned(),
9678                    vec!["key-revoked".to_owned()],
9679                )]),
9680            }],
9681        };
9682        assert_eq!(
9683            client.purge_local_data(&input).expect("apply purge"),
9684            LocalDataPurgeResult {
9685                already_applied: false,
9686                purged_rows: 1,
9687                dropped_commits: 1,
9688            }
9689        );
9690        let rows = client
9691            .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
9692            .expect("visible rows");
9693        assert_eq!(rows.len(), 2);
9694        assert_eq!(rows[0]["id"], "kept");
9695        assert_eq!(rows[1]["id"], "unrelated");
9696        assert_eq!(rows[1]["title"], "Unrelated original");
9697        let fts = client
9698            .query(
9699                "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'",
9700                &[],
9701            )
9702            .expect("fts query");
9703        assert!(fts.is_empty());
9704        assert_eq!(client.outbox.len(), 1);
9705        assert_eq!(client.outbox[0].client_commit_id, kept);
9706        let outcome = client
9707            .commit_outcome(&doomed)
9708            .expect("read doomed outcome")
9709            .expect("doomed outcome");
9710        assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
9711        match &outcome.results[0] {
9712            CommitOperationOutcome::Error { rejection } => {
9713                assert_eq!(rejection.code, "client.local_data_purged");
9714                assert_eq!(rejection.client_commit_id, doomed);
9715            }
9716            other => panic!("expected local purge rejection, got {other:?}"),
9717        }
9718        assert_eq!(client.drain_change_batches().len(), 1);
9719        assert_eq!(
9720            client.purge_local_data(&input).expect("retry purge"),
9721            LocalDataPurgeResult {
9722                already_applied: true,
9723                purged_rows: 0,
9724                dropped_commits: 0,
9725            }
9726        );
9727        let conflicting = LocalDataPurgeInput {
9728            purge_id: input.purge_id.clone(),
9729            targets: vec![LocalDataPurgeTarget {
9730                table: "patient_notes".to_owned(),
9731                selectors: BTreeMap::from([(
9732                    "encryption_key_id".to_owned(),
9733                    vec!["key-held".to_owned()],
9734                )]),
9735            }],
9736        };
9737        assert!(client
9738            .purge_local_data(&conflicting)
9739            .expect_err("id collision must fail")
9740            .contains("already used with a different plan"));
9741    }
9742}
9743
9744impl SubState {
9745    fn name(self) -> &'static str {
9746        match self {
9747            SubState::Active => "active",
9748            SubState::Revoked => "revoked",
9749            SubState::Failed => "failed",
9750        }
9751    }
9752
9753    fn parse(value: &str) -> Self {
9754        match value {
9755            "revoked" => Self::Revoked,
9756            "failed" => Self::Failed,
9757            _ => Self::Active,
9758        }
9759    }
9760}
9761
9762#[derive(Debug, Clone)]
9763struct Subscription {
9764    id: String,
9765    table: String,
9766    requested: Vec<(String, Vec<String>)>,
9767    params: Option<String>,
9768    cursor: i64,
9769    /// §4.7 resume token, round-tripped opaquely.
9770    bootstrap_state: Option<String>,
9771    state: SubState,
9772    reason_code: Option<String>,
9773    /// Last effective scopes echoed while active (§3.3: persisted for the
9774    /// purge contract; each active echo replaces it).
9775    effective: Option<Vec<(String, Vec<String>)>>,
9776    synced_once: bool,
9777}
9778
9779#[cfg(test)]
9780mod subscription_rearm_tests {
9781    use super::*;
9782    use serde_json::json;
9783
9784    fn client() -> SyncClient {
9785        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9786            {"name":"id","type":"string","nullable":false},
9787            {"name":"project_id","type":"string","nullable":false}],
9788            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[],"ftsIndexes":[]}]});
9789        SyncClient::new("rearm".into(), &schema, ClientLimits::default()).expect("client")
9790    }
9791
9792    fn scopes() -> Vec<(String, Vec<String>)> {
9793        vec![("project_id".to_owned(), vec!["p1".to_owned()])]
9794    }
9795
9796    /// §3.3: the stored registration carries progress the re-arm must drop.
9797    fn store_revoked(client: &mut SyncClient) {
9798        client
9799            .subscribe("sub".into(), "tasks".into(), scopes(), None)
9800            .expect("subscribe");
9801        let sub = &mut client.subs[0];
9802        sub.cursor = 41;
9803        sub.bootstrap_state = Some("resume".to_owned());
9804        sub.effective = Some(scopes());
9805        sub.state = SubState::Revoked;
9806        sub.reason_code = Some("sync.scope_revoked".to_owned());
9807        client.persist_sub(&client.subs[0]).expect("persist");
9808        client.sync_needed = false;
9809        client.sync_intent_queue.clear();
9810    }
9811
9812    #[test]
9813    fn resubscribing_the_same_intent_rearms_a_revoked_registration() {
9814        let mut client = client();
9815        store_revoked(&mut client);
9816
9817        client
9818            .subscribe("sub".into(), "tasks".into(), scopes(), None)
9819            .expect("re-arm");
9820
9821        let view = client.subscription_state("sub").expect("state");
9822        assert_eq!(view.status, "active");
9823        assert_eq!(view.cursor, -1);
9824        assert!(!view.has_resume_token);
9825        assert_eq!(view.effective_scopes, None);
9826        assert_eq!(view.reason_code, None);
9827        assert!(client.sync_needed, "a re-arm asks for a pull");
9828        assert!(matches!(
9829            client.drain_sync_intents().as_slice(),
9830            [SyncIntent::Interactive]
9831        ));
9832        let stored: String = client
9833            .conn
9834            .query_row(
9835                "SELECT state_json FROM _syncular_subscriptions WHERE id = 'sub'",
9836                [],
9837                |row| row.get(0),
9838            )
9839            .expect("persisted row");
9840        let stored: Value = serde_json::from_str(&stored).expect("stored json");
9841        assert_eq!(stored["status"], json!("active"));
9842        assert_eq!(stored["cursor"], json!(-1));
9843        assert_eq!(stored["bootstrapState"], Value::Null);
9844        assert_eq!(stored["effectiveScopes"], Value::Null);
9845        assert_eq!(stored["reasonCode"], Value::Null);
9846    }
9847
9848    #[test]
9849    fn a_failed_registration_is_not_rearmed() {
9850        let mut client = client();
9851        client
9852            .subscribe("sub".into(), "tasks".into(), scopes(), None)
9853            .expect("subscribe");
9854        client.subs[0].state = SubState::Failed;
9855        client.subs[0].reason_code = Some("sync.scope_revoked".to_owned());
9856        client.persist_sub(&client.subs[0]).expect("persist");
9857        client.sync_needed = false;
9858        client.sync_intent_queue.clear();
9859
9860        client
9861            .subscribe("sub".into(), "tasks".into(), scopes(), None)
9862            .expect("subscribe");
9863
9864        let view = client.subscription_state("sub").expect("state");
9865        assert_eq!(view.status, "failed");
9866        assert_eq!(view.reason_code.as_deref(), Some("sync.scope_revoked"));
9867        assert!(!client.sync_needed);
9868        assert!(client.drain_sync_intents().is_empty());
9869    }
9870}
9871
9872#[derive(Debug, Clone)]
9873struct OutboxOp {
9874    upsert: bool,
9875    table: String,
9876    row_id: String,
9877    base_version: Option<i64>,
9878    /// Schema-agnostic local form (§0): driver JSON values, encoded with
9879    /// the current codec at send time. A key's presence IS the §6.1 sparse
9880    /// presence set — both the overlay and the wire payload derive from it.
9881    values: Option<Map<String, Value>>,
9882}
9883
9884impl From<&OutboxOp> for CommitOperation {
9885    fn from(operation: &OutboxOp) -> Self {
9886        Self {
9887            table: operation.table.clone(),
9888            row_id: operation.row_id.clone(),
9889            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
9890            base_version: operation.base_version,
9891            values: operation.values.clone(),
9892        }
9893    }
9894}
9895
9896#[derive(Debug, Clone)]
9897struct OutboxCommit {
9898    client_commit_id: String,
9899    ops: Vec<OutboxOp>,
9900}
9901
9902/// One commit's `(table, value keys)` per upsert operation — the classification
9903/// shape the §7.4.4 send-time drop and the RFC 0005 D6 audit share, so the two
9904/// can never disagree about which commits cannot re-encode.
9905fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
9906    commit
9907        .ops
9908        .iter()
9909        .filter(|operation| operation.upsert)
9910        .map(|operation| {
9911            (
9912                operation.table.as_str(),
9913                operation
9914                    .values
9915                    .as_ref()
9916                    .map(|values| values.keys().map(String::as_str).collect())
9917                    .unwrap_or_default(),
9918            )
9919        })
9920        .collect()
9921}
9922
9923type FailedCommit = (OutboxCommit, Map<String, Value>);
9924
9925#[derive(Debug, Clone)]
9926struct CompiledLocalDataPurgeTarget {
9927    table: String,
9928    selectors: Vec<(String, Vec<String>)>,
9929}
9930
9931struct StoredCommitOutcomeRow {
9932    sequence: i64,
9933    client_commit_id: String,
9934    status: String,
9935    recorded_at_ms: i64,
9936    results_json: String,
9937    operations_json: Option<String>,
9938    resolution: String,
9939    resolved_at_ms: Option<i64>,
9940    replacement_client_commit_id: Option<String>,
9941}
9942
9943impl StoredCommitOutcomeRow {
9944    fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
9945        Ok(Self {
9946            sequence: row.get(0)?,
9947            client_commit_id: row.get(1)?,
9948            status: row.get(2)?,
9949            recorded_at_ms: row.get(3)?,
9950            results_json: row.get(4)?,
9951            operations_json: row.get(5)?,
9952            resolution: row.get(6)?,
9953            resolved_at_ms: row.get(7)?,
9954            replacement_client_commit_id: row.get(8)?,
9955        })
9956    }
9957}
9958
9959/// Section outcome distinguishing the §5.6 subscription-local fail-closed
9960/// path from a round-aborting failure (§1.4 rule 5).
9961enum SectionError {
9962    FailClosed,
9963    Abort(String, String),
9964}
9965
9966#[derive(Debug)]
9967pub(crate) struct RequestMeta {
9968    pushed_ids: Vec<String>,
9969    /// Subscription id → the request carried `cursor < 0` and no resume
9970    /// token (§5.6 first-page detection: a *fresh* bootstrap).
9971    fresh: Vec<(String, bool)>,
9972    pub(crate) accept: u8,
9973    /// §6.1 splitBatch: outbox commits held back from THIS request because
9974    /// the running operation count reached the push cap — the next round
9975    /// pushes them (`sync_needed` stays set while any remain).
9976    deferred_commits: usize,
9977}
9978
9979#[derive(Default)]
9980struct ChangeAccumulator {
9981    /// None means table-wide; Some is the exact scoped domain.
9982    tables: BTreeMap<String, Option<BTreeSet<String>>>,
9983    windows: BTreeMap<(String, String), BTreeSet<String>>,
9984    status: bool,
9985    conflicts: bool,
9986    rejections: bool,
9987    outcomes: bool,
9988}
9989
9990impl ChangeAccumulator {
9991    fn table(&mut self, table: &str) {
9992        self.tables.insert(table.to_owned(), None);
9993    }
9994
9995    fn scope(&mut self, table: &str, key: String) {
9996        match self.tables.get_mut(table) {
9997            Some(None) => {}
9998            Some(Some(keys)) => {
9999                keys.insert(key);
10000            }
10001            None => {
10002                self.tables
10003                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
10004            }
10005        }
10006    }
10007
10008    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
10009        self.windows
10010            .entry((base_key.to_owned(), table.to_owned()))
10011            .or_default()
10012            .insert(unit.to_owned());
10013    }
10014
10015    fn touched(&self) -> bool {
10016        !self.tables.is_empty()
10017            || !self.windows.is_empty()
10018            || self.status
10019            || self.conflicts
10020            || self.rejections
10021            || self.outcomes
10022    }
10023}
10024
10025/// §6.1: the server caps total operations per request (reference default
10026/// 500) and rejects the whole batch with `sync.too_many_operations`; the
10027/// client "splits and retries". Splitting happens at build time: commits are
10028/// included IN ORDER until the operation budget is spent, the rest wait for
10029/// the next round.
10030const PUSH_OPS_PER_REQUEST: usize = 500;
10031const MAX_LOCAL_PURGE_TARGETS: usize = 64;
10032const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
10033const MAX_LOCAL_PURGE_VALUES: usize = 128;
10034const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
10035pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
10036
10037/// Dirty visible tables; `None` means a whole-replica recovery/reset.
10038struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
10039
10040impl OverlayDirty {
10041    fn clean() -> Self {
10042        Self(RefCell::new(Some(BTreeSet::new())))
10043    }
10044    fn get(&self) -> bool {
10045        self.0
10046            .borrow()
10047            .as_ref()
10048            .is_none_or(|tables| !tables.is_empty())
10049    }
10050    fn set(&self, dirty: bool) {
10051        *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
10052    }
10053    fn table(&self, table: &str) {
10054        if let Some(tables) = self.0.borrow_mut().as_mut() {
10055            tables.insert(table.to_owned());
10056        }
10057    }
10058    fn snapshot(&self) -> Option<BTreeSet<String>> {
10059        self.0.borrow().clone()
10060    }
10061    fn restore(&self, tables: Option<BTreeSet<String>>) {
10062        *self.0.borrow_mut() = tables;
10063    }
10064}
10065
10066pub struct SyncClient {
10067    #[cfg(feature = "bench-internals")]
10068    benchmark_phases: Recorder,
10069    conn: Connection,
10070    schema: ClientSchema,
10071    client_id: String,
10072    limits: ClientLimits,
10073    subs: Vec<Subscription>,
10074    outbox: Vec<OutboxCommit>,
10075    failed_commits: Vec<FailedCommit>,
10076    retain_failed_commits: bool,
10077    conflicts: Vec<ConflictRecord>,
10078    rejections: Vec<RejectionRecord>,
10079    schema_floor: Option<SchemaFloor>,
10080    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
10081    lease_state: Option<LeaseState>,
10082    /// §1.6: the schema-floor response stops syncing until an upgrade.
10083    stopped: bool,
10084    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
10085    upgrading: bool,
10086    /// §8.4 coalesced sync-needed signal.
10087    sync_needed: bool,
10088    /// §8.8: how sync rounds treat the realtime binding, and its exact
10089    /// availability state with the evidence behind a lost or refused one.
10090    realtime_policy: RealtimePolicy,
10091    realtime_state: RealtimeState,
10092    realtime_reason_code: Option<String>,
10093    realtime_retry_delay_ms: Option<u64>,
10094    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
10095    presence: HashMap<String, HashMap<String, PresencePeer>>,
10096    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
10097    /// host may pin it (conformance runs on a virtual clock).
10098    now_ms: Option<i64>,
10099    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
10100    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
10101    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
10102    encryption: crate::values::EncryptionConfig,
10103    /// Fail-closed host bootstrap gate. While set, command hosts permit only
10104    /// status/lifecycle inspection and an exact authorized local purge.
10105    security_preflight: bool,
10106    transport_enabled: bool,
10107    /// RFC 0005 D2/D8: the previous-version capture config. `None` (default)
10108    /// means the feature is off; the descriptor write and the container orphan
10109    /// sweep still happen.
10110    previous_version: Option<PreviousVersionContextConfig>,
10111    /// Per-table primary-key upsert SQL, built once per (full table name) —
10112    /// the row write path runs per row during bootstrap (§5.6), so the SQL
10113    /// string (and, via `prepare_cached`, its compiled statement) is reused
10114    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
10115    /// schema reset (the column lists may have changed).
10116    insert_sql: RefCell<HashMap<String, String>>,
10117    /// §7.1: reconcile only tables whose base/outbox changed. Whole-replica
10118    /// reset and restart explicitly invalidate every table.
10119    overlay_dirty: OverlayDirty,
10120    // First SQLite failure of the current operation, before savepoint cleanup.
10121    storage_failure: RefCell<Option<QueryReadFailure>>,
10122    /// Test-only structural performance signal: response processing must not
10123    /// turn a batch of acknowledgements into one full overlay rebuild each.
10124    #[cfg(test)]
10125    overlay_rebuild_count: Cell<usize>,
10126    #[cfg(test)]
10127    acknowledged_replay_count: Cell<usize>,
10128    /// Test-only structural performance signal: outcome retention is enforced
10129    /// once per response rather than once per acknowledgement.
10130    #[cfg(test)]
10131    outcome_prune_count: Cell<usize>,
10132    /// Exact observer-transaction output drained by command/FFI hosts.
10133    progress: ProgressObserver,
10134    change_queue: VecDeque<ClientChangeBatch>,
10135    sync_intent_queue: VecDeque<SyncIntent>,
10136    /// Explicit exponential retry policy for transient transport failures.
10137    retry_delay_ms: u64,
10138    /// §7.6: the background retry the current round scheduled, if any.
10139    round_retry_delay_ms: Option<u64>,
10140    /// A round whose response never completed (§1.5): the client keeps its
10141    /// code and reports it on every following request until a round receives a
10142    /// complete, decodable response. The server already recorded the round, so
10143    /// nothing else can tell it the round failed on this side.
10144    round_failure: Option<String>,
10145    active_round: Option<uuid::Uuid>,
10146    last_round: Option<DiagnosticLastRound>,
10147    last_change: Option<DiagnosticLastChange>,
10148    /// §7.6: latest failed owned snapshot read per owner id, oldest first.
10149    query_failures: Vec<DiagnosticQueryFailure>,
10150    /// SPEC §1.4 rule 4: the open coalesced apply transaction of a response.
10151    coalesced: Option<CoalescedApply>,
10152    /// A coalesced apply transaction of the current response failed; every
10153    /// later observation of that response fails until it ends.
10154    coalesced_failure: Option<String>,
10155}
10156
10157/// SPEC §1.4 rule 4: the frames and blocks of one response share one local
10158/// write transaction of at most `COALESCED_APPLY_ROWS` rows. The in-memory
10159/// state captured at its `BEGIN` is restored when its `COMMIT` fails.
10160struct CoalescedApply {
10161    rows: usize,
10162    subs: Vec<Subscription>,
10163    outbox: Vec<OutboxCommit>,
10164    failed_commits: Vec<FailedCommit>,
10165    conflicts: Vec<ConflictRecord>,
10166    rejections: Vec<RejectionRecord>,
10167    schema_floor: Option<SchemaFloor>,
10168    lease_state: Option<LeaseState>,
10169    stopped: bool,
10170    upgrading: bool,
10171    sync_needed: bool,
10172    overlay_dirty: Option<BTreeSet<String>>,
10173    change_queue: usize,
10174    sync_intent_queue: usize,
10175    last_change: Option<DiagnosticLastChange>,
10176}
10177
10178const COALESCED_APPLY_ROWS: usize = 1024;
10179
10180pub(crate) fn quote_ident(name: &str) -> String {
10181    format!("\"{}\"", name.replace('"', "\"\""))
10182}
10183/// §7.4.1 `_syncular_meta` helpers. Free functions so RFC 0005's
10184/// previous-version module reads and writes the SAME records as the client
10185/// instead of growing a parallel meta layer.
10186pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
10187    conn.query_row(
10188        "SELECT value FROM _syncular_meta WHERE key = ?1",
10189        rusqlite::params![key],
10190        |row| row.get::<_, String>(0),
10191    )
10192    .ok()
10193}
10194
10195pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
10196    let _ = conn.execute(
10197        "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
10198        rusqlite::params![key, value],
10199    );
10200}
10201
10202pub(crate) fn meta_delete(conn: &Connection, key: &str) {
10203    let _ = conn.execute(
10204        "DELETE FROM _syncular_meta WHERE key = ?1",
10205        rusqlite::params![key],
10206    );
10207}
10208
10209/// Seek by the existing key while retaining the exact legacy text match.
10210fn row_id_predicate(table: &TableSchema) -> String {
10211    let key = quote_ident(&table.primary_key);
10212    let text_match = format!("CAST({key} AS TEXT) = ?1");
10213    match table.columns[table.pk_index].ty {
10214        ColumnType::String | ColumnType::Json => {
10215            format!("{key} = ?1 AND {text_match}")
10216        }
10217        // Native columns have no declared SQLite affinity. Match the stored
10218        // integer bind type explicitly. Unary + removes expression affinity so
10219        // SQLite can seek the typeless key; the text check preserves exact IDs.
10220        ColumnType::Integer | ColumnType::Boolean => {
10221            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
10222        }
10223        // REAL-to-text conversion can round distinct values to the same text.
10224        // Narrowing those matches by numeric equality would change behavior.
10225        _ => text_match,
10226    }
10227}
10228
10229fn is_local_operation_code_like(value: &str) -> bool {
10230    let bytes = value.as_bytes();
10231    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
10232        && bytes
10233            .iter()
10234            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
10235}
10236
10237fn base_table(name: &str) -> String {
10238    quote_ident(&format!("_syncular_base_{name}"))
10239}
10240
10241/// §4.8: a stable, server-opaque key for a window base — table + variable +
10242/// canonical fixed scopes. Two `set_window` calls with the same base
10243/// address the same registry rows.
10244/// §4.8 deferred eviction record: (sub id, table, effective scope map).
10245type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
10246
10247fn window_base_key(base: &WindowBase) -> String {
10248    format!(
10249        "{}\0{}\0{}",
10250        base.table,
10251        base.variable,
10252        canonical_scope_json(&base.fixed_scopes)
10253    )
10254}
10255
10256/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
10257fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
10258    let mut scopes = base.fixed_scopes.clone();
10259    scopes.retain(|(k, _)| k != &base.variable);
10260    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
10261    scopes
10262}
10263
10264/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
10265/// echoed not interpreted by the server, so the exact hash is client
10266/// convention; SHA-256 matches the SPEC's worked example.
10267fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
10268    let canonical = canonical_scope_json(&unit_scopes(base, unit));
10269    let digest = Sha256::digest(canonical.as_bytes());
10270    let hex = bytes_to_hex(&digest);
10271    format!("w:{}:{}", base.table, &hex[..16])
10272}
10273
10274fn visible_table(name: &str) -> String {
10275    quote_ident(name)
10276}
10277
10278const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
10279
10280/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
10281/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
10282fn is_synced_table_name(name: &str) -> bool {
10283    if name.starts_with("sqlite_") {
10284        return false;
10285    }
10286    if name.starts_with("_syncular_base_") {
10287        return true; // the base half of a synced table pair
10288    }
10289    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
10290    !name.starts_with("_syncular_")
10291}
10292
10293/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
10294fn blob_id_for(bytes: &[u8]) -> String {
10295    let digest = Sha256::digest(bytes);
10296    format!("sha256:{}", bytes_to_hex(&digest))
10297}
10298
10299/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
10300/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
10301/// per row on the §5.6 bootstrap path), scalars bind owned.
10302enum RowParam<'a> {
10303    Cell(&'a Option<ColumnValue>),
10304    Version(i64),
10305}
10306
10307impl rusqlite::ToSql for RowParam<'_> {
10308    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
10309        Ok(match self {
10310            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
10311            RowParam::Cell(cell) => match cell {
10312                None => ToSqlOutput::Owned(SqlValue::Null),
10313                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
10314                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
10315                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
10316                Some(ColumnValue::Boolean(b)) => {
10317                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
10318                }
10319                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
10320                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
10321                }
10322                // §5.10: crdt bytes store as BLOB, like bytes.
10323                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
10324                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
10325                }
10326            },
10327        })
10328    }
10329}
10330
10331// Retain a typed primary key across the current import chunk without copying row payloads.
10332fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
10333    match param.to_sql().map_err(|error| error.to_string())? {
10334        ToSqlOutput::Owned(value) => Ok(value),
10335        ToSqlOutput::Borrowed(value) => {
10336            SqlValue::try_from(value).map_err(|error| error.to_string())
10337        }
10338        _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
10339    }
10340}
10341
10342/// §5.3 image cell → bind parameter, strict per the declared column type
10343/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
10344/// nullable). Mismatches are image-producer violations. Returns the cell
10345/// borrowed when the stored representation already matches what the row
10346/// codec would write, or the normalized scalar (boolean → 0/1, float from
10347/// INTEGER → REAL) otherwise — the local write is byte-identical to the
10348/// old convert-then-insert path without allocating per cell.
10349fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
10350    use ssp2::segment::ColumnType;
10351    let mismatch = || {
10352        Err(format!(
10353            "image column {:?} holds a value of the wrong type",
10354            column.name
10355        ))
10356    };
10357    match value {
10358        ValueRef::Null => {
10359            if !column.nullable {
10360                return Err(format!(
10361                    "image column {:?} is NULL but not nullable",
10362                    column.name
10363                ));
10364            }
10365            Ok(ToSqlOutput::Owned(SqlValue::Null))
10366        }
10367        ValueRef::Integer(i) => match column.ty {
10368            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
10369            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
10370            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
10371            _ => mismatch(),
10372        },
10373        ValueRef::Real(_) => match column.ty {
10374            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
10375            _ => mismatch(),
10376        },
10377        ValueRef::Text(t) => {
10378            std::str::from_utf8(t)
10379                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
10380            match column.ty {
10381                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
10382                    Ok(ToSqlOutput::Borrowed(value))
10383                }
10384                _ => mismatch(),
10385            }
10386        }
10387        ValueRef::Blob(_) => match column.ty {
10388            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
10389            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
10390            _ => mismatch(),
10391        },
10392    }
10393}
10394
10395pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
10396    use rusqlite::types::ValueRef;
10397    match value {
10398        ValueRef::Null => Value::Null,
10399        ValueRef::Integer(i) => match column.ty {
10400            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
10401            ssp2::segment::ColumnType::Float => {
10402                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
10403            }
10404            _ => Value::from(i),
10405        },
10406        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
10407        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
10408        ValueRef::Blob(b) => {
10409            let mut map = Map::new();
10410            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
10411            Value::Object(map)
10412        }
10413    }
10414}
10415
10416/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
10417/// Objects are accepted in lossless `{"$bytes": hex}` and
10418/// `{"$bigint": decimal}` envelope forms.
10419fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
10420    Ok(match value {
10421        Value::Null => SqlValue::Null,
10422        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
10423        Value::Number(n) => {
10424            if let Some(i) = n.as_i64() {
10425                SqlValue::Integer(i)
10426            } else if let Some(f) = n.as_f64() {
10427                SqlValue::Real(f)
10428            } else {
10429                return Err(format!("query param number {n} is out of range"));
10430            }
10431        }
10432        Value::String(s) => SqlValue::Text(s.clone()),
10433        Value::Object(_) => {
10434            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
10435                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
10436            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
10437                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
10438                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
10439                })?)
10440            } else {
10441                return Err(
10442                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
10443                        .to_owned(),
10444                );
10445            }
10446        }
10447        Value::Array(_) => return Err("query array params are not supported".to_owned()),
10448    })
10449}
10450
10451/// Map a rusqlite value with no schema column to consult (arbitrary query
10452/// output): integers/reals/text pass through by stored affinity, blobs ride
10453/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
10454/// schema column type to recover booleans/floats/json.
10455fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
10456    use rusqlite::types::ValueRef;
10457    match value {
10458        ValueRef::Null => Value::Null,
10459        ValueRef::Integer(i) => {
10460            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
10461            // ordinary UI-sized integers ergonomic and envelope only values
10462            // beyond JavaScript's safe range.
10463            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
10464            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
10465                Value::from(i)
10466            } else {
10467                let mut map = Map::new();
10468                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
10469                Value::Object(map)
10470            }
10471        }
10472        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
10473        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
10474        ValueRef::Blob(b) => {
10475            let mut map = Map::new();
10476            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
10477            Value::Object(map)
10478        }
10479    }
10480}
10481
10482/// §7.5: SQLite failures classify by result code into [`QueryReadFailure`].
10483fn query_connection(
10484    conn: &Connection,
10485    sql: &str,
10486    params: &[QueryValue],
10487) -> Result<Vec<QueryRow>, QueryReadFailure> {
10488    crate::query_guard::assert_read_only_query(sql)?;
10489    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
10490    let bound: Vec<SqlValue> = params
10491        .iter()
10492        .map(json_param_to_sql)
10493        .collect::<Result<_, _>>()?;
10494    let mut stmt = conn.prepare(&lowered_sql)?;
10495    let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
10496    let bound_refs: Vec<&dyn rusqlite::ToSql> =
10497        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
10498    let mut sql_rows = stmt.query(bound_refs.as_slice())?;
10499    let mut out = Vec::new();
10500    while let Some(row) = sql_rows.next()? {
10501        let mut record = Map::new();
10502        for (i, name) in column_names.iter().enumerate() {
10503            let value = row.get_ref(i)?;
10504            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
10505        }
10506        out.push(record);
10507    }
10508    Ok(out)
10509}
10510
10511fn persisted_window_state(
10512    conn: &Connection,
10513    base: &WindowBase,
10514) -> Result<WindowState, QueryReadFailure> {
10515    let mut stmt = conn.prepare(
10516        "SELECT windows.unit, subscriptions.state_json
10517           FROM _syncular_windows AS windows
10518           JOIN _syncular_subscriptions AS subscriptions
10519             ON subscriptions.id = windows.sub_id
10520          WHERE windows.base = ?1
10521          ORDER BY windows.unit ASC",
10522    )?;
10523    let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
10524        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
10525    })?;
10526    let mut units = Vec::new();
10527    let mut pending = Vec::new();
10528    for row in rows {
10529        let (unit, raw) = row?;
10530        let state: Value = serde_json::from_str(&raw)
10531            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
10532        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
10533            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
10534            || state
10535                .get("bootstrapState")
10536                .is_some_and(|value| !value.is_null());
10537        if is_pending {
10538            pending.push(unit.clone());
10539        }
10540        units.push(unit);
10541    }
10542    Ok(WindowState { units, pending })
10543}
10544
10545/// One read-only statement in a §7.5 snapshot read.
10546#[derive(Debug, Clone, Copy)]
10547pub struct SnapshotStatement<'a> {
10548    pub sql: &'a str,
10549    pub params: &'a [Value],
10550}
10551
10552/// A §7.5 atomic read request: read-only statements, window coverage,
10553/// subscription catch-up states, and commit deliveries, all resolved from one
10554/// SQLite read transaction and one revision.
10555#[derive(Debug, Clone, Default)]
10556pub struct SnapshotReadRequest<'a> {
10557    pub statements: Vec<SnapshotStatement<'a>>,
10558    pub coverage: &'a [WindowCoverage],
10559    pub subscriptions: Vec<String>,
10560    pub commit_ids: Vec<String>,
10561}
10562
10563#[cfg(test)]
10564thread_local! {
10565    /// Test-only rendezvous that runs between the statements of one snapshot
10566    /// read. A test uses it to commit on another connection mid-read and prove
10567    /// the read transaction's snapshot and revision stay stable.
10568    static SNAPSHOT_READ_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
10569        std::cell::RefCell::new(None);
10570}
10571
10572#[cfg(test)]
10573fn run_snapshot_read_hook() {
10574    SNAPSHOT_READ_HOOK.with(|slot| {
10575        if let Some(hook) = slot.borrow_mut().take() {
10576            hook();
10577        }
10578    });
10579}
10580
10581/// §7.5: resolve a request over one SQLite read transaction. The revision,
10582/// every statement's rows, coverage, catch-up states, and deliveries are
10583/// captured under one `SAVEPOINT`; any statement's failure rolls the whole
10584/// read back and releases the connection.
10585fn snapshot_read_connection(
10586    conn: &Connection,
10587    request: &SnapshotReadRequest<'_>,
10588) -> Result<SnapshotRead, QueryReadFailure> {
10589    conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
10590    let corrupt = |message: &str| QueryReadFailure {
10591        code: Some("sync.local_corrupt"),
10592        sqlite_code: None,
10593        sqlite_message: None,
10594        rollback_failure: None,
10595        message: message.to_owned(),
10596    };
10597    let stored_failure = |error: rusqlite::Error, message: &str| match error {
10598        rusqlite::Error::Utf8Error(..)
10599        | rusqlite::Error::InvalidColumnType(..)
10600        | rusqlite::Error::FromSqlConversionFailure(..)
10601        | rusqlite::Error::IntegralValueOutOfRange(..) => corrupt(message),
10602        _ => QueryReadFailure::from(error),
10603    };
10604    let result: Result<SnapshotRead, QueryReadFailure> = (|| {
10605        // The durable revision as a canonical decimal `u64`; a missing marker
10606        // is the legacy zero, and any other non-canonical value is corrupt.
10607        let revision: Option<String> = conn
10608            .query_row(
10609                "SELECT value FROM _syncular_meta WHERE key = ?1",
10610                rusqlite::params![LOCAL_REVISION_KEY],
10611                |row| row.get(0),
10612            )
10613            .optional()
10614            .map_err(|error| stored_failure(error, "persisted local revision is invalid"))?;
10615        let revision = match revision {
10616            None => "0".to_owned(),
10617            Some(value)
10618                if value == "0"
10619                    || (matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
10620                        && value.bytes().all(|b| b.is_ascii_digit())
10621                        && value.parse::<u64>().is_ok()) =>
10622            {
10623                value
10624            }
10625            Some(_) => return Err(corrupt("persisted local revision is invalid")),
10626        };
10627        let mut queries = Vec::with_capacity(request.statements.len());
10628        for statement in &request.statements {
10629            queries.push(query_connection(conn, statement.sql, statement.params)?);
10630            #[cfg(test)]
10631            run_snapshot_read_hook();
10632        }
10633        let mut pending = Vec::new();
10634        let mut missing = Vec::new();
10635        for requested in request.coverage {
10636            let base_key = window_base_key(&requested.base);
10637            let state = persisted_window_state(conn, &requested.base)?;
10638            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
10639                let reference = WindowUnitRef {
10640                    base_key: base_key.clone(),
10641                    unit: unit.clone(),
10642                };
10643                if !state.units.iter().any(|held| held == &unit) {
10644                    missing.push(reference);
10645                } else if state.pending.iter().any(|held| held == &unit) {
10646                    pending.push(reference);
10647                }
10648            }
10649        }
10650        let mut subscriptions = Vec::with_capacity(request.subscriptions.len());
10651        for id in &request.subscriptions {
10652            let row: Option<(String, String)> = conn
10653                .query_row(
10654                    "SELECT tbl, state_json FROM _syncular_subscriptions WHERE id = ?1",
10655                    rusqlite::params![id],
10656                    |row| Ok((row.get(0)?, row.get(1)?)),
10657                )
10658                .optional()
10659                .map_err(|error| {
10660                    stored_failure(error, "persisted subscription state is invalid")
10661                })?;
10662            let Some((table, raw)) = row else {
10663                subscriptions.push(SubscriptionCatchup::Unknown { id: id.clone() });
10664                continue;
10665            };
10666            // Every persisted field is required to be well typed; a corrupt
10667            // record fails typed instead of inventing a state.
10668            let state: Value = serde_json::from_str(&raw)
10669                .map_err(|_| corrupt("persisted subscription state is invalid"))?;
10670            let status = match state.get("status").and_then(Value::as_str) {
10671                Some(value @ ("active" | "revoked" | "failed")) => value,
10672                _ => return Err(corrupt("persisted subscription state is invalid")),
10673            };
10674            let Some(cursor) = state.get("cursor").and_then(Value::as_i64) else {
10675                return Err(corrupt("persisted subscription state is invalid"));
10676            };
10677            if cursor.unsigned_abs() > MAX_JS_SAFE_INTEGER {
10678                return Err(corrupt("persisted subscription state is invalid"));
10679            }
10680            let effective_scopes = state
10681                .get("effectiveScopes")
10682                .filter(|value| !value.is_null());
10683            if let Some(scopes) = effective_scopes {
10684                json_to_scope_map(scopes)
10685                    .map_err(|_| corrupt("persisted subscription state is invalid"))?;
10686            }
10687            let has_resume_token = match state.get("bootstrapState") {
10688                None | Some(Value::Null) => false,
10689                Some(Value::String(_)) => true,
10690                Some(_) => return Err(corrupt("persisted subscription state is invalid")),
10691            };
10692            // A present reasonCode must be a string; a non-string value is a
10693            // corrupt record, never silently dropped.
10694            let reason_code = match state.get("reasonCode") {
10695                None | Some(Value::Null) => None,
10696                Some(Value::String(value)) => Some(value.clone()),
10697                Some(_) => return Err(corrupt("persisted subscription state is invalid")),
10698            };
10699            let active = status == "active";
10700            subscriptions.push(SubscriptionCatchup::Known {
10701                id: id.clone(),
10702                table,
10703                status: status.to_owned(),
10704                cursor,
10705                has_resume_token,
10706                bootstrap_complete: active && cursor >= 0 && !has_resume_token,
10707                known_pending_pages: active && (has_resume_token || cursor < 0),
10708                effective_scopes: effective_scopes.cloned(),
10709                reason_code,
10710            });
10711        }
10712        let mut deliveries = Vec::with_capacity(request.commit_ids.len());
10713        for id in &request.commit_ids {
10714            let pending: bool = conn.query_row(
10715                "SELECT EXISTS(SELECT 1 FROM _syncular_outbox WHERE commit_id = ?1)",
10716                rusqlite::params![id],
10717                |row| row.get(0),
10718            )?;
10719            if pending {
10720                deliveries.push(CommitDelivery::Pending {
10721                    client_commit_id: id.clone(),
10722                });
10723                continue;
10724            }
10725            let row = conn
10726                .query_row(
10727                    "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
10728                            resolution, resolved_at_ms, replacement_client_commit_id
10729                       FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
10730                    rusqlite::params![id],
10731                    StoredCommitOutcomeRow::from_row,
10732                )
10733                .optional()
10734                .map_err(|error| stored_failure(error, "persisted commit outcome is invalid"))?;
10735            deliveries.push(match row {
10736                Some(row) => CommitDelivery::Known {
10737                    client_commit_id: id.clone(),
10738                    outcome: SyncClient::outcome_from_row(row)
10739                        .map_err(|_| corrupt("persisted commit outcome is invalid"))?,
10740                },
10741                None => CommitDelivery::Unknown {
10742                    client_commit_id: id.clone(),
10743                },
10744            });
10745        }
10746        Ok(SnapshotRead {
10747            revision,
10748            queries,
10749            coverage: CoverageSnapshot {
10750                complete: pending.is_empty() && missing.is_empty(),
10751                pending,
10752                missing,
10753            },
10754            subscriptions,
10755            deliveries,
10756        })
10757    })();
10758    match result {
10759        Ok(read) => {
10760            conn.execute_batch("RELEASE syncular_snapshot_read")?;
10761            Ok(read)
10762        }
10763        Err(mut error) => {
10764            if let Err(rollback) = conn
10765                .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
10766            {
10767                error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
10768            }
10769            Err(error)
10770        }
10771    }
10772}
10773
10774fn snapshot_connection(
10775    conn: &Connection,
10776    sql: &str,
10777    params: &[Value],
10778    coverage: &[WindowCoverage],
10779) -> Result<QuerySnapshot, QueryReadFailure> {
10780    let read = snapshot_read_connection(
10781        conn,
10782        &SnapshotReadRequest {
10783            statements: vec![SnapshotStatement { sql, params }],
10784            coverage,
10785            subscriptions: Vec::new(),
10786            commit_ids: Vec::new(),
10787        },
10788    )?;
10789    Ok(QuerySnapshot {
10790        revision: read.revision,
10791        rows: read
10792            .queries
10793            .into_iter()
10794            .next()
10795            .expect("single-query snapshot returns one result"),
10796        coverage: read.coverage,
10797    })
10798}
10799
10800/// A long-lived read-only SQLite sidecar for latency-critical native views.
10801/// Network rounds stay serialized on the mutable core owner, while atomic
10802/// query snapshots use this independent connection and therefore never queue
10803/// behind HTTP/WebSocket latency.
10804pub struct FileQuerySnapshotReader {
10805    path: String,
10806    conn: Option<Connection>,
10807}
10808
10809impl FileQuerySnapshotReader {
10810    #[must_use]
10811    pub fn new(path: impl Into<String>) -> Self {
10812        Self {
10813            path: path.into(),
10814            conn: None,
10815        }
10816    }
10817
10818    fn connection(&mut self) -> Result<&Connection, QueryReadFailure> {
10819        if self.conn.is_none() {
10820            let conn = Connection::open_with_flags(
10821                &self.path,
10822                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
10823            )
10824            .map_err(QueryReadFailure::from)?;
10825            conn.busy_timeout(std::time::Duration::from_millis(250))
10826                .map_err(QueryReadFailure::from)?;
10827            self.conn = Some(conn);
10828        }
10829        self.conn
10830            .as_ref()
10831            .ok_or_else(|| QueryReadFailure::from("read sidecar connection missing".to_owned()))
10832    }
10833
10834    /// One atomic read over the shared primitive: every statement, coverage,
10835    /// catch-up, and delivery under one `SAVEPOINT` and one revision.
10836    pub fn snapshot_read(
10837        &mut self,
10838        request: &SnapshotReadRequest<'_>,
10839    ) -> Result<SnapshotRead, QueryReadFailure> {
10840        snapshot_read_connection(self.connection()?, request)
10841    }
10842
10843    /// The current durable local revision. A host can skip a read when it is
10844    /// unchanged; a corrupt or unreadable marker fails typed.
10845    pub fn local_revision(&mut self) -> Result<String, QueryReadFailure> {
10846        Ok(self
10847            .snapshot_read(&SnapshotReadRequest::default())?
10848            .revision)
10849    }
10850
10851    /// One subscription's persisted catch-up state (see the §7.5 contract).
10852    pub fn subscription_catchup(
10853        &mut self,
10854        id: &str,
10855    ) -> Result<SubscriptionCatchup, QueryReadFailure> {
10856        let read = self.snapshot_read(&SnapshotReadRequest {
10857            subscriptions: vec![id.to_owned()],
10858            ..Default::default()
10859        })?;
10860        Ok(read
10861            .subscriptions
10862            .into_iter()
10863            .next()
10864            .expect("one requested catch-up"))
10865    }
10866
10867    /// One client commit's delivery status: pending, its retained outcome, or
10868    /// an explicit unknown (never held, or pruned by retention).
10869    pub fn commit_delivery(&mut self, id: &str) -> Result<CommitDelivery, QueryReadFailure> {
10870        let read = self.snapshot_read(&SnapshotReadRequest {
10871            commit_ids: vec![id.to_owned()],
10872            ..Default::default()
10873        })?;
10874        Ok(read
10875            .deliveries
10876            .into_iter()
10877            .next()
10878            .expect("one requested delivery"))
10879    }
10880
10881    /// Hosts report an owned failure, and the first success after it, to the
10882    /// owning core through [`SyncClient::record_query_read`] (§7.6).
10883    pub fn query_snapshot(
10884        &mut self,
10885        sql: &str,
10886        params: &[Value],
10887        coverage: &[WindowCoverage],
10888    ) -> Result<QuerySnapshot, QueryReadFailure> {
10889        snapshot_connection(self.connection()?, sql, params, coverage)
10890    }
10891}
10892
10893fn validate_authority_reads(
10894    schema: &ClientSchema,
10895    declarations: &[crate::api::AuthorityReadDeclaration],
10896) -> Result<(), String> {
10897    let mut seen = BTreeSet::new();
10898    for read in declarations {
10899        let table = schema
10900            .table(&read.table)
10901            .ok_or("client.authority_read_forbidden: authority table is not declared")?;
10902        let plain = |name: &str| {
10903            table.columns.iter().enumerate().any(|(i, c)| {
10904                c.name == name
10905                    && name != "authority_version"
10906                    && !name.starts_with("_sync")
10907                    && !table.encrypted_columns.iter().any(|e| e.index == i)
10908                    && matches!(
10909                        c.ty,
10910                        ssp2::segment::ColumnType::String
10911                            | ssp2::segment::ColumnType::Integer
10912                            | ssp2::segment::ColumnType::Float
10913                            | ssp2::segment::ColumnType::Boolean
10914                            | ssp2::segment::ColumnType::Json
10915                    )
10916            })
10917        };
10918        if !seen.insert(&read.table)
10919            || read.columns.is_empty()
10920            || read.columns.iter().collect::<BTreeSet<_>>().len() != read.columns.len()
10921            || !read.columns.contains(&table.primary_key)
10922            || !read.columns.iter().all(|c| plain(c))
10923            || read.scopes.is_empty()
10924            || !read.scopes.iter().all(|(v, values)| {
10925                table
10926                    .scope_column(v)
10927                    .is_some_and(|c| read.columns.iter().any(|column| column == c) && plain(c))
10928                    && !values.is_empty()
10929                    && values.iter().all(|v| !v.is_empty() && v != "*")
10930            })
10931        {
10932            return Err("client.authority_read_forbidden: authority reads require plain columns, a primary key and nonempty plain scope selectors".into());
10933        }
10934    }
10935    Ok(())
10936}
10937
10938// §7.4.2: every open and recreation checks before any persistent writes.
10939/// A marker read that hit a recognized SQLite storage failure keeps that
10940/// classification (BUSY/LOCKED stay retryable); only an unclassified read
10941/// failure is the invalid-marker `sync.local_corrupt`.
10942fn schema_marker_read_failure(error: rusqlite::Error) -> String {
10943    let failure = QueryReadFailure::from(error);
10944    if failure.code.is_some() {
10945        failure.to_string()
10946    } else {
10947        "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned()
10948    }
10949}
10950
10951/// §7.1: reject an invalid configured client limit before any storage is
10952/// opened or written. Zero would wedge the push queue and an out-of-range
10953/// value would differ per target; the TS core enforces the same bounds.
10954fn validate_client_limits(limits: &crate::api::ClientLimits) -> Result<(), String> {
10955    if limits.outcome_retention_max_entries == Some(0) {
10956        return Err("sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned());
10957    }
10958    if [
10959        limits.max_push_commits_per_request,
10960        limits.max_push_operations_per_request,
10961        limits.max_push_request_bytes,
10962    ]
10963    .into_iter()
10964    .flatten()
10965    .any(|value| value == 0 || value > u32::MAX as usize)
10966    {
10967        return Err(
10968            "sync.invalid_request: push limit must be an integer in 1..=4294967295".to_owned(),
10969        );
10970    }
10971    Ok(())
10972}
10973
10974fn read_local_schema_version(conn: &Connection, requested: i32) -> Result<Option<i32>, String> {
10975    let corrupt =
10976        || "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned();
10977    let table_type: Option<String> = conn
10978        .query_row(
10979            "SELECT type FROM sqlite_master WHERE name = '_syncular_meta'",
10980            [],
10981            |row| row.get(0),
10982        )
10983        .optional()
10984        .map_err(schema_marker_read_failure)?;
10985    let Some(table_type) = table_type else {
10986        return Ok(None);
10987    };
10988    if table_type != "table" {
10989        return Err(corrupt());
10990    }
10991    // §7.4.2: a duplicated marker cannot be resolved. `query_row` would take
10992    // whichever row SQLite returns first, and an older one would drive a
10993    // destructive reset over a newer replica.
10994    let mut statement = conn
10995        .prepare("SELECT value FROM _syncular_meta WHERE key = ?1")
10996        .map_err(schema_marker_read_failure)?;
10997    let mut rows = statement
10998        .query([LOCAL_SCHEMA_VERSION_KEY])
10999        .map_err(schema_marker_read_failure)?;
11000    let value: Option<String> = rows
11001        .next()
11002        .map_err(schema_marker_read_failure)?
11003        .map(|row| row.get(0))
11004        .transpose()
11005        .map_err(schema_marker_read_failure)?;
11006    if rows.next().map_err(schema_marker_read_failure)?.is_some() {
11007        return Err(corrupt());
11008    }
11009    let Some(value) = value else {
11010        let has_descriptor = conn
11011            .query_row(
11012                "SELECT 1 FROM _syncular_meta WHERE key = ?1 LIMIT 1",
11013                [crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY],
11014                |_| Ok(()),
11015            )
11016            .optional()
11017            .map_err(schema_marker_read_failure)?
11018            .is_some();
11019        if has_descriptor {
11020            return Err(corrupt());
11021        }
11022        return Ok(None);
11023    };
11024    if !matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
11025        || !value.bytes().all(|b| b.is_ascii_digit())
11026    {
11027        return Err(corrupt());
11028    }
11029    let version = value.parse::<i32>().map_err(|_| corrupt())?;
11030    if version > requested {
11031        return Err(
11032            "client.schema_downgrade: persisted local schema is newer than the requested schema"
11033                .to_owned(),
11034        );
11035    }
11036    Ok(Some(version))
11037}
11038
11039impl SyncClient {
11040    pub fn new_with_identity(
11041        client_id: Option<String>,
11042        schema_json: &Value,
11043        limits: ClientLimits,
11044    ) -> Result<Self, String> {
11045        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
11046        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11047        Self::with_connection(resolved, schema_json, limits, conn)
11048    }
11049
11050    pub fn new(
11051        client_id: String,
11052        schema_json: &Value,
11053        limits: ClientLimits,
11054    ) -> Result<Self, String> {
11055        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11056        Self::with_connection(client_id, schema_json, limits, conn)
11057    }
11058
11059    /// Build a client backed by an on-disk SQLite database at `path` — the
11060    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
11061    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
11062    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
11063    /// of the command router's dependency set (the router only holds a path).
11064    pub fn open_path(
11065        client_id: String,
11066        schema_json: &Value,
11067        limits: ClientLimits,
11068        path: &str,
11069    ) -> Result<Self, String> {
11070        validate_client_limits(&limits)?;
11071        parse_schema_json(schema_json)?;
11072        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11073        Self::with_connection(client_id, schema_json, limits, conn)
11074    }
11075
11076    pub fn open_path_with_identity(
11077        client_id: Option<String>,
11078        schema_json: &Value,
11079        limits: ClientLimits,
11080        path: &str,
11081    ) -> Result<Self, String> {
11082        validate_client_limits(&limits)?;
11083        // §7.4.2: an invalid requested version is refused before the replica is
11084        // even created, so a rejected constructor leaves no file behind.
11085        parse_schema_json(schema_json)?;
11086        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11087        // A file-backed native client uses an independent read connection for
11088        // latency-critical snapshots. WAL is SQLite's intended reader/writer
11089        // concurrency mode: a view read never holds a rollback-journal lock
11090        // that delays the client's next commit. The busy timeout must precede
11091        // this replica's own startup transaction; WAL is applied only after
11092        // startup succeeded, because a refused open must leave the replica's
11093        // journal mode and contents untouched (§7.4.2).
11094        conn.busy_timeout(std::time::Duration::from_millis(250))
11095            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
11096        let client = Self::with_connection_identity(client_id, schema_json, limits, conn)?;
11097        let journal_mode: String = client
11098            .conn
11099            .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
11100            .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
11101        if journal_mode != "wal" {
11102            return Err(
11103                "sync.invalid_request: file-backed client requires WAL journal mode".to_owned(),
11104            );
11105        }
11106        Ok(client)
11107    }
11108
11109    #[must_use]
11110    pub fn client_id(&self) -> &str {
11111        &self.client_id
11112    }
11113
11114    /// Build a client over a caller-supplied rusqlite connection — the seam a
11115    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
11116    /// with an on-disk database (`Connection::open(path)`) rather than the
11117    /// default `:memory:`. The connection MUST be fresh (no pre-existing
11118    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
11119    /// the same file across process restarts reuses the persisted rows.
11120    pub fn with_connection(
11121        client_id: String,
11122        schema_json: &Value,
11123        limits: ClientLimits,
11124        conn: Connection,
11125    ) -> Result<Self, String> {
11126        Self::with_connection_identity(Some(client_id), schema_json, limits, conn)
11127    }
11128
11129    /// [`Self::with_connection`] for `open_path_with_identity`: no requested id
11130    /// means a durable replica's persisted `clientId` wins over a fresh one.
11131    fn with_connection_identity(
11132        client_id: Option<String>,
11133        schema_json: &Value,
11134        limits: ClientLimits,
11135        conn: Connection,
11136    ) -> Result<Self, String> {
11137        validate_client_limits(&limits)?;
11138        let schema = parse_schema_json(schema_json)?;
11139        validate_authority_reads(&schema, &limits.authority_reads)?;
11140        // RFC 0005 D8: resolve the previous-version config before the opening
11141        // reset runs — the capture happens inside it. Bad bounds fail loud.
11142        let previous_version = match limits.previous_version_context {
11143            Some(config) => {
11144                config.validate()?;
11145                Some(config)
11146            }
11147            None => None,
11148        };
11149        let mut client = SyncClient {
11150            #[cfg(feature = "bench-internals")]
11151            benchmark_phases: Recorder::default(),
11152            conn,
11153            schema,
11154            client_id: client_id
11155                .clone()
11156                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
11157            limits,
11158            subs: Vec::new(),
11159            outbox: Vec::new(),
11160            failed_commits: Vec::new(),
11161            retain_failed_commits: false,
11162            conflicts: Vec::new(),
11163            rejections: Vec::new(),
11164            schema_floor: None,
11165            lease_state: None,
11166            stopped: false,
11167            upgrading: false,
11168            sync_needed: false,
11169            realtime_policy: RealtimePolicy::Optional,
11170            realtime_state: RealtimeState::Disconnected,
11171            realtime_reason_code: None,
11172            realtime_retry_delay_ms: None,
11173            presence: HashMap::new(),
11174            now_ms: None,
11175            encryption: crate::values::EncryptionConfig::default(),
11176            security_preflight: false,
11177            transport_enabled: true,
11178            previous_version,
11179            insert_sql: RefCell::new(HashMap::new()),
11180            overlay_dirty: OverlayDirty::clean(),
11181            storage_failure: RefCell::new(None),
11182            #[cfg(test)]
11183            overlay_rebuild_count: Cell::new(0),
11184            #[cfg(test)]
11185            acknowledged_replay_count: Cell::new(0),
11186            #[cfg(test)]
11187            outcome_prune_count: Cell::new(0),
11188            progress: ProgressObserver::default(),
11189            change_queue: VecDeque::new(),
11190            sync_intent_queue: VecDeque::new(),
11191            retry_delay_ms: 250,
11192            round_retry_delay_ms: None,
11193            round_failure: None,
11194            active_round: None,
11195            last_round: None,
11196            last_change: None,
11197            query_failures: Vec::new(),
11198            coalesced: None,
11199            coalesced_failure: None,
11200        };
11201        // The row write path leans on the prepared-statement cache (two
11202        // insert statements per synced table, plus the bookkeeping
11203        // statements); size it so a multi-table schema never thrashes.
11204        client
11205            .conn
11206            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
11207        // §7.4.2: one write transaction (SQLite `BEGIN IMMEDIATE`, a reserved
11208        // writer) covers the marker read, the identity check, and every
11209        // bookkeeping/schema write, so a concurrent open that upgraded the
11210        // replica cannot interleave between the guard and the writes, and a
11211        // refusal leaves the replica unchanged.
11212        client
11213            .conn
11214            .execute_batch("BEGIN IMMEDIATE")
11215            .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
11216        let startup = (|| -> Result<(), String> {
11217            // The marker guard runs inside the write transaction, so the version
11218            // it validates is the version every write below commits under.
11219            let marker = read_local_schema_version(&client.conn, client.schema.version)?;
11220            // New app indexes may reference columns that only exist after a
11221            // version-bump reset.
11222            client.create_bookkeeping_tables()?;
11223            match client.get_meta(CLIENT_ID_KEY) {
11224                Some(existing) => match client_id {
11225                    Some(requested) if existing != requested => {
11226                        return Err(format!(
11227                            "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
11228                        ))
11229                    }
11230                    _ => client.client_id = existing,
11231                },
11232                None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
11233            }
11234            client.restore_persisted_state()?;
11235            client.failed_commits = client.load_failed_commits()?;
11236            match marker {
11237                None => {
11238                    client.create_synced_tables()?;
11239                    client.persist_schema_version()?;
11240                    client.save_subscription_scope_schema();
11241                }
11242                Some(version) if version == client.schema.version => {
11243                    client.create_synced_tables()?;
11244                    // RFC 0005 D1: the same-version open backfills the descriptor
11245                    // for a database first opened by an unaware binary, so the
11246                    // NEXT bump can capture without needing a schema bump first.
11247                    set_local_schema_descriptor(&client.conn, &client.schema)
11248                        .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
11249                    client.save_subscription_scope_schema();
11250                }
11251                Some(_) => client.run_schema_reset()?,
11252            }
11253            // RFC 0005 D9: the reset sweep cannot see a container whose capture
11254            // committed in its OWN file while the replica's savepoint never
11255            // released — a crash at that seam leaves a container beside a matching
11256            // OLD marker, and the next open at that version runs no reset. Discard
11257            // it before anything can read it. No-op when no container exists.
11258            client.reconcile_previous_version_at_boot()?;
11259            client.clear_satisfied_persisted_schema_floor();
11260            client.prune_unknown_subscriptions(false)?;
11261            // §7.1/§7.4.4: recover intrinsically codec-invalid persisted values at
11262            // the full startup/reset reconciliation boundary, inside this open
11263            // transaction and after the schema and identity guards, so a commit
11264            // an earlier version accepted is dropped with durable rejection
11265            // evidence before any replay. A table the current schema removed
11266            // keeps the §7.4.4 send-time classification, and an unresolved
11267            // encryption key stays with the send seam because hosts configure
11268            // keys after open.
11269            client.drop_unencodable_outbox(false, None)?;
11270            if marker == Some(client.schema.version)
11271                && (!client.outbox.is_empty()
11272                    || !client.failed_commits.is_empty()
11273                    || client.has_acknowledged_rows())
11274            {
11275                // Reconstruct the visible optimistic overlay from the durable
11276                // base plus outbox instead of trusting a process-interrupted
11277                // mirror.
11278                client.overlay_dirty.set(true);
11279                client.rebuild_overlay()?;
11280            }
11281            // Every persisted active subscription needs one catch-up pull on
11282            // open: realtime only covers changes after connection, while an
11283            // idempotent setWindow correctly creates no fresh command effect.
11284            // Pending outbox work has the same restart requirement. The core
11285            // owns this intent so native hosts never poll or require an
11286            // application-issued sync().
11287            client.enqueue_startup_sync_if_needed()?;
11288            Ok(())
11289        })();
11290        match startup {
11291            Ok(()) => match client.conn.execute_batch("COMMIT") {
11292                Ok(()) => Ok(client),
11293                Err(error) => {
11294                    let failure = Self::sqlite_failure(&client.storage_failure, error);
11295                    if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
11296                        // Keep a failed rollback's typed storage classification.
11297                        let _ = Self::sqlite_failure(&client.storage_failure, rollback);
11298                    }
11299                    Err(failure)
11300                }
11301            },
11302            Err(error) => {
11303                if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
11304                    // Keep a failed rollback's typed storage classification.
11305                    let _ = Self::sqlite_failure(&client.storage_failure, rollback);
11306                }
11307                Err(error)
11308            }
11309        }
11310    }
11311
11312    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
11313    /// against this instead of system time (conformance virtual clock).
11314    pub fn set_now_ms(&mut self, now_ms: i64) {
11315        self.now_ms = Some(now_ms);
11316    }
11317
11318    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
11319    /// The command router parses these from the `create` command's
11320    /// `encryption` config (keys as `{$bytes: hex}`).
11321    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
11322        self.encryption = encryption;
11323    }
11324
11325    #[must_use]
11326    pub fn security_lifecycle(&self) -> &'static str {
11327        if self.security_preflight {
11328            "preflight"
11329        } else {
11330            "active"
11331        }
11332    }
11333
11334    #[must_use]
11335    pub fn security_preflight(&self) -> bool {
11336        self.security_preflight
11337    }
11338
11339    /// Host-owned, non-persisted network gate. Local operations remain available.
11340    pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
11341        if self.transport_enabled != enabled {
11342            self.transport_enabled = enabled;
11343            self.sync_intent_queue.clear();
11344            self.sync_intent_queue
11345                .push_back(if enabled && !self.security_preflight {
11346                    SyncIntent::Interactive
11347                } else {
11348                    SyncIntent::None
11349                });
11350        }
11351        // A captured reply keeps its normal apply/revocation semantics.
11352        if !enabled && self.active_round.is_none() {
11353            self.disconnect_realtime(transport);
11354        }
11355    }
11356
11357    pub fn transport_enabled(&self) -> bool {
11358        self.transport_enabled
11359    }
11360
11361    /// Quarantine this replica: enter the fail-closed gate, release all
11362    /// core-owned key material, and record the gate in the database so
11363    /// reopening re-enters preflight until `activate_security` clears it.
11364    ///
11365    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
11366    /// which must NOT leave that durable mark.
11367    pub fn begin_security_preflight(&mut self) {
11368        self.seal_security_on_teardown();
11369        // Persist the quarantine so it survives handle teardown and restart:
11370        // reopening this replica re-enters preflight until activation clears it.
11371        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
11372    }
11373
11374    /// Release core-owned key material as a host tears the client down,
11375    /// WITHOUT recording a quarantine.
11376    ///
11377    /// Shutting an activated client down is not a quarantine event. Persisting
11378    /// the gate here marks every cleanly closed replica as pending, and the
11379    /// next plain `create` is then refused permanently: the reopen path
11380    /// restores the flag from the marker and the create guard rejects it. The
11381    /// in-memory flag still closes the gate for anything still holding this
11382    /// instance, which is all a teardown barrier needs.
11383    pub fn seal_security_on_teardown(&mut self) {
11384        self.cancel_sync_round();
11385        self.security_preflight = true;
11386        self.encryption = crate::values::EncryptionConfig::default();
11387        self.sync_intent_queue.clear();
11388    }
11389
11390    /// Install the post-authentication keyring and release the host loop.
11391    pub fn activate_security(
11392        &mut self,
11393        encryption: crate::values::EncryptionConfig,
11394    ) -> Result<(), String> {
11395        if !self.security_preflight {
11396            return Err(
11397                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
11398            );
11399        }
11400        self.enqueue_startup_sync_if_needed()?;
11401        self.encryption = encryption;
11402        self.security_preflight = false;
11403        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
11404        Ok(())
11405    }
11406
11407    fn clock_now_ms(&self) -> i64 {
11408        self.now_ms.unwrap_or_else(crate::wall_clock_ms)
11409    }
11410
11411    fn create_bookkeeping_tables(&self) -> Result<(), String> {
11412        // Durable client bookkeeping (outbox + subscription + meta).
11413        self.conn
11414            .execute_batch(
11415                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
11416                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
11417                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
11418                 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
11419                   tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
11420                 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
11421                   commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
11422                   commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
11423                   PRIMARY KEY(commit_id, idx));
11424                 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
11425                   seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
11426                   operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
11427                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
11428                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
11429                   client_commit_id TEXT NOT NULL UNIQUE,
11430                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
11431                   recorded_at_ms INTEGER NOT NULL,
11432                   results_json TEXT NOT NULL,
11433                   operations_json TEXT,
11434                   resolution TEXT NOT NULL DEFAULT 'active'
11435                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
11436                   resolved_at_ms INTEGER,
11437                   replacement_client_commit_id TEXT);
11438                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
11439                   ON _syncular_commit_outcomes(resolution, seq);
11440                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
11441                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
11442                 CREATE TABLE IF NOT EXISTS _syncular_meta (
11443                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
11444                 CREATE TABLE IF NOT EXISTS _syncular_windows (
11445                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
11446                   PRIMARY KEY (base, unit));
11447                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
11448                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
11449                   effective_scopes TEXT NOT NULL);
11450                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
11451                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
11452                   PRIMARY KEY(commit_id, blob_id));
11453                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
11454                   ON _syncular_blob_commit_refs(blob_id);",
11455            )
11456            .map_err(|e| e.to_string())?;
11457        // Migrate an outcome journal created before failed aggregate
11458        // envelopes were retained. Historical rows intentionally stay NULL.
11459        let _ = self
11460            .conn
11461            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
11462        if self.get_meta(LOCAL_REVISION_KEY).is_none() {
11463            self.set_meta(LOCAL_REVISION_KEY, "0");
11464        }
11465        // §5.9.7 blob cache. Mutable upload state stays off the body row so
11466        // completing a large upload does not rewrite its BLOB record.
11467        if self.schema_has_blobs() {
11468            let exists = self
11469                .conn
11470                .query_row(
11471                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
11472                    [],
11473                    |row| row.get::<_, bool>(0),
11474                )
11475                .map_err(|e| e.to_string())?;
11476            if exists {
11477                let mut statement = self
11478                    .conn
11479                    .prepare("PRAGMA table_info(_syncular_blobs)")
11480                    .map_err(|e| e.to_string())?;
11481                let mut columns = statement
11482                    .query_map([], |row| row.get::<_, String>(1))
11483                    .map_err(|e| e.to_string())?
11484                    .collect::<Result<Vec<_>, _>>()
11485                    .map_err(|e| e.to_string())?;
11486                columns.sort();
11487                let mut expected = vec![
11488                    "blob_id",
11489                    "byte_length",
11490                    "bytes",
11491                    "created_at_ms",
11492                    "media_type",
11493                ];
11494                expected.sort_unstable();
11495                if columns != expected {
11496                    return Err(
11497                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
11498                    );
11499                }
11500            } else {
11501                self.conn
11502                    .execute_batch(
11503                        "CREATE TABLE _syncular_blobs(
11504                           blob_id TEXT PRIMARY KEY,
11505                           bytes BLOB NOT NULL,
11506                           byte_length INTEGER NOT NULL,
11507                           media_type TEXT,
11508                           created_at_ms INTEGER NOT NULL);",
11509                    )
11510                    .map_err(|e| e.to_string())?;
11511            }
11512            self.conn
11513                .execute_batch(
11514                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
11515                       blob_id TEXT PRIMARY KEY,
11516                       media_type TEXT,
11517                       created_at_ms INTEGER NOT NULL);",
11518                )
11519                .map_err(|e| e.to_string())?;
11520        }
11521        Ok(())
11522    }
11523
11524    /// True iff any synced table declares a `blob_ref` column (§5.9).
11525    fn schema_has_blobs(&self) -> bool {
11526        self.schema
11527            .tables
11528            .iter()
11529            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
11530    }
11531
11532    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
11533
11534    fn get_meta(&self, key: &str) -> Option<String> {
11535        meta_get(&self.conn, key)
11536    }
11537
11538    fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
11539        self.conn
11540            .query_row(
11541                "SELECT value FROM _syncular_meta WHERE key = ?1",
11542                rusqlite::params![key],
11543                |row| row.get::<_, String>(0),
11544            )
11545            .optional()
11546            .map_err(|_| {
11547                "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
11548            })
11549    }
11550
11551    // The caller holds the startup/reset transaction. Either both schema
11552    // records persist or the enclosing operation fails and rolls back.
11553    fn persist_schema_version(&self) -> Result<(), String> {
11554        self.conn
11555            .execute(
11556                "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
11557                rusqlite::params![LOCAL_SCHEMA_VERSION_KEY, self.schema.version.to_string()],
11558            )
11559            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11560        set_local_schema_descriptor(&self.conn, &self.schema)
11561            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
11562    }
11563
11564    fn set_meta(&self, key: &str, value: &str) {
11565        meta_set(&self.conn, key, value);
11566    }
11567
11568    fn delete_meta(&self, key: &str) {
11569        meta_delete(&self.conn, key);
11570    }
11571
11572    /// §7.1: the durable outbox in FIFO order, in schema-agnostic local form.
11573    fn load_outbox(&self) -> Result<Vec<OutboxCommit>, String> {
11574        let mut stmt = self
11575            .conn
11576            .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
11577            .map_err(|error| error.to_string())?;
11578        let rows = stmt
11579            .query_map([], |row| {
11580                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11581            })
11582            .map_err(|error| error.to_string())?;
11583        let mut commits = Vec::new();
11584        for row in rows {
11585            let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
11586            let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
11587                format!("invalid persisted outbox {client_commit_id:?}: {error}")
11588            })?;
11589            let mut ops = Vec::with_capacity(entries.len());
11590            for entry in entries {
11591                let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
11592                ops.push(OutboxOp {
11593                    upsert: op == "upsert",
11594                    table: entry
11595                        .get("table")
11596                        .and_then(Value::as_str)
11597                        .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
11598                        .to_owned(),
11599                    row_id: entry
11600                        .get("rowId")
11601                        .and_then(Value::as_str)
11602                        .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
11603                        .to_owned(),
11604                    base_version: entry.get("baseVersion").and_then(Value::as_i64),
11605                    // A legacy entry's `changedFields` key is ignored:
11606                    // the values map is the presence set (§6.1).
11607                    values: entry.get("values").and_then(Value::as_object).cloned(),
11608                });
11609            }
11610            commits.push(OutboxCommit {
11611                client_commit_id,
11612                ops,
11613            });
11614        }
11615        Ok(commits)
11616    }
11617
11618    fn restore_persisted_state(&mut self) -> Result<(), String> {
11619        self.subs = {
11620            let mut stmt = self
11621                .conn
11622                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
11623                .map_err(|error| error.to_string())?;
11624            let rows = stmt
11625                .query_map([], |row| {
11626                    Ok((
11627                        row.get::<_, String>(0)?,
11628                        row.get::<_, String>(1)?,
11629                        row.get::<_, String>(2)?,
11630                    ))
11631                })
11632                .map_err(|error| error.to_string())?;
11633            let mut subscriptions = Vec::new();
11634            for row in rows {
11635                let (id, table, raw) = row.map_err(|error| error.to_string())?;
11636                let state: Value = serde_json::from_str(&raw)
11637                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
11638                let requested = json_to_scope_map(
11639                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
11640                )?;
11641                let effective = state
11642                    .get("effectiveScopes")
11643                    .filter(|value| !value.is_null())
11644                    .map(json_to_scope_map)
11645                    .transpose()?;
11646                subscriptions.push(Subscription {
11647                    id,
11648                    table,
11649                    requested,
11650                    params: state
11651                        .get("params")
11652                        .and_then(Value::as_str)
11653                        .map(str::to_owned),
11654                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
11655                    bootstrap_state: state
11656                        .get("bootstrapState")
11657                        .and_then(Value::as_str)
11658                        .map(str::to_owned),
11659                    state: SubState::parse(
11660                        state
11661                            .get("status")
11662                            .and_then(Value::as_str)
11663                            .unwrap_or("active"),
11664                    ),
11665                    reason_code: state
11666                        .get("reasonCode")
11667                        .and_then(Value::as_str)
11668                        .map(str::to_owned),
11669                    effective,
11670                    synced_once: state
11671                        .get("syncedOnce")
11672                        .and_then(Value::as_bool)
11673                        .unwrap_or(false),
11674                });
11675            }
11676            subscriptions
11677        };
11678
11679        self.outbox = self.load_outbox()?;
11680
11681        self.prune_commit_outcomes()?;
11682        let active = self.commit_outcomes(CommitOutcomeQuery {
11683            active_only: true,
11684            ..CommitOutcomeQuery::default()
11685        })?;
11686        self.conflicts = active
11687            .iter()
11688            .flat_map(|outcome| outcome.results.iter())
11689            .filter_map(|result| match result {
11690                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
11691                _ => None,
11692            })
11693            .collect();
11694        self.rejections = active
11695            .iter()
11696            .flat_map(|outcome| outcome.results.iter())
11697            .filter_map(|result| match result {
11698                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
11699                _ => None,
11700            })
11701            .collect();
11702
11703        self.lease_state = self
11704            .get_meta(LEASE_STATE_KEY)
11705            .map(|raw| serde_json::from_str(&raw))
11706            .transpose()
11707            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
11708        self.schema_floor = self
11709            .get_meta(SCHEMA_FLOOR_KEY)
11710            .map(|raw| serde_json::from_str(&raw))
11711            .transpose()
11712            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
11713        self.stopped = self.schema_floor.is_some();
11714        // A replica left in unactivated preflight reopens gated: the quarantine
11715        // decision persists with the data across handle teardown and restart.
11716        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
11717            self.security_preflight = true;
11718        }
11719        Ok(())
11720    }
11721
11722    fn save_subscription_scope_schema(&self) {
11723        let declarations: BTreeMap<_, BTreeMap<_, _>> = self
11724            .schema
11725            .tables
11726            .iter()
11727            .map(|table| {
11728                (
11729                    table.name.clone(),
11730                    table
11731                        .scope_variables
11732                        .iter()
11733                        .map(|scope| {
11734                            (
11735                                scope.variable.clone(),
11736                                [scope.prefix.clone(), scope.column.clone()],
11737                            )
11738                        })
11739                        .collect(),
11740                )
11741            })
11742            .collect();
11743        self.set_meta(
11744            "subscriptionScopeSchema",
11745            &serde_json::to_string(&declarations).expect("scope schema serialization"),
11746        );
11747    }
11748
11749    /// Remove registrations with incompatible table/key/prefix/column meaning.
11750    fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
11751        let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
11752            self.get_meta("subscriptionScopeSchema")
11753                .map(|raw| serde_json::from_str(&raw))
11754                .transpose()
11755                .map_err(|error| format!("invalid persisted scope schema: {error}"))?
11756        } else {
11757            None
11758        };
11759        let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
11760            let Some(table) = self.schema.table(table_name) else {
11761                return false;
11762            };
11763            if requested
11764                .iter()
11765                .any(|(variable, _)| table.scope_column(variable).is_none())
11766            {
11767                return false;
11768            }
11769            if !bump {
11770                return true;
11771            }
11772            let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
11773                return false;
11774            };
11775            let variables: BTreeSet<_> = if requested.is_empty() {
11776                old.keys()
11777                    .cloned()
11778                    .chain(
11779                        table
11780                            .scope_variables
11781                            .iter()
11782                            .map(|scope| scope.variable.clone()),
11783                    )
11784                    .collect()
11785            } else {
11786                requested
11787                    .iter()
11788                    .map(|(variable, _)| variable.clone())
11789                    .collect()
11790            };
11791            variables.iter().all(|variable| {
11792                let current = table
11793                    .scope_variables
11794                    .iter()
11795                    .find(|scope| &scope.variable == variable);
11796                match (old.get(variable), current) {
11797                    (Some(old), Some(current)) => {
11798                        old == &[current.prefix.clone(), current.column.clone()]
11799                    }
11800                    _ => false,
11801                }
11802            })
11803        };
11804        let mut stale_ids: BTreeSet<_> = self
11805            .subs
11806            .iter()
11807            .filter(|sub| !compatible(&sub.table, &sub.requested))
11808            .map(|sub| sub.id.clone())
11809            .collect();
11810        for (id, table, effective) in self.load_pending_evictions()? {
11811            if !compatible(&table, &effective) {
11812                stale_ids.insert(id);
11813            }
11814        }
11815        for id in &stale_ids {
11816            for sql in [
11817                "DELETE FROM _syncular_windows WHERE sub_id = ?1",
11818                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
11819                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
11820            ] {
11821                self.conn
11822                    .execute(sql, rusqlite::params![id])
11823                    .map_err(|error| error.to_string())?;
11824            }
11825        }
11826        self.subs.retain(|sub| !stale_ids.contains(&sub.id));
11827        Ok(())
11828    }
11829
11830    #[must_use]
11831    pub fn local_revision(&self) -> u64 {
11832        self.get_meta(LOCAL_REVISION_KEY)
11833            .and_then(|value| value.parse().ok())
11834            .unwrap_or(0)
11835    }
11836
11837    /// §7.5: resolve a snapshot read on the mutable owner connection through
11838    /// the same primitive the read-only sidecar uses, so the command surface
11839    /// and the file sidecar return one revision, rows, coverage, catch-up, and
11840    /// delivery shape. The structured [`QueryReadFailure`] is preserved; an
11841    /// `owner` records its failure (and its next success) in diagnostics.
11842    pub fn snapshot_read(
11843        &mut self,
11844        request: &SnapshotReadRequest<'_>,
11845        owner: Option<QueryOwner<'_>>,
11846    ) -> Result<SnapshotRead, QueryReadFailure> {
11847        let result = snapshot_read_connection(&self.conn, request);
11848        if let Some(owner) = owner {
11849            self.record_query_read(owner, result.as_ref().err());
11850        }
11851        result
11852    }
11853
11854    #[must_use]
11855    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
11856        SyncStatusSnapshot {
11857            current_schema_version: self.schema.version,
11858            outbox: self.outbox.len(),
11859            upgrading: self.upgrading,
11860            lease_state: self.lease_state.clone(),
11861            schema_floor: self.schema_floor.clone(),
11862            sync_needed: self.sync_needed,
11863            previous_version_context: self.previous_version_status(),
11864        }
11865    }
11866
11867    /// RFC 0005 A2: container presence for a host that must act on it (Diego's
11868    /// rollback precondition), read without creating the file.
11869    fn previous_version_status(&self) -> PreviousVersionStatus {
11870        let Some(replica_path) = self.previous_version_replica_path() else {
11871            return PreviousVersionStatus {
11872                present: false,
11873                created_at_ms: None,
11874            };
11875        };
11876        match crate::previous_version::read_previous_version_record(&replica_path) {
11877            Ok(Some(record)) => PreviousVersionStatus {
11878                present: true,
11879                created_at_ms: Some(record.created_at_ms),
11880            },
11881            _ => PreviousVersionStatus {
11882                present: std::path::Path::new(
11883                    &crate::previous_version::previous_version_container_path(&replica_path),
11884                )
11885                .exists(),
11886                created_at_ms: None,
11887            },
11888        }
11889    }
11890
11891    /// RFC 0005 D7: read captured rows from the previous local schema. `state`
11892    /// is always `previousVersion`; the container never makes
11893    /// `query_snapshot()` coverage complete. Lease stop/expiry, scope
11894    /// revocation, coverage completion and the TTL DISCARD the container and
11895    /// close the read with their own reason.
11896    pub fn previous_version_snapshot(
11897        &self,
11898        spec: &PreviousVersionReadSpec,
11899    ) -> Result<PreviousVersionSnapshot, String> {
11900        let now_ms = self.clock_now_ms();
11901        crate::previous_version::previous_version_snapshot(
11902            &self.conn,
11903            self.previous_version_replica_path().as_deref(),
11904            self.previous_version.as_ref(),
11905            PreviousVersionLifecycle {
11906                lease_inactive: self.previous_version_lease_inactive(now_ms),
11907                scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
11908                coverage_complete: self.previous_version_coverage_complete(),
11909            },
11910            now_ms,
11911            self.schema.version,
11912            spec,
11913        )
11914    }
11915
11916    /// RFC 0005 D6: the advisory pre-reset compatibility audit.
11917    #[must_use]
11918    pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
11919        crate::previous_version::stored_previous_version_audit(&self.conn)
11920    }
11921
11922    /// RFC 0005 A2/D9: the executable downgrade step. Drop the container file
11923    /// and both metadata records, and report whether anything was present.
11924    /// Idempotent: absence is a successful no-op.
11925    pub fn previous_version_discard(
11926        &self,
11927    ) -> crate::previous_version::PreviousVersionDiscardOutcome {
11928        self.drop_previous_version()
11929    }
11930
11931    /// The module's discard has no failure path (it removes the file even when
11932    /// the container cannot be opened), so the only error a caller could ever
11933    /// see is a bug; report absence rather than claiming a discard happened.
11934    fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
11935        let absent = crate::previous_version::PreviousVersionDiscardOutcome {
11936            present: false,
11937            discarded: false,
11938        };
11939        let Some(replica_path) = self.previous_version_replica_path() else {
11940            return absent;
11941        };
11942        crate::previous_version::discard_previous_version(&self.conn, &replica_path)
11943            .unwrap_or(absent)
11944    }
11945
11946    /// RFC 0005 D7: `leaseState.errorCode` or an expired lease.
11947    fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
11948        let Some(lease) = &self.lease_state else {
11949            return false;
11950        };
11951        if lease.error_code.is_some() {
11952            return true;
11953        }
11954        lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
11955    }
11956
11957    /// RFC 0005 D7: §7.4.5 replacement coverage — every ACTIVE subscription
11958    /// has `cursor >= 0` and no resume token. An empty active set is not
11959    /// completion (mirrors the TS predicate exactly).
11960    fn previous_version_coverage_complete(&self) -> bool {
11961        let active = self
11962            .subs
11963            .iter()
11964            .filter(|sub| sub.state == SubState::Active)
11965            .collect::<Vec<_>>();
11966        if active.is_empty() {
11967            return false;
11968        }
11969        active
11970            .iter()
11971            .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
11972    }
11973
11974    /// RFC 0005 D7: lifetime triggers evaluated once per sync round, after the
11975    /// apply. A container that no longer has a reason to exist is discarded so
11976    /// the rows do not outlive the window they were kept for.
11977    fn previous_version_lifetime_check(&self) {
11978        let Some(config) = self.previous_version.filter(|config| config.enabled) else {
11979            return;
11980        };
11981        let now_ms = self.clock_now_ms();
11982        if self.previous_version_lease_inactive(now_ms) {
11983            let _ = self.drop_previous_version();
11984            return;
11985        }
11986        let Some(replica_path) = self.previous_version_replica_path() else {
11987            return;
11988        };
11989        let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
11990        else {
11991            return;
11992        };
11993        if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
11994            let _ = self.drop_previous_version();
11995            return;
11996        }
11997        if now_ms - record.created_at_ms > config.max_age_ms {
11998            let _ = self.drop_previous_version();
11999            return;
12000        }
12001        if self.previous_version_coverage_complete() {
12002            let _ = self.drop_previous_version();
12003        }
12004    }
12005
12006    pub fn diagnostics_snapshot(
12007        &self,
12008        request: &ClientDiagnosticsRequest,
12009    ) -> Result<ClientDiagnosticsSnapshot, String> {
12010        if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
12011            return Err(format!(
12012                "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
12013            ));
12014        }
12015        let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
12016        for sub in &self.subs {
12017            let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
12018            let complete =
12019                sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
12020            let state = match sub.state {
12021                SubState::Revoked => "revoked",
12022                SubState::Failed => "failed",
12023                SubState::Active if reset => "reset",
12024                SubState::Active if complete => "complete",
12025                SubState::Active => "bootstrapping",
12026            };
12027            subscriptions.insert(
12028                sub.id.clone(),
12029                DiagnosticSubscription {
12030                    id: sub.id.clone(),
12031                    table: sub.table.clone(),
12032                    state: state.to_owned(),
12033                    complete,
12034                    cursor: Some(sub.cursor),
12035                    reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
12036                },
12037            );
12038        }
12039        for expected in &request.expected_subscriptions {
12040            if expected.id.is_empty() || expected.table.is_empty() {
12041                return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
12042            }
12043            if subscriptions
12044                .get(&expected.id)
12045                .is_some_and(|registered| registered.table != expected.table)
12046            {
12047                subscriptions.insert(
12048                    expected.id.clone(),
12049                    DiagnosticSubscription {
12050                        id: expected.id.clone(),
12051                        table: expected.table.clone(),
12052                        state: "failed".to_owned(),
12053                        complete: false,
12054                        cursor: None,
12055                        reason_code: Some("client.subscription_intent_mismatch".to_owned()),
12056                    },
12057                );
12058            } else {
12059                subscriptions.entry(expected.id.clone()).or_insert_with(|| {
12060                    DiagnosticSubscription {
12061                        id: expected.id.clone(),
12062                        table: expected.table.clone(),
12063                        state: "unregistered".to_owned(),
12064                        complete: false,
12065                        cursor: None,
12066                        reason_code: None,
12067                    }
12068                });
12069            }
12070        }
12071        let mut ordered_subscriptions = Vec::new();
12072        let mut included = BTreeSet::new();
12073        for expected in &request.expected_subscriptions {
12074            if included.insert(expected.id.clone()) {
12075                if let Some(subscription) = subscriptions.get(&expected.id) {
12076                    ordered_subscriptions.push(subscription.clone());
12077                }
12078            }
12079        }
12080        for (id, subscription) in &subscriptions {
12081            if included.insert(id.clone()) {
12082                ordered_subscriptions.push(subscription.clone());
12083            }
12084        }
12085        let subscriptions_truncated =
12086            ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
12087        ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
12088        let captured_at_ms = self.clock_now_ms();
12089        let lease = if let Some(error_code) = self
12090            .lease_state
12091            .as_ref()
12092            .and_then(|state| state.error_code.clone())
12093        {
12094            ClientDiagnosticsLease {
12095                state: "stopped".to_owned(),
12096                expires_at_ms: self
12097                    .lease_state
12098                    .as_ref()
12099                    .and_then(|state| state.expires_at_ms),
12100                error_code: Some(Self::diagnostic_code(&error_code)),
12101            }
12102        } else if let Some(expires_at_ms) = self
12103            .lease_state
12104            .as_ref()
12105            .and_then(|state| state.expires_at_ms)
12106        {
12107            ClientDiagnosticsLease {
12108                state: if expires_at_ms <= captured_at_ms {
12109                    "expired".to_owned()
12110                } else {
12111                    "active".to_owned()
12112                },
12113                expires_at_ms: Some(expires_at_ms),
12114                error_code: None,
12115            }
12116        } else {
12117            ClientDiagnosticsLease {
12118                state: "none".to_owned(),
12119                expires_at_ms: None,
12120                error_code: None,
12121            }
12122        };
12123        let connectivity = match self.last_round.as_ref() {
12124            _ if !self.transport_enabled && self.active_round.is_none() => "offline",
12125            Some(round) if round.status == "succeeded" => "online",
12126            Some(round)
12127                if round.status == "failed"
12128                    && round
12129                        .error_code
12130                        .as_deref()
12131                        .is_some_and(Self::retryable_transport_code) =>
12132            {
12133                "offline"
12134            }
12135            _ => "unknown",
12136        };
12137        Ok(ClientDiagnosticsSnapshot {
12138            version: CLIENT_DIAGNOSTICS_VERSION,
12139            captured_at_ms,
12140            host: ClientDiagnosticsHost {
12141                kind: "direct".to_owned(),
12142                role: "single".to_owned(),
12143                connectivity: connectivity.to_owned(),
12144                realtime: self.realtime_state,
12145                realtime_policy: self.realtime_policy,
12146                realtime_reason_code: self.realtime_reason_code.clone(),
12147                realtime_retry_delay_ms: self.realtime_retry_delay_ms,
12148            },
12149            security_lifecycle: self.security_lifecycle().to_owned(),
12150            schema: ClientDiagnosticsSchema {
12151                current_version: self.schema.version,
12152                upgrading: self.upgrading,
12153                required_version: self
12154                    .schema_floor
12155                    .as_ref()
12156                    .and_then(|floor| floor.required_schema_version),
12157                latest_version: self
12158                    .schema_floor
12159                    .as_ref()
12160                    .and_then(|floor| floor.latest_schema_version),
12161            },
12162            replica: ClientDiagnosticsReplica {
12163                local_revision: self.local_revision().to_string(),
12164                sync_needed: self.sync_needed,
12165                pending_outbox: self.outbox.len(),
12166            },
12167            lease,
12168            subscriptions: ordered_subscriptions,
12169            subscriptions_truncated,
12170            last_round: self.last_round.clone(),
12171            last_change: self.last_change.clone(),
12172            storage: self.diagnostics_storage(),
12173            query_failures: self.query_failures.clone(),
12174        })
12175    }
12176
12177    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
12178        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
12179            let page_count: i64 = self
12180                .conn
12181                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
12182            let page_size: i64 = self
12183                .conn
12184                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
12185            // Reuse compiled aggregates; every invocation still reads current storage.
12186            let outbox_bytes: i64 = self
12187                .conn
12188                .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
12189                .query_row([], |row| row.get(0))?;
12190            let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
12191                "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
12192            )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
12193            let blob_bytes = if self.schema_has_blobs() {
12194                self.conn
12195                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
12196                    .query_row([], |row| row.get(0))?
12197            } else {
12198                0
12199            };
12200            let pressure = self
12201                .limits
12202                .blob_cache_max_bytes
12203                .is_some_and(|limit| blob_bytes > limit);
12204            Ok(ClientDiagnosticsStorage {
12205                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
12206                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
12207                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
12208                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
12209                retained_outcome_entries: Some(outcome_entries.max(0)),
12210                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
12211                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
12212            })
12213        };
12214        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
12215            status: "unreadable".to_owned(),
12216            database_bytes_approx: None,
12217            pending_outbox_bytes_approx: None,
12218            retained_outcome_bytes_approx: None,
12219            retained_outcome_entries: None,
12220            blob_cache_bytes_approx: None,
12221            pressure_reason_code: None,
12222        })
12223    }
12224
12225    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
12226        self.change_queue.drain(..).collect()
12227    }
12228
12229    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
12230        let intents = self.sync_intent_queue.drain(..).collect();
12231        if self.transport_enabled {
12232            intents
12233        } else {
12234            vec![SyncIntent::None]
12235        }
12236    }
12237
12238    fn schedule_background_retry(&mut self) -> u64 {
12239        let delay_ms = self.retry_delay_ms;
12240        self.sync_intent_queue
12241            .push_back(SyncIntent::Background { delay_ms });
12242        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
12243        self.round_retry_delay_ms = Some(delay_ms);
12244        delay_ms
12245    }
12246
12247    fn reset_background_retry(&mut self) {
12248        self.retry_delay_ms = 250;
12249    }
12250
12251    fn retryable_transport_code(code: &str) -> bool {
12252        code == "transport.failed"
12253            || code == "transport.unavailable"
12254            || code == "sync.transport_failed"
12255    }
12256
12257    /// A failed round worth a background retry: a transport failure, or a
12258    /// §10.2 code the catalog marks retryable (clients may hardcode the
12259    /// catalog metadata, §10.1). Matches the TypeScript core, which retries
12260    /// on the server's `retryable` flag.
12261    fn retryable_failure_code(code: &str) -> bool {
12262        Self::retryable_transport_code(code)
12263            || matches!(
12264                code,
12265                "sync.auth_required"
12266                    | "sync.auth_lease_required"
12267                    | "sync.auth_lease_revoked"
12268                    | "sync.internal_error"
12269                    | "sync.idempotency_cache_miss"
12270                    | "sync.schema_not_ready"
12271                    | "sync.segment_expired"
12272                    | "sync.rate_limited"
12273                    | "sync.websocket_connection_limit"
12274            )
12275    }
12276
12277    fn diagnostic_code(code: &str) -> String {
12278        let valid = !code.is_empty()
12279            && code.len() <= 96
12280            && code.contains('.')
12281            && code
12282                .bytes()
12283                .next()
12284                .is_some_and(|byte| byte.is_ascii_lowercase())
12285            && code.bytes().all(|byte| {
12286                byte.is_ascii_lowercase()
12287                    || byte.is_ascii_digit()
12288                    || matches!(byte, b'.' | b'_' | b'-')
12289            });
12290        if valid {
12291            code.to_owned()
12292        } else {
12293            "client.unknown_failure".to_owned()
12294        }
12295    }
12296
12297    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
12298        if self.sync_needed != value {
12299            if self.begin_observation("syncular_status").is_ok() {
12300                self.sync_needed = value;
12301                let batch = ChangeAccumulator {
12302                    status: true,
12303                    ..ChangeAccumulator::default()
12304                };
12305                if self.finish_observation("syncular_status", batch).is_err() {
12306                    self.rollback_observation("syncular_status");
12307                }
12308            } else {
12309                self.sync_needed = value;
12310            }
12311        }
12312        if value && interactive {
12313            self.sync_intent_queue.push_back(SyncIntent::Interactive);
12314        }
12315    }
12316
12317    fn sqlite_failure(
12318        storage_failure: &RefCell<Option<QueryReadFailure>>,
12319        error: rusqlite::Error,
12320    ) -> String {
12321        let message = error.to_string();
12322        let failure = QueryReadFailure::from(error);
12323        let code = failure.code;
12324        if code.is_some() && storage_failure.borrow().is_none() {
12325            *storage_failure.borrow_mut() = Some(failure);
12326        }
12327        match code {
12328            Some(code) => format!("{code}: {message}"),
12329            None => message,
12330        }
12331    }
12332
12333    fn begin_observation(&self, name: &str) -> Result<(), String> {
12334        if let Some(failure) = &self.coalesced_failure {
12335            return Err(failure.clone());
12336        }
12337        // SQLite rolls a whole transaction back on some I/O and capacity
12338        // errors; later frames must not commit on their own after that.
12339        if self.coalesced.is_some() && self.conn.is_autocommit() {
12340            return Err("the coalesced apply transaction was rolled back".to_owned());
12341        }
12342        self.conn
12343            .execute_batch(&format!("SAVEPOINT {name}"))
12344            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12345    }
12346
12347    /// SPEC §1.4 rule 4: open a coalesced apply transaction unless a caller
12348    /// already owns one.
12349    fn begin_coalesced_apply(&mut self) -> Result<(), String> {
12350        if self.coalesced.is_some() || !self.conn.is_autocommit() {
12351            return Ok(());
12352        }
12353        self.conn
12354            .execute_batch("BEGIN")
12355            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12356        self.coalesced = Some(CoalescedApply {
12357            rows: 0,
12358            subs: self.subs.clone(),
12359            outbox: self.outbox.clone(),
12360            failed_commits: self.failed_commits.clone(),
12361            conflicts: self.conflicts.clone(),
12362            rejections: self.rejections.clone(),
12363            schema_floor: self.schema_floor.clone(),
12364            lease_state: self.lease_state.clone(),
12365            stopped: self.stopped,
12366            upgrading: self.upgrading,
12367            sync_needed: self.sync_needed,
12368            overlay_dirty: self.overlay_dirty.snapshot(),
12369            change_queue: self.change_queue.len(),
12370            sync_intent_queue: self.sync_intent_queue.len(),
12371            last_change: self.last_change.clone(),
12372        });
12373        Ok(())
12374    }
12375
12376    /// Account the rows of the next frame or block. A unit that would take the
12377    /// coalesced transaction past the bound commits the earlier units first.
12378    fn coalesce_unit(&mut self, rows: usize) -> Result<(), String> {
12379        let Some(open) = &self.coalesced else {
12380            return Ok(());
12381        };
12382        if open.rows > 0 && open.rows + rows > COALESCED_APPLY_ROWS {
12383            self.commit_coalesced_apply()?;
12384            self.begin_coalesced_apply()?;
12385        }
12386        if let Some(open) = &mut self.coalesced {
12387            open.rows += rows;
12388        }
12389        Ok(())
12390    }
12391
12392    /// COMMIT the coalesced transaction. A failed COMMIT rolls every coalesced
12393    /// frame back, restores the in-memory state of its `BEGIN`, drops the
12394    /// change batches and sync intents queued since, and fails the rest of the
12395    /// response.
12396    fn commit_coalesced_apply(&mut self) -> Result<(), String> {
12397        let Some(open) = self.coalesced.take() else {
12398            return Ok(());
12399        };
12400        let committed = if self.conn.is_autocommit() {
12401            Err("the coalesced apply transaction was rolled back".to_owned())
12402        } else {
12403            self.conn
12404                .execute_batch("COMMIT")
12405                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12406        };
12407        let Err(error) = committed else {
12408            return Ok(());
12409        };
12410        if !self.conn.is_autocommit() {
12411            if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
12412                let _ = Self::sqlite_failure(&self.storage_failure, rollback);
12413            }
12414        }
12415        self.subs = open.subs;
12416        self.outbox = open.outbox;
12417        self.failed_commits = open.failed_commits;
12418        self.conflicts = open.conflicts;
12419        self.rejections = open.rejections;
12420        self.schema_floor = open.schema_floor;
12421        self.lease_state = open.lease_state;
12422        self.stopped = open.stopped;
12423        self.upgrading = open.upgrading;
12424        self.sync_needed = open.sync_needed;
12425        self.overlay_dirty.restore(open.overlay_dirty);
12426        self.change_queue.truncate(open.change_queue);
12427        self.sync_intent_queue.truncate(open.sync_intent_queue);
12428        self.last_change = open.last_change;
12429        self.coalesced_failure = Some(error.clone());
12430        Err(error)
12431    }
12432
12433    /// End the response's coalescing: COMMIT what is open and clear a failure.
12434    fn finish_coalesced_apply(&mut self) -> Result<(), String> {
12435        let committed = self.commit_coalesced_apply();
12436        match self.coalesced_failure.take() {
12437            Some(failure) => Err(failure),
12438            None => committed,
12439        }
12440    }
12441
12442    fn rollback_observation(&self, name: &str) {
12443        if let Err(error) = self
12444            .conn
12445            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
12446        {
12447            if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
12448                if failure.rollback_failure.is_none() {
12449                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
12450                }
12451            }
12452        }
12453    }
12454
12455    fn finish_observation(
12456        &mut self,
12457        name: &str,
12458        mut batch: ChangeAccumulator,
12459    ) -> Result<(), String> {
12460        #[cfg(feature = "bench-internals")]
12461        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
12462        if self.failed_commits.iter().any(|(commit, _)| {
12463            commit
12464                .ops
12465                .iter()
12466                .any(|operation| batch.tables.contains_key(&operation.table))
12467        }) {
12468            batch.outcomes = true;
12469        }
12470        if !batch.touched() {
12471            self.conn
12472                .execute_batch(&format!("RELEASE {name}"))
12473                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12474            return Ok(());
12475        }
12476        let revision = self
12477            .local_revision()
12478            .checked_add(1)
12479            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
12480        self.conn
12481            .execute(
12482                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
12483                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
12484            )
12485            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12486        let status = batch.status.then(|| self.status_snapshot());
12487        let event = ClientChangeBatch {
12488            revision: revision.to_string(),
12489            tables: batch
12490                .tables
12491                .into_iter()
12492                .map(|(table, scope_keys)| TableChange {
12493                    table,
12494                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
12495                })
12496                .collect(),
12497            windows: batch
12498                .windows
12499                .into_iter()
12500                .map(|((base_key, table), units)| WindowChange {
12501                    base_key,
12502                    table,
12503                    units: units.into_iter().collect(),
12504                })
12505                .collect(),
12506            status,
12507            conflicts_changed: batch.conflicts,
12508            rejections_changed: batch.rejections,
12509            outcomes_changed: batch.outcomes,
12510        };
12511        self.conn
12512            .execute_batch(&format!("RELEASE {name}"))
12513            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12514        let mut diagnostic_tables = event
12515            .tables
12516            .iter()
12517            .map(|entry| entry.table.clone())
12518            .collect::<BTreeSet<_>>()
12519            .into_iter()
12520            .collect::<Vec<_>>();
12521        let mut diagnostic_windows = event
12522            .windows
12523            .iter()
12524            .map(|entry| entry.table.clone())
12525            .collect::<BTreeSet<_>>()
12526            .into_iter()
12527            .collect::<Vec<_>>();
12528        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
12529            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
12530        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
12531        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
12532        self.last_change = Some(DiagnosticLastChange {
12533            revision: event.revision.clone(),
12534            recorded_at_ms: self.clock_now_ms(),
12535            tables: diagnostic_tables,
12536            windows: diagnostic_windows,
12537            domains_truncated,
12538            status_changed: event.status.is_some(),
12539            conflicts_changed: event.conflicts_changed,
12540            rejections_changed: event.rejections_changed,
12541            outcomes_changed: event.outcomes_changed,
12542        });
12543        self.change_queue.push_back(event);
12544        Ok(())
12545    }
12546
12547    fn record_scope_map(
12548        &self,
12549        batch: &mut ChangeAccumulator,
12550        table_name: &str,
12551        scopes: &[(String, Vec<String>)],
12552    ) {
12553        let Some(table) = self.schema.table(table_name) else {
12554            return;
12555        };
12556        for (variable, values) in scopes {
12557            let Some(scope) = table
12558                .scope_variables
12559                .iter()
12560                .find(|scope| &scope.variable == variable)
12561            else {
12562                continue;
12563            };
12564            for value in values {
12565                batch.scope(table_name, format!("{}:{value}", scope.prefix));
12566            }
12567        }
12568    }
12569
12570    /// Record a row's current scope keys from the base or visible table.
12571    fn record_row_scopes(
12572        &self,
12573        batch: &mut ChangeAccumulator,
12574        table_name: &str,
12575        row_id: &str,
12576        base: bool,
12577    ) -> bool {
12578        let Some(table) = self.schema.table(table_name) else {
12579            return false;
12580        };
12581        if table.scope_variables.is_empty() {
12582            return false;
12583        }
12584        let columns = table
12585            .scope_variables
12586            .iter()
12587            .map(|scope| quote_ident(&scope.column))
12588            .collect::<Vec<_>>()
12589            .join(", ");
12590        let full_table = if base {
12591            base_table(table_name)
12592        } else {
12593            visible_table(table_name)
12594        };
12595        let sql = format!(
12596            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
12597            row_id_predicate(table)
12598        );
12599        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
12600            return false;
12601        };
12602        let values = stmt.query_row(rusqlite::params![row_id], |row| {
12603            let mut values = Vec::with_capacity(table.scope_variables.len());
12604            for index in 0..table.scope_variables.len() {
12605                values.push(row.get::<_, Option<String>>(index)?);
12606            }
12607            Ok(values)
12608        });
12609        let Ok(values) = values else {
12610            return false;
12611        };
12612        let mut recorded = false;
12613        for (scope, value) in table.scope_variables.iter().zip(values) {
12614            if let Some(value) = value {
12615                batch.scope(table_name, format!("{}:{value}", scope.prefix));
12616                recorded = true;
12617            }
12618        }
12619        recorded
12620    }
12621
12622    fn record_commit_changes(
12623        &self,
12624        batch: &mut ChangeAccumulator,
12625        tables: &[String],
12626        changes: &[ssp2::model::Change],
12627    ) {
12628        #[cfg(feature = "bench-internals")]
12629        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
12630        for change in changes {
12631            let Some(table_name) = tables.get(change.table_index as usize) else {
12632                continue;
12633            };
12634            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
12635            if let Some(table) = self.schema.table(table_name) {
12636                for (variable, value) in &change.scopes {
12637                    if let Some(scope) = table
12638                        .scope_variables
12639                        .iter()
12640                        .find(|scope| &scope.variable == variable)
12641                    {
12642                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
12643                        precise = true;
12644                    }
12645                }
12646            }
12647            if !precise {
12648                batch.table(table_name);
12649            }
12650        }
12651    }
12652
12653    fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
12654        if effective.is_empty() {
12655            return false;
12656        }
12657        let Some(table) = self.schema.table(table_name) else {
12658            return false;
12659        };
12660        let mut clauses = Vec::new();
12661        let mut params = Vec::new();
12662        for (variable, values) in effective {
12663            let Some(column) = table.scope_column(variable) else {
12664                return false;
12665            };
12666            if values.is_empty() {
12667                return false;
12668            }
12669            let placeholders = values
12670                .iter()
12671                .map(|value| {
12672                    params.push(SqlValue::Text(value.clone()));
12673                    "?"
12674                })
12675                .collect::<Vec<_>>()
12676                .join(", ");
12677            clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
12678        }
12679        let sql = format!(
12680            "SELECT 1 FROM {} WHERE {} LIMIT 1",
12681            base_table(table_name),
12682            clauses.join(" AND ")
12683        );
12684        self.conn
12685            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
12686            .is_ok()
12687    }
12688
12689    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
12690    pub fn upgrading(&self) -> bool {
12691        self.upgrading
12692    }
12693
12694    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
12695    /// keeping this client's local database (identity, outbox, tables). The
12696    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
12697    /// version increases. Recreation runs the same marker guard as opening
12698    /// a durable replica, holds the guard and every write it authorizes under
12699    /// one write transaction, and preserves the current client (compiled
12700    /// schema, outbox, tables) on refusal.
12701    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
12702        let new_schema = parse_schema_json(schema_json)?;
12703        validate_authority_reads(&new_schema, &self.limits.authority_reads)?;
12704        self.conn
12705            .execute_batch("BEGIN IMMEDIATE")
12706            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12707        let prior_schema = std::mem::replace(&mut self.schema, new_schema);
12708        // §7.4.3/§7.4.4: a reset inside this transaction mutates in-memory
12709        // state (dropped outbox commits, rejections, upgrade and floor flags,
12710        // overlay dirtiness) and appends change/intent batches. A COMMIT that
12711        // fails after the reset succeeded rolls the database back, so every
12712        // one of those fields is captured here and restored with it.
12713        let prior_subs = self.subs.clone();
12714        let prior_outbox = self.outbox.clone();
12715        let prior_rejections = self.rejections.clone();
12716        let prior_upgrading = self.upgrading;
12717        let prior_stopped = self.stopped;
12718        let prior_schema_floor = self.schema_floor.clone();
12719        let prior_overlay_dirty = self.overlay_dirty.snapshot();
12720        let prior_sync_needed = self.sync_needed;
12721        let prior_active_round = self.active_round;
12722        let prior_changes = self.change_queue.len();
12723        let prior_intents = self.sync_intent_queue.len();
12724        let prior_last_change = self.last_change.clone();
12725        let guarded = (|| -> Result<(), String> {
12726            // §7.4.2: the marker is re-read under the write lock the caller
12727            // holds, so a replica another process upgraded meanwhile refuses
12728            // this recreation instead of having its marker and tables
12729            // downgraded.
12730            let marker = read_local_schema_version(&self.conn, self.schema.version)?;
12731            self.create_bookkeeping_tables()?;
12732            match marker {
12733                Some(version) if version < self.schema.version => self.run_schema_reset()?,
12734                None => {
12735                    self.create_synced_tables()?;
12736                    self.persist_schema_version()?;
12737                }
12738                _ => {}
12739            }
12740            // §7.4.2: recreation IS the boot for the in-memory core, so reload the
12741            // durable outbox the same-version path is about to replay. Only the
12742            // outbox is reloaded: the recovery below reads it, and the caller's
12743            // rollback snapshot already covers this field.
12744            self.outbox = self.load_outbox()?;
12745            // §7.1/§7.4.4: recover intrinsically codec-invalid persisted values at
12746            // this guarded boundary once the schema and marker agree. A bump went
12747            // through `run_schema_reset`, which performs the same recovery after
12748            // it creates the new physical tables and before its final replay.
12749            self.drop_unencodable_outbox(false, None)?;
12750            if marker == Some(self.schema.version)
12751                && (!self.outbox.is_empty()
12752                    || !self.failed_commits.is_empty()
12753                    || self.has_acknowledged_rows())
12754            {
12755                self.overlay_dirty.set(true);
12756                self.rebuild_overlay()?;
12757            }
12758            self.prune_unknown_subscriptions(false)?;
12759            self.save_subscription_scope_schema();
12760            // The conformance recreate is the in-memory equivalent of reopening
12761            // a durable client. Apply the same startup catch-up contract even
12762            // when the schema itself did not change.
12763            self.enqueue_startup_sync_if_needed()?;
12764            Ok(())
12765        })();
12766        let outcome = match guarded {
12767            Ok(()) => self
12768                .conn
12769                .execute_batch("COMMIT")
12770                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error)),
12771            Err(error) => Err(error),
12772        };
12773        if let Err(error) = outcome {
12774            if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
12775                // Keep a failed rollback's typed storage classification.
12776                let _ = Self::sqlite_failure(&self.storage_failure, rollback);
12777            }
12778            self.schema = prior_schema;
12779            self.subs = prior_subs;
12780            self.outbox = prior_outbox;
12781            self.rejections = prior_rejections;
12782            self.upgrading = prior_upgrading;
12783            self.stopped = prior_stopped;
12784            self.schema_floor = prior_schema_floor;
12785            self.overlay_dirty.restore(prior_overlay_dirty);
12786            self.sync_needed = prior_sync_needed;
12787            self.active_round = prior_active_round;
12788            self.change_queue.truncate(prior_changes);
12789            self.sync_intent_queue.truncate(prior_intents);
12790            self.last_change = prior_last_change;
12791            // Prepared insert statements were derived from the reverted schema.
12792            self.insert_sql.borrow_mut().clear();
12793            return Err(error);
12794        }
12795        Ok(())
12796    }
12797
12798    fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
12799        let startup_work = !self.stopped
12800            && (!self.outbox.is_empty()
12801                || self.has_acknowledged_rows()
12802                || !self.load_pending_evictions()?.is_empty()
12803                || self.subs.iter().any(|sub| sub.state == SubState::Active));
12804        if startup_work {
12805            self.sync_needed = true;
12806            self.sync_intent_queue.push_back(SyncIntent::Interactive);
12807        }
12808        Ok(())
12809    }
12810
12811    /// A native client persists schema-floor stops across process restarts.
12812    /// Once the running app already satisfies that floor, the persisted stop
12813    /// is only stale evidence from an older server round (for example, the
12814    /// server was restarted after catching up to an app that was ahead).
12815    /// Clear it and let the normal startup pull re-negotiate. If the server is
12816    /// still incompatible it will return the floor again in that first round.
12817    fn clear_satisfied_persisted_schema_floor(&mut self) {
12818        let satisfied = self
12819            .schema_floor
12820            .as_ref()
12821            .and_then(|floor| floor.required_schema_version)
12822            .is_some_and(|required| self.schema.version >= required);
12823        if !satisfied {
12824            return;
12825        }
12826        self.schema_floor = None;
12827        self.stopped = false;
12828        self.delete_meta(SCHEMA_FLOOR_KEY);
12829    }
12830
12831    /// RFC 0005 D1: discard an orphan or stale container at boot. The reset
12832    /// path already swept it; this covers a same-version or fresh-install open,
12833    /// which runs no reset and therefore no sweep — plus the D7 TTL, which only
12834    /// an aware binary can apply.
12835    fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
12836        let Some(replica_path) = self.previous_version_replica_path() else {
12837            return Ok(());
12838        };
12839        let max_age_ms = self
12840            .previous_version
12841            .filter(|config| config.enabled)
12842            .map(|config| config.max_age_ms);
12843        reconcile_previous_version_at_boot(
12844            &self.conn,
12845            &replica_path,
12846            self.schema.version,
12847            max_age_ms,
12848            self.clock_now_ms(),
12849        )
12850    }
12851
12852    /// RFC 0005 D3: the sibling container path, or `None` when the replica has
12853    /// no file — an in-memory replica returns an empty path, and a `file:` URI
12854    /// has no plain sibling path — so the feature resolves as off there.
12855    fn previous_version_replica_path(&self) -> Option<String> {
12856        let path = self.conn.path()?;
12857        if path.is_empty() || path.starts_with("file:") {
12858            return None;
12859        }
12860        Some(path.to_owned())
12861    }
12862
12863    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
12864    /// and leaseState. Drops/recreates every synced table from the new
12865    /// schema, resets subscription sync-state (keeping registrations), clears
12866    /// the schema-floor stop state, rewrites the marker, drops outbox commits
12867    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
12868    fn run_schema_reset(&mut self) -> Result<(), String> {
12869        self.begin_observation("syncular_schema_reset")?;
12870        let prior_subs = self.subs.clone();
12871        let prior_outbox = self.outbox.clone();
12872        let prior_rejections = self.rejections.clone();
12873        let prior_upgrading = self.upgrading;
12874        let prior_stopped = self.stopped;
12875        let prior_schema_floor = self.schema_floor.clone();
12876        let prior_overlay_dirty = self.overlay_dirty.snapshot();
12877        let prior_sync_needed = self.sync_needed;
12878        let prior_active_round = self.active_round;
12879        let mut batch = ChangeAccumulator::default();
12880        let result = self
12881            .run_schema_reset_observed(&mut batch, true)
12882            .and_then(|()| self.finish_observation("syncular_schema_reset", batch));
12883        if let Err(error) = result {
12884            self.rollback_observation("syncular_schema_reset");
12885            self.subs = prior_subs;
12886            self.outbox = prior_outbox;
12887            self.rejections = prior_rejections;
12888            self.upgrading = prior_upgrading;
12889            self.stopped = prior_stopped;
12890            self.schema_floor = prior_schema_floor;
12891            self.overlay_dirty.restore(prior_overlay_dirty);
12892            self.sync_needed = prior_sync_needed;
12893            self.active_round = prior_active_round;
12894            return Err(error);
12895        }
12896        Ok(())
12897    }
12898
12899    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
12900        if self.get_meta(LOG_EPOCH_KEY).is_none() {
12901            self.begin_observation("syncular_epoch_acquisition")?;
12902            let previous_sync_needed = self.sync_needed;
12903            if let Err(error) = self.conn.execute(
12904                "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
12905                rusqlite::params![LOG_EPOCH_KEY, log_epoch],
12906            ) {
12907                self.rollback_observation("syncular_epoch_acquisition");
12908                return Err(Self::sqlite_failure(&self.storage_failure, error));
12909            }
12910            self.sync_needed = true;
12911            let batch = ChangeAccumulator {
12912                status: true,
12913                ..ChangeAccumulator::default()
12914            };
12915            if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
12916                self.rollback_observation("syncular_epoch_acquisition");
12917                self.sync_needed = previous_sync_needed;
12918                return Err(error);
12919            }
12920            self.sync_intent_queue.push_back(SyncIntent::Interactive);
12921            return Ok(Vec::new());
12922        }
12923        let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
12924        self.begin_observation("syncular_log_epoch_reset")?;
12925        let prior_subs = self.subs.clone();
12926        let prior_outbox = self.outbox.clone();
12927        let prior_rejections = self.rejections.clone();
12928        let prior_upgrading = self.upgrading;
12929        let prior_stopped = self.stopped;
12930        let prior_schema_floor = self.schema_floor.clone();
12931        let prior_overlay_dirty = self.overlay_dirty.snapshot();
12932        let prior_sync_needed = self.sync_needed;
12933        let prior_active_round = self.active_round;
12934        let mut batch = ChangeAccumulator::default();
12935        let result = self
12936            .run_schema_reset_observed(&mut batch, false)
12937            .and_then(|()| {
12938                self.conn
12939                    .execute(
12940                        "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
12941                        rusqlite::params![LOG_EPOCH_KEY, log_epoch],
12942                    )
12943                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12944                self.sync_needed = true;
12945                batch.status = true;
12946                self.finish_observation("syncular_log_epoch_reset", batch)
12947            });
12948        if let Err(error) = result {
12949            self.rollback_observation("syncular_log_epoch_reset");
12950            self.subs = prior_subs;
12951            self.outbox = prior_outbox;
12952            self.rejections = prior_rejections;
12953            self.upgrading = prior_upgrading;
12954            self.stopped = prior_stopped;
12955            self.schema_floor = prior_schema_floor;
12956            self.overlay_dirty.restore(prior_overlay_dirty);
12957            self.sync_needed = prior_sync_needed;
12958            self.active_round = prior_active_round;
12959            return Err(error);
12960        }
12961        self.sync_intent_queue.push_back(SyncIntent::Interactive);
12962        Ok(resets)
12963    }
12964
12965    fn run_schema_reset_observed(
12966        &mut self,
12967        batch: &mut ChangeAccumulator,
12968        capture: bool,
12969    ) -> Result<(), String> {
12970        // §7.4.2: the reset is the only path that wipes local tables for a
12971        // version bump, and it rewrites the marker last. Re-read the marker
12972        // under this reset's transaction: a replica another process upgraded
12973        // meanwhile refuses here instead of being reset to this client's
12974        // older schema.
12975        read_local_schema_version(&self.conn, self.schema.version)?;
12976        self.conn
12977            .execute_batch(
12978                "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
12979            )
12980            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12981        self.cancel_sync_round();
12982        self.upgrading = true;
12983        batch.status = true;
12984        // RFC 0005 D6: classify the pending outbox against the NEW compiled
12985        // schema BEFORE the wipe, and record the advisory audit. This pass
12986        // drops nothing, so the audit sees the original pending intent; the
12987        // §7.4.4 send-time drop and the intrinsic value recovery later in this
12988        // reset are the paths that remove a commit. The classification is
12989        // shared with the send-time drop so the two cannot disagree about what
12990        // is incompatible.
12991        if self.previous_version.is_some_and(|config| config.enabled) {
12992            let previous_version = self
12993                .get_meta(LOCAL_SCHEMA_VERSION_KEY)
12994                .and_then(|value| value.parse::<i32>().ok())
12995                .unwrap_or(self.schema.version);
12996            let pending = self
12997                .outbox
12998                .iter()
12999                .map(|commit| PendingCommitAudit {
13000                    commit_id: &commit.client_commit_id,
13001                    operations: commit_audit_operations(commit),
13002                })
13003                .collect::<Vec<_>>();
13004            crate::previous_version::write_previous_version_audit(
13005                &self.conn,
13006                &crate::previous_version::build_previous_version_audit(
13007                    &self.schema,
13008                    &pending,
13009                    previous_version,
13010                    self.schema.version,
13011                    self.clock_now_ms(),
13012                ),
13013            );
13014        }
13015        // RFC 0005 D5: the container lives in its own FILE, so the reset can
13016        // never reach it. Sweep first (unconditional — also on the log-epoch
13017        // reset, which captures nothing), then capture BEFORE the wipe drops
13018        // any row. A refused capture records its reason and leaves no file.
13019        if let Some(replica_path) = self.previous_version_replica_path() {
13020            sweep_previous_version_container(&self.conn, &replica_path)?;
13021            if capture {
13022                if let Some(config) = self.previous_version.filter(|config| config.enabled) {
13023                    let previous_version = self
13024                        .get_meta(LOCAL_SCHEMA_VERSION_KEY)
13025                        .and_then(|value| value.parse::<i32>().ok())
13026                        .unwrap_or(self.schema.version);
13027                    capture_previous_version_from_replica(
13028                        &self.conn,
13029                        &replica_path,
13030                        previous_version,
13031                        self.schema.version,
13032                        &config,
13033                        self.clock_now_ms(),
13034                    )?;
13035                }
13036            }
13037        }
13038        for table in &self.schema.tables {
13039            batch.table(&table.name);
13040        }
13041        for (base_key, unit, table) in self.load_registered_window_units() {
13042            batch.window(&base_key, &table, &unit);
13043        }
13044        if capture {
13045            self.prune_unknown_subscriptions(true)?;
13046        }
13047        // The per-table insert SQL is derived from the OLD column lists.
13048        self.insert_sql.borrow_mut().clear();
13049        self.overlay_dirty.set(true);
13050        // Drop every synced table (base + visible) that currently exists —
13051        // discovered from sqlite_master so a bump that adds/removes tables is
13052        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
13053        // and the blob cache) are preserved; base tables are `_syncular_base_*`
13054        // so they are matched explicitly, not by the bookkeeping filter.
13055        // Drop virtual tables first so SQLite removes every FTS shadow table
13056        // atomically instead of the generic discovery tearing it apart.
13057        let virtual_tables: Vec<String> = {
13058            let mut stmt = self
13059                .conn
13060                .prepare(
13061                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
13062                )
13063                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13064            let rows = stmt
13065                .query_map([], |row| row.get::<_, String>(0))
13066                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13067            rows.filter_map(Result::ok)
13068                .filter(|name| is_synced_table_name(name))
13069                .collect()
13070        };
13071        for name in virtual_tables {
13072            self.conn
13073                .execute_batch(&format!(
13074                    "DROP TABLE IF EXISTS {}",
13075                    quote_ident(&format!("_syncular_fts_{name}"))
13076                ))
13077                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13078            self.conn
13079                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
13080                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13081        }
13082        let existing: Vec<String> = {
13083            let mut stmt = self
13084                .conn
13085                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
13086                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13087            let rows = stmt
13088                .query_map([], |row| row.get::<_, String>(0))
13089                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13090            rows.filter_map(Result::ok)
13091                .filter(|name| is_synced_table_name(name))
13092                .collect()
13093        };
13094        for name in &existing {
13095            if let Some(table) = name.strip_prefix("_syncular_base_") {
13096                batch.table(table);
13097            } else if !name.starts_with("_syncular_") {
13098                batch.table(name);
13099            }
13100        }
13101        for name in existing {
13102            self.conn
13103                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
13104                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13105        }
13106        // Recreate the synced tables from the NEW schema.
13107        self.create_synced_tables()?;
13108        // Reset every subscription's sync-state, keeping the registration.
13109        for sub in &mut self.subs {
13110            sub.cursor = -1;
13111            sub.bootstrap_state = None;
13112            sub.effective = None;
13113            sub.state = SubState::Active;
13114            sub.reason_code = None;
13115            sub.synced_once = false;
13116        }
13117        let subs = self.subs.clone();
13118        for sub in &subs {
13119            self.persist_sub(sub)?;
13120        }
13121        // The stop state is over: this client now ships a servable schema.
13122        self.stopped = false;
13123        self.schema_floor = None;
13124        self.delete_meta(SCHEMA_FLOOR_KEY);
13125        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
13126        self.persist_schema_version()?;
13127        self.save_subscription_scope_schema();
13128        // §7.1/§7.4.4: the previous-version capture and audit above saw the
13129        // original pending intent; the synced tables now carry the new schema, so
13130        // recover intrinsically codec-invalid persisted values here, before the
13131        // final replay. A pending upsert that names a column the new schema
13132        // removed stays pending for the send-time `drop_incompatible_outbox`
13133        // prepass. The reset wrapper snapshots and restores the outbox,
13134        // rejections, and change queues, so a failed commit rolls this recovery
13135        // back with the rest of the reset.
13136        // §7.4.4: the enclosing reset observation owns the publication, so the
13137        // recovery contributes its rejection, outcome, and table domains to it.
13138        self.drop_unencodable_outbox(false, Some(batch))?;
13139        // Re-apply the surviving outbox optimistically over the empty tables.
13140        self.rebuild_overlay()?;
13141        Ok(())
13142    }
13143
13144    /// §7.4.4 send-time prepass: a persisted upsert whose values reference a
13145    /// column the current schema lacks (or a removed table) cannot be encoded
13146    /// (`commit_audit_operations` inspects upserts, so a value-free delete stays
13147    /// encodable and is validated by the server). Drop the commit, undo its
13148    /// optimistic projection, and raise a client-local
13149    /// `sync.outbox_incompatible` rejection.
13150    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
13151        let incompatible = self
13152            .outbox
13153            .iter()
13154            .filter(|commit| {
13155                first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
13156            })
13157            .cloned()
13158            .collect::<Vec<_>>();
13159        if incompatible.is_empty() {
13160            return Ok(false);
13161        }
13162        // §7.4.4: one observation covers the durable outcome writes, the outbox
13163        // removal, the in-memory drop, and the overlay rebuild, so a storage
13164        // fault leaves the replica and the client unchanged and publishes no
13165        // change; a successful drop publishes exactly one batch carrying the
13166        // rejections, outcomes, status, and the tables whose projection changed.
13167        let prior_outbox = self.outbox.clone();
13168        let prior_rejections = self.rejections.clone();
13169        let prior_overlay_dirty = self.overlay_dirty.snapshot();
13170        let owns_transaction = self.conn.is_autocommit();
13171        self.begin_observation("syncular_outbox_drop")?;
13172        let mut batch = ChangeAccumulator::default();
13173        let dropped = (|| -> Result<(), String> {
13174            let mut rejections = Vec::new();
13175            for commit in &incompatible {
13176                let results = commit
13177                    .ops
13178                    .iter()
13179                    .enumerate()
13180                    .map(|(op_index, operation)| {
13181                        let rejection = RejectionRecord {
13182                            client_commit_id: commit.client_commit_id.clone(),
13183                            op_index: op_index as i32,
13184                            code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
13185                            message: "the persisted commit cannot encode under the current schema"
13186                                .to_owned(),
13187                            retryable: false,
13188                            details: None,
13189                            operation: Some(CommitOperation::from(operation)),
13190                        };
13191                        rejections.push(rejection.clone());
13192                        CommitOperationOutcome::Error { rejection }
13193                    })
13194                    .collect::<Vec<_>>();
13195                self.persist_commit_outcome(
13196                    &commit.client_commit_id,
13197                    CommitOutcomeStatus::Rejected,
13198                    &results,
13199                    Some(&commit.ops),
13200                )?;
13201                self.delete_outbox_persisted(&commit.client_commit_id)?;
13202                // A removed table has no local projection to invalidate; every
13203                // surviving table the dropped commit touched does.
13204                for operation in &commit.ops {
13205                    if self.schema.table(&operation.table).is_some() {
13206                        batch.table(&operation.table);
13207                    }
13208                }
13209            }
13210            self.prune_commit_outcomes()?;
13211            let incompatible_ids = incompatible
13212                .iter()
13213                .map(|commit| commit.client_commit_id.as_str())
13214                .collect::<BTreeSet<_>>();
13215            self.outbox
13216                .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
13217            self.rejections.extend(rejections);
13218            // The dropped commits' purely-optimistic rows are undone: the
13219            // visible projection is re-derived from the surviving base + outbox.
13220            self.overlay_dirty.set(true);
13221            self.rebuild_overlay()?;
13222            batch.status = true;
13223            batch.rejections = true;
13224            batch.outcomes = true;
13225            Ok(())
13226        })();
13227        let result = dropped.and_then(|()| self.finish_observation("syncular_outbox_drop", batch));
13228        if let Err(error) = result {
13229            // An outer RELEASE is the COMMIT. Roll back that transaction
13230            // directly: retrying RELEASE after ROLLBACK TO still needs the
13231            // reader's lock. A nested observation only rolls back its savepoint.
13232            if owns_transaction {
13233                if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
13234                    let mut failure = self.storage_failure.borrow_mut();
13235                    let failure =
13236                        failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
13237                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
13238                }
13239            } else {
13240                self.rollback_observation("syncular_outbox_drop");
13241            }
13242            self.outbox = prior_outbox;
13243            self.rejections = prior_rejections;
13244            self.overlay_dirty.restore(prior_overlay_dirty);
13245            return Err(error);
13246        }
13247        Ok(true)
13248    }
13249
13250    /// §7.4.3: (re)create the base + visible table pair for every synced
13251    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
13252    fn create_synced_tables(&self) -> Result<(), String> {
13253        for table in &self.schema.tables {
13254            // The base half + the visible half form the synced-table pair. An
13255            // index name is global in SQLite, so the base half's indexes are
13256            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
13257            for (full, index_prefix) in [
13258                (base_table(&table.name), "_syncular_base_"),
13259                (visible_table(&table.name), ""),
13260            ] {
13261                let mut cols: Vec<String> =
13262                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
13263                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
13264                let sql = format!(
13265                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
13266                    cols.join(", "),
13267                    quote_ident(&table.primary_key)
13268                );
13269                self.conn
13270                    .execute(&sql, [])
13271                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13272                // Local secondary indexes (CREATE INDEX subset). Created on
13273                // both halves so mirror reads hit an index on either. Runs on
13274                // both the initial create and the §7.4.3 reset recreate path.
13275                for index in &table.indexes {
13276                    let unique = if index.unique { "UNIQUE " } else { "" };
13277                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
13278                    let cols_sql = index
13279                        .columns
13280                        .iter()
13281                        .map(|c| quote_ident(c))
13282                        .collect::<Vec<_>>()
13283                        .join(", ");
13284                    let index_sql = format!(
13285                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
13286                    );
13287                    self.conn
13288                        .execute(&index_sql, [])
13289                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13290                }
13291            }
13292        }
13293        // FTS exists only on the visible half. It is a local search
13294        // projection, never a synced/base table or a wire-schema member.
13295        for table in &self.schema.tables {
13296            for index in &table.fts_indexes {
13297                self.create_fts_projection(table, index)?;
13298            }
13299        }
13300        Ok(())
13301    }
13302
13303    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
13304        let count: i64 = self
13305            .conn
13306            .query_row(
13307                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
13308                rusqlite::params![index.name],
13309                |row| row.get(0),
13310            )
13311            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13312        Ok(count > 0)
13313    }
13314
13315    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
13316        for suffix in ["ai", "ad", "au"] {
13317            self.conn
13318                .execute(
13319                    &format!(
13320                        "DROP TRIGGER IF EXISTS {}",
13321                        quote_ident(&format!("{}_{suffix}", index.name))
13322                    ),
13323                    [],
13324                )
13325                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13326        }
13327        Ok(())
13328    }
13329
13330    fn create_fts_triggers(
13331        &self,
13332        table: &TableSchema,
13333        index: &FtsIndexSchema,
13334    ) -> Result<(), String> {
13335        let fts = quote_ident(&index.name);
13336        let source = visible_table(&table.name);
13337        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
13338        let pk = quote_ident(&table.primary_key);
13339        let projection_columns = std::iter::once(source_id.clone())
13340            .chain(index.columns.iter().map(|column| quote_ident(column)))
13341            .collect::<Vec<_>>()
13342            .join(", ");
13343        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
13344            .chain(
13345                index
13346                    .columns
13347                    .iter()
13348                    .map(|column| format!("new.{}", quote_ident(column))),
13349            )
13350            .collect::<Vec<_>>()
13351            .join(", ");
13352        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
13353        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)");
13354        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)");
13355        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})");
13356        let bi = quote_ident(&format!("{}_bi", index.name));
13357        let ai = quote_ident(&format!("{}_ai", index.name));
13358        let ad = quote_ident(&format!("{}_ad", index.name));
13359        let au = quote_ident(&format!("{}_au", index.name));
13360        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
13361        let sql = format!(
13362            "DROP TRIGGER IF EXISTS {bi};
13363             DROP TRIGGER IF EXISTS {ai};
13364             DROP TRIGGER IF EXISTS {ad};
13365             DROP TRIGGER IF EXISTS {au};
13366             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
13367             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
13368             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
13369             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
13370        );
13371        self.conn
13372            .execute_batch(&sql)
13373            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13374    }
13375
13376    fn rebuild_fts_projection(
13377        &self,
13378        table: &TableSchema,
13379        index: &FtsIndexSchema,
13380    ) -> Result<(), String> {
13381        let fts = quote_ident(&index.name);
13382        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
13383        let pk = quote_ident(&table.primary_key);
13384        let indexed_columns = index
13385            .columns
13386            .iter()
13387            .map(|column| quote_ident(column))
13388            .collect::<Vec<_>>();
13389        let projection_columns = std::iter::once(source_id.clone())
13390            .chain(indexed_columns.iter().cloned())
13391            .collect::<Vec<_>>()
13392            .join(", ");
13393        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
13394        let sql = format!(
13395            "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};",
13396            columns = indexed_columns.join(", "),
13397            source = visible_table(&table.name),
13398        );
13399        self.conn
13400            .execute_batch(&sql)
13401            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13402    }
13403
13404    fn create_fts_projection(
13405        &self,
13406        table: &TableSchema,
13407        index: &FtsIndexSchema,
13408    ) -> Result<(), String> {
13409        self.begin_observation("syncular_fts_schema")?;
13410        let result = (|| {
13411            let existed = self.fts_projection_exists(index)?;
13412            let tokenizer = index.tokenize.replace('\'', "''");
13413            let columns = index
13414                .columns
13415                .iter()
13416                .map(|column| quote_ident(column))
13417                .collect::<Vec<_>>()
13418                .join(", ");
13419            let sql = format!(
13420            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
13421            fts = quote_ident(&index.name),
13422            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
13423        );
13424            self.conn.execute(&sql, []).map_err(|error| {
13425                format!(
13426                    "cannot create local FTS5 projection {:?}: {error}",
13427                    index.name
13428                )
13429            })?;
13430            let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
13431            let mapped: bool = self
13432                .conn
13433                .query_row(
13434                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
13435                    [format!("_syncular_fts_{}", index.name)],
13436                    |row| row.get(0),
13437                )
13438                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13439            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))?;
13440            if !mapped && existed {
13441                self.conn
13442                    .execute_batch(&format!(
13443                        "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
13444                        quote_ident(FTS_SOURCE_ID_COLUMN),
13445                        quote_ident(&index.name)
13446                    ))
13447                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13448            }
13449            self.create_fts_triggers(table, index)?;
13450            if !existed {
13451                self.rebuild_fts_projection(table, index)?;
13452            }
13453            Ok(())
13454        })();
13455        if let Err(error) = result {
13456            self.rollback_observation("syncular_fts_schema");
13457            return Err(error);
13458        }
13459        if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
13460            self.rollback_observation("syncular_fts_schema");
13461            return Err(error.to_string());
13462        }
13463        Ok(())
13464    }
13465
13466    // -- persistence write-through --------------------------------------------
13467
13468    fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
13469        let state = serde_json::json!({
13470            "requested": scope_map_to_json(&sub.requested),
13471            "params": sub.params,
13472            "cursor": sub.cursor,
13473            "bootstrapState": sub.bootstrap_state,
13474            "status": sub.state.name(),
13475            "reasonCode": sub.reason_code,
13476            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
13477            "syncedOnce": sub.synced_once,
13478        });
13479        self.conn.execute(
13480            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
13481            rusqlite::params![sub.id, sub.table, state.to_string()],
13482        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13483    }
13484
13485    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
13486        let ops: Vec<Value> = commit
13487            .ops
13488            .iter()
13489            .map(|op| {
13490                serde_json::json!({
13491                    "op": if op.upsert { "upsert" } else { "delete" },
13492                    "table": op.table,
13493                    "rowId": op.row_id,
13494                    "baseVersion": op.base_version,
13495                    "values": op.values.clone().map(Value::Object),
13496                })
13497            })
13498            .collect();
13499        self.conn
13500            .execute(
13501                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
13502                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
13503            )
13504            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13505        for operation in &commit.ops {
13506            let Some(values) = &operation.values else {
13507                continue;
13508            };
13509            let Some(table) = self.schema.table(&operation.table) else {
13510                continue;
13511            };
13512            for column in table
13513                .columns
13514                .iter()
13515                .filter(|column| column.ty == ColumnType::BlobRef)
13516            {
13517                let Some(Value::String(raw)) = values.get(&column.name) else {
13518                    continue;
13519                };
13520                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
13521                    continue;
13522                };
13523                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
13524                    continue;
13525                };
13526                self.conn
13527                    .execute(
13528                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
13529                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
13530                        rusqlite::params![commit.client_commit_id, blob_id],
13531                    )
13532                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13533            }
13534        }
13535        Ok(())
13536    }
13537
13538    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
13539        if let Some(commit) = self
13540            .outbox
13541            .iter()
13542            .find(|commit| commit.client_commit_id == client_commit_id)
13543        {
13544            for operation in &commit.ops {
13545                self.overlay_dirty.table(&operation.table);
13546            }
13547        }
13548
13549        self.conn
13550            .execute(
13551                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
13552                rusqlite::params![client_commit_id],
13553            )
13554            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13555        self.conn
13556            .execute(
13557                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
13558                rusqlite::params![client_commit_id],
13559            )
13560            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13561        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)", [])
13562            .map(|_| ())
13563            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13564    }
13565
13566    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
13567        match status {
13568            CommitOutcomeStatus::Applied => "applied",
13569            CommitOutcomeStatus::Cached => "cached",
13570            CommitOutcomeStatus::Conflict => "conflict",
13571            CommitOutcomeStatus::Rejected => "rejected",
13572        }
13573    }
13574
13575    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
13576        match resolution {
13577            CommitOutcomeResolution::Active => "active",
13578            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
13579            CommitOutcomeResolution::Superseded => "superseded",
13580            CommitOutcomeResolution::Dismissed => "dismissed",
13581        }
13582    }
13583
13584    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
13585        match value {
13586            "applied" => Ok(CommitOutcomeStatus::Applied),
13587            "cached" => Ok(CommitOutcomeStatus::Cached),
13588            "conflict" => Ok(CommitOutcomeStatus::Conflict),
13589            "rejected" => Ok(CommitOutcomeStatus::Rejected),
13590            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
13591        }
13592    }
13593
13594    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
13595        match value {
13596            "active" => Ok(CommitOutcomeResolution::Active),
13597            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
13598            "superseded" => Ok(CommitOutcomeResolution::Superseded),
13599            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
13600            _ => Err(format!(
13601                "invalid persisted commit outcome resolution {value:?}"
13602            )),
13603        }
13604    }
13605
13606    fn persist_commit_outcome(
13607        &self,
13608        client_commit_id: &str,
13609        status: CommitOutcomeStatus,
13610        results: &[CommitOperationOutcome],
13611        operations: Option<&[OutboxOp]>,
13612    ) -> Result<(), String> {
13613        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
13614        let operations_json = operations
13615            .map(|items| {
13616                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
13617            })
13618            .transpose()
13619            .map_err(|error| error.to_string())?;
13620        self.conn
13621            .execute(
13622                "INSERT INTO _syncular_commit_outcomes (
13623                   client_commit_id, status, recorded_at_ms, results_json,
13624                   operations_json, resolution
13625                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
13626                rusqlite::params![
13627                    client_commit_id,
13628                    Self::outcome_status_name(status),
13629                    self.clock_now_ms(),
13630                    results_json,
13631                    operations_json
13632                ],
13633            )
13634            .map(|_| ())
13635            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13636    }
13637
13638    /// §7.2: the journal holds a stored cell only in its canonical shape. A cell
13639    /// the byte-envelope rule refuses cannot be journaled verbatim.
13640    fn stored_cell_is_representable(cell: &Value) -> bool {
13641        cell.is_null()
13642            || cell.is_string()
13643            || cell.is_boolean()
13644            || cell.is_number()
13645            || is_canonical_bytes_envelope(cell)
13646    }
13647
13648    fn stored_row_is_representable(value: &Value) -> bool {
13649        value
13650            .as_object()
13651            .is_some_and(|row| row.values().all(Self::stored_cell_is_representable))
13652    }
13653
13654    fn stored_operation_is_representable(value: &Value) -> bool {
13655        value.get("table").is_some_and(Value::is_string)
13656            && value.get("rowId").is_some_and(Value::is_string)
13657            && value.get("baseVersion").is_none_or(|v| {
13658                v.as_i64()
13659                    .is_some_and(|n| n.unsigned_abs() <= MAX_JS_SAFE_INTEGER)
13660            })
13661            && match value.get("op").and_then(Value::as_str) {
13662                Some("upsert") => value
13663                    .get("values")
13664                    .is_some_and(Self::stored_row_is_representable),
13665                Some("delete") => value.get("values").is_none(),
13666                _ => false,
13667            }
13668    }
13669
13670    /// §7.2/§7.4.4: a dropped commit keeps its operation envelope in the journal
13671    /// only when the journal reader accepts the serialized operation, which is the
13672    /// rule `outcome_from_row` applies to this same row later.
13673    fn outbox_op_is_journalable(operation: &OutboxOp) -> bool {
13674        serde_json::to_value(CommitOperation::from(operation))
13675            .is_ok_and(|value| Self::stored_operation_is_representable(&value))
13676    }
13677
13678    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
13679        let StoredCommitOutcomeRow {
13680            sequence,
13681            client_commit_id,
13682            status,
13683            recorded_at_ms,
13684            results_json,
13685            operations_json,
13686            resolution,
13687            resolved_at_ms,
13688            replacement_client_commit_id,
13689        } = row;
13690        if sequence.unsigned_abs() > MAX_JS_SAFE_INTEGER
13691            || recorded_at_ms.unsigned_abs() > MAX_JS_SAFE_INTEGER
13692            || resolved_at_ms.is_some_and(|value| value.unsigned_abs() > MAX_JS_SAFE_INTEGER)
13693        {
13694            return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned());
13695        }
13696        let invalid = || "sync.local_corrupt: persisted commit outcome is invalid".to_owned();
13697        let stored: Vec<Value> = serde_json::from_str(&results_json).map_err(|_| invalid())?;
13698        for result in &stored {
13699            let nested = match result.get("status").and_then(Value::as_str) {
13700                Some("conflict") => result.get("conflict"),
13701                Some("error") => result.get("rejection"),
13702                _ => None,
13703            };
13704            if nested
13705                .and_then(|record| record.get("operation"))
13706                .is_some_and(|op| !Self::stored_operation_is_representable(op))
13707                || (result.get("status").and_then(Value::as_str) == Some("conflict")
13708                    && nested
13709                        .and_then(|record| record.get("serverRow"))
13710                        .is_some_and(|row| !Self::stored_row_is_representable(row)))
13711            {
13712                return Err(invalid());
13713            }
13714        }
13715        let results: Vec<CommitOperationOutcome> =
13716            serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())?;
13717        let has_conflict = results
13718            .iter()
13719            .any(|result| matches!(result, CommitOperationOutcome::Conflict { .. }));
13720        let has_error = results
13721            .iter()
13722            .any(|result| matches!(result, CommitOperationOutcome::Error { .. }));
13723        let status = Self::parse_outcome_status(&status).map_err(|_| invalid())?;
13724        let redacted = results.is_empty() && operations_json.is_none();
13725        if match status {
13726            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => has_conflict || has_error,
13727            CommitOutcomeStatus::Conflict => !(has_conflict || redacted),
13728            CommitOutcomeStatus::Rejected => has_conflict || !(has_error || redacted),
13729        } {
13730            return Err(invalid());
13731        }
13732        let operations: Option<Vec<CommitOperation>> = operations_json
13733            .map(|raw| {
13734                let stored: Vec<Value> = serde_json::from_str(&raw).map_err(|_| invalid())?;
13735                if !stored.iter().all(Self::stored_operation_is_representable) {
13736                    return Err(invalid());
13737                }
13738                serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())
13739            })
13740            .transpose()?;
13741        for result in &results {
13742            match result {
13743                CommitOperationOutcome::Conflict { conflict }
13744                    if conflict.server_version.unsigned_abs() > MAX_JS_SAFE_INTEGER =>
13745                {
13746                    return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned())
13747                }
13748                CommitOperationOutcome::Error { rejection } => {
13749                    if let Some(details) = &rejection.details {
13750                        let encoded = serde_json::to_string(details).map_err(|_| {
13751                            "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
13752                        })?;
13753                        RejectionDetails::parse(&encoded).map_err(|_| {
13754                            "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
13755                        })?;
13756                    }
13757                }
13758                _ => {}
13759            }
13760        }
13761        Ok(CommitOutcome {
13762            sequence,
13763            client_commit_id,
13764            status,
13765            recorded_at_ms,
13766            results,
13767            operations,
13768            retained_rows: None,
13769            resolution: Self::parse_outcome_resolution(&resolution)?,
13770            resolved_at_ms,
13771            replacement_client_commit_id,
13772        })
13773    }
13774
13775    /// Resolve declared unique keys through SQLite, including affinity and NULL semantics.
13776    fn unique_conflicts(
13777        &self,
13778        table: &TableSchema,
13779        values: &Map<String, Value>,
13780        base: bool,
13781    ) -> Result<Vec<RetainedUniqueConflict>, String> {
13782        let source = if base {
13783            base_table(&table.name)
13784        } else {
13785            visible_table(&table.name)
13786        };
13787        let mut conflicts = Vec::new();
13788        for index in table.indexes.iter().filter(|index| index.unique) {
13789            let mut params = vec![json_param_to_sql(
13790                values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
13791            )?];
13792            for name in &index.columns {
13793                let column = table
13794                    .columns
13795                    .iter()
13796                    .find(|column| column.name == *name)
13797                    .ok_or("sync.local_corrupt")?;
13798                let cell = json_to_column_value(column, values.get(name))?;
13799                params.push(owned_sql_value(RowParam::Cell(&cell))?);
13800            }
13801            let sql = format!(
13802                "SELECT * FROM {source} WHERE {} != ? AND {}",
13803                quote_ident(&table.primary_key),
13804                index
13805                    .columns
13806                    .iter()
13807                    .map(|column| format!("{} = ?", quote_ident(column)))
13808                    .collect::<Vec<_>>()
13809                    .join(" AND ")
13810            );
13811            let mut statement = self
13812                .conn
13813                .prepare_cached(&sql)
13814                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13815            let records = statement
13816                .query_map(rusqlite::params_from_iter(params), |record| {
13817                    let values = table
13818                        .columns
13819                        .iter()
13820                        .enumerate()
13821                        .map(|(index, column)| {
13822                            let value = sql_ref_to_json(column, record.get_ref(index)?);
13823                            json_to_column_value(column, Some(&value)).map_err(|message| {
13824                                rusqlite::Error::ToSqlConversionFailure(message.into())
13825                            })
13826                        })
13827                        .collect::<Result<Row, _>>()?;
13828                    Ok((values, record.get::<_, i64>(table.columns.len())?))
13829                })
13830                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13831            for record in records {
13832                let (row, version) =
13833                    record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13834                let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
13835                conflicts.push(RetainedUniqueConflict {
13836                    index: index.name.clone(),
13837                    columns: index.columns.clone(),
13838                    row_id,
13839                    server_row: table
13840                        .columns
13841                        .iter()
13842                        .zip(&row)
13843                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
13844                        .collect(),
13845                    server_version: version,
13846                });
13847            }
13848        }
13849        Ok(conflicts)
13850    }
13851
13852    fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
13853        if let Some((commit, initial)) = self
13854            .failed_commits
13855            .iter()
13856            .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
13857        {
13858            let mut rows = Vec::new();
13859            for operation in &commit.ops {
13860                let table = self
13861                    .schema
13862                    .table(&operation.table)
13863                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
13864                let key =
13865                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
13866                        .map_err(|_| "sync.local_corrupt".to_owned())?;
13867                let base = self.stored_row(table, &operation.row_id, true)?;
13868                rows.push(RetainedCommitRow {
13869                    table: operation.table.clone(),
13870                    row_id: operation.row_id.clone(),
13871                    unique_conflicts: if operation.upsert {
13872                        self.unique_conflicts(
13873                            table,
13874                            initial
13875                                .get(&key)
13876                                .and_then(Value::as_object)
13877                                .or(operation.values.as_ref())
13878                                .ok_or("sync.local_corrupt")?,
13879                            true,
13880                        )?
13881                    } else {
13882                        Vec::new()
13883                    },
13884                    local_row: if operation.upsert {
13885                        initial
13886                            .get(&key)
13887                            .and_then(Value::as_object)
13888                            .cloned()
13889                            .or_else(|| operation.values.clone())
13890                    } else {
13891                        None
13892                    },
13893                    server_row: base.as_ref().map(|(values, _)| {
13894                        table
13895                            .columns
13896                            .iter()
13897                            .zip(values)
13898                            .map(|(column, value)| {
13899                                (column.name.clone(), column_value_to_json(value))
13900                            })
13901                            .collect()
13902                    }),
13903                    server_version: base.map(|(_, version)| version),
13904                });
13905            }
13906            outcome.retained_rows = Some(rows);
13907        }
13908        Ok(outcome)
13909    }
13910
13911    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
13912        let row = self
13913            .conn
13914            .query_row(
13915                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
13916                        resolution, resolved_at_ms, replacement_client_commit_id
13917                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
13918                rusqlite::params![client_commit_id],
13919                |row| {
13920                    Ok(StoredCommitOutcomeRow {
13921                        sequence: row.get(0)?,
13922                        client_commit_id: row.get(1)?,
13923                        status: row.get(2)?,
13924                        recorded_at_ms: row.get(3)?,
13925                        results_json: row.get(4)?,
13926                        operations_json: row.get(5)?,
13927                        resolution: row.get(6)?,
13928                        resolved_at_ms: row.get(7)?,
13929                        replacement_client_commit_id: row.get(8)?,
13930                    })
13931                },
13932            )
13933            .optional()
13934            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13935        row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
13936            .transpose()
13937    }
13938
13939    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
13940        if query.limit == Some(0) {
13941            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
13942        }
13943        let mut sql = String::from(
13944            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
13945                    resolution, resolved_at_ms, replacement_client_commit_id
13946               FROM _syncular_commit_outcomes",
13947        );
13948        if query.active_only {
13949            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
13950        }
13951        sql.push_str(" ORDER BY seq DESC");
13952        if let Some(limit) = query.limit {
13953            sql.push_str(&format!(" LIMIT {limit}"));
13954        }
13955        let mut stmt = self
13956            .conn
13957            .prepare(&sql)
13958            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13959        let rows = stmt
13960            .query_map([], |row| {
13961                Ok(StoredCommitOutcomeRow {
13962                    sequence: row.get(0)?,
13963                    client_commit_id: row.get(1)?,
13964                    status: row.get(2)?,
13965                    recorded_at_ms: row.get(3)?,
13966                    results_json: row.get(4)?,
13967                    operations_json: row.get(5)?,
13968                    resolution: row.get(6)?,
13969                    resolved_at_ms: row.get(7)?,
13970                    replacement_client_commit_id: row.get(8)?,
13971                })
13972            })
13973            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13974        let mut outcomes = Vec::new();
13975        for row in rows {
13976            outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
13977                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
13978            )?)?);
13979        }
13980        Ok(outcomes)
13981    }
13982
13983    fn prune_commit_outcomes(&self) -> Result<(), String> {
13984        #[cfg(test)]
13985        self.outcome_prune_count
13986            .set(self.outcome_prune_count.get() + 1);
13987        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
13988        let count = self
13989            .conn
13990            .query_row(
13991                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
13992                [],
13993                |row| row.get::<_, i64>(0),
13994            )
13995            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13996            as usize;
13997        let excess = count.saturating_sub(max_entries);
13998        if excess == 0 {
13999            return Ok(());
14000        }
14001        let mut stmt = self
14002            .conn
14003            .prepare(
14004                "SELECT seq FROM _syncular_commit_outcomes
14005                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
14006                  ORDER BY seq ASC LIMIT ?1",
14007            )
14008            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14009        let rows = stmt
14010            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
14011            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14012        let sequences = rows
14013            .collect::<Result<Vec<_>, _>>()
14014            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14015        drop(stmt);
14016        for sequence in sequences {
14017            self.conn
14018                .execute(
14019                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
14020                    rusqlite::params![sequence],
14021                )
14022                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14023        }
14024        Ok(())
14025    }
14026
14027    // -- driver surface ---------------------------------------------------------
14028
14029    pub fn subscribe(
14030        &mut self,
14031        id: String,
14032        table: String,
14033        scopes: Vec<(String, Vec<String>)>,
14034        params: Option<String>,
14035    ) -> Result<(), String> {
14036        if self.schema.table(&table).is_none() {
14037            return Err(format!("unknown table {table:?}"));
14038        }
14039        let registered_at = self
14040            .subs
14041            .iter()
14042            .position(|subscription| subscription.id == id);
14043        if let Some(index) = registered_at {
14044            let existing = &self.subs[index];
14045            let same_intent = existing.table == table
14046                && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
14047                && existing.params == params;
14048            if !same_intent {
14049                return Err(
14050                    "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
14051                        .to_owned(),
14052                );
14053            }
14054            // §3.3: a revoked registration keeps its id and intent but no
14055            // progress. Subscribing again with the same intent re-arms it and
14056            // asks for a pull, which resolves its scopes from scratch. A
14057            // `failed` registration is a configuration error and stays put.
14058            if existing.state != SubState::Revoked {
14059                return Ok(());
14060            }
14061        }
14062        let sub = Subscription {
14063            id: id.clone(),
14064            table,
14065            requested: scopes,
14066            params,
14067            cursor: -1,
14068            bootstrap_state: None,
14069            state: SubState::Active,
14070            reason_code: None,
14071            effective: None,
14072            synced_once: false,
14073        };
14074        self.persist_sub(&sub)?;
14075        match registered_at {
14076            Some(index) => self.subs[index] = sub,
14077            None => self.subs.push(sub),
14078        }
14079        // Only a re-arm asks for a sync here: a fresh registration reaches the
14080        // next round through the host's schedule, and `set_window` (§4.8)
14081        // batches its own intent for the units it registers.
14082        if registered_at.is_some() {
14083            self.set_sync_needed(true, true);
14084        }
14085        Ok(())
14086    }
14087
14088    /// Register several subscriptions in one local transaction: the same
14089    /// rules as `subscribe` for each request, and one commit for all of them
14090    /// (on OPFS a commit costs milliseconds, so a cold start with hundreds of
14091    /// registrations pays it once). An error rolls back every registration
14092    /// of the call.
14093    pub fn subscribe_many(&mut self, requests: Vec<SubscriptionRequest>) -> Result<(), String> {
14094        let prior_subs = self.subs.clone();
14095        self.begin_observation("syncular_subscribe")?;
14096        let registered = (|| {
14097            for request in requests {
14098                self.subscribe(request.id, request.table, request.scopes, request.params)?;
14099            }
14100            self.conn
14101                .execute_batch("RELEASE syncular_subscribe")
14102                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14103        })();
14104        if let Err(error) = registered {
14105            self.rollback_observation("syncular_subscribe");
14106            self.subs = prior_subs;
14107            return Err(error);
14108        }
14109        Ok(())
14110    }
14111
14112    pub fn unsubscribe(&mut self, id: &str) {
14113        if self.subs.iter().any(|s| s.id == id) {
14114            self.cancel_sync_round();
14115        }
14116        self.subs.retain(|s| s.id != id);
14117        let _ = self.conn.execute(
14118            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
14119            rusqlite::params![id],
14120        );
14121    }
14122
14123    // -- windowed subscriptions (§4.8) ------------------------------------------
14124
14125    /// §4.8: set the live window units for a base — a value-sharded family
14126    /// of subscriptions, one per unit. Added units get fresh subscriptions
14127    /// (image-lane bootstrap on the next sync); removed units are
14128    /// unsubscribed with their first eviction chunk in one transaction (E1–E4).
14129    /// Idempotent; re-entry cancels any deferred eviction.
14130    pub fn set_window(
14131        &mut self,
14132        base: &WindowBase,
14133        units: &[String],
14134    ) -> Result<CommandEffects, String> {
14135        let table = self
14136            .schema
14137            .table(&base.table)
14138            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
14139        if table.scope_column(&base.variable).is_none() {
14140            return Err(format!(
14141                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
14142                base.table, base.variable
14143            ));
14144        }
14145        let base_key = window_base_key(base);
14146        let wanted: std::collections::HashSet<&String> = units.iter().collect();
14147        let live = self.load_window_units(&base_key);
14148        self.begin_observation("syncular_window")?;
14149        let mut batch = ChangeAccumulator::default();
14150        let mut changed = false;
14151
14152        let prior_subs = self.subs.clone();
14153        let widened = (|| {
14154            // Widen: units wanted but not live → fresh subscription + registry row.
14155            for unit in units {
14156                if live.iter().any(|(u, _)| u == unit) {
14157                    continue;
14158                }
14159                let sub_id = derive_sub_id(base, unit);
14160                self.delete_pending_evict(&sub_id)?;
14161                self.insert_window_unit(&base_key, unit, &sub_id)?;
14162                self.subscribe(
14163                    sub_id,
14164                    base.table.clone(),
14165                    unit_scopes(base, unit),
14166                    base.params.clone(),
14167                )?;
14168                batch.window(&base_key, &base.table, unit);
14169                changed = true;
14170            }
14171
14172            self.finish_observation("syncular_window", batch)
14173        })();
14174        if let Err(error) = widened {
14175            self.rollback_observation("syncular_window");
14176            self.subs = prior_subs;
14177            return Err(error);
14178        }
14179        for (unit, sub_id) in live {
14180            if wanted.contains(&unit) {
14181                continue;
14182            }
14183            self.rebuild_overlay_if_dirty()?;
14184            let prior_subs = self.subs.clone();
14185            self.begin_observation("syncular_window")?;
14186            let effective = self
14187                .subs
14188                .iter()
14189                .find(|sub| sub.id == sub_id)
14190                .and_then(|sub| sub.effective.clone())
14191                .unwrap_or_else(|| unit_scopes(base, &unit));
14192            let mut batch = ChangeAccumulator::default();
14193            self.record_scope_map(&mut batch, &base.table, &effective);
14194            batch.window(&base_key, &base.table, &unit);
14195            let result = self.evict_unit(&base_key, base, &unit, &sub_id);
14196            let remaining = match result {
14197                Ok(remaining) => remaining,
14198                Err(error) => {
14199                    self.rollback_observation("syncular_window");
14200                    self.subs = prior_subs;
14201                    return Err(error);
14202                }
14203            };
14204            if let Err(error) = self.finish_observation("syncular_window", batch) {
14205                self.rollback_observation("syncular_window");
14206                self.subs = prior_subs;
14207                return Err(error);
14208            }
14209            if remaining {
14210                self.drain_pending_evictions()?;
14211            }
14212            changed = true;
14213        }
14214        Ok(if changed {
14215            CommandEffects::interactive()
14216        } else {
14217            CommandEffects::none()
14218        })
14219    }
14220
14221    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
14222    /// the subset still bootstrap-pending. Registration alone is not
14223    /// completeness — a unit is pending until its subscription completes a
14224    /// bootstrap round (cursor advances past -1 with no resume token held).
14225    pub fn window_state(&self, base: &WindowBase) -> WindowState {
14226        let mut units = Vec::new();
14227        let mut pending = Vec::new();
14228        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
14229            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
14230                Some(sub) => {
14231                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
14232                }
14233                None => true,
14234            };
14235            if is_pending {
14236                pending.push(unit.clone());
14237            }
14238            units.push(unit);
14239        }
14240        WindowState { units, pending }
14241    }
14242
14243    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
14244        let mut stmt = match self
14245            .conn
14246            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
14247        {
14248            Ok(stmt) => stmt,
14249            Err(_) => return Vec::new(),
14250        };
14251        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
14252            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
14253        });
14254        match rows {
14255            Ok(rows) => rows.filter_map(Result::ok).collect(),
14256            Err(_) => Vec::new(),
14257        }
14258    }
14259
14260    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
14261        let mut stmt = match self.conn.prepare(
14262            "SELECT windows.base, windows.unit, subscriptions.tbl
14263               FROM _syncular_windows AS windows
14264               JOIN _syncular_subscriptions AS subscriptions
14265                 ON subscriptions.id = windows.sub_id
14266               ORDER BY windows.base, windows.unit",
14267        ) {
14268            Ok(stmt) => stmt,
14269            Err(_) => return Vec::new(),
14270        };
14271        let rows = stmt.query_map([], |row| {
14272            Ok((
14273                row.get::<_, String>(0)?,
14274                row.get::<_, String>(1)?,
14275                row.get::<_, String>(2)?,
14276            ))
14277        });
14278        match rows {
14279            Ok(rows) => rows.filter_map(Result::ok).collect(),
14280            Err(_) => Vec::new(),
14281        }
14282    }
14283
14284    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
14285        self.conn
14286            .query_row(
14287                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
14288                rusqlite::params![sub_id],
14289                |row| Ok((row.get(0)?, row.get(1)?)),
14290            )
14291            .ok()
14292    }
14293
14294    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
14295        self.conn
14296            .execute(
14297                "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
14298                rusqlite::params![base_key, unit, sub_id],
14299            )
14300            .map(|_| ())
14301            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14302    }
14303
14304    fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
14305        self.conn
14306            .execute(
14307                "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
14308                rusqlite::params![base_key, unit],
14309            )
14310            .map(|_| ())
14311            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14312    }
14313
14314    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
14315    /// Deletes the unit's rows except those pinned by a pending outbox
14316    /// commit (E1); records a deferred eviction if any pin remains; discards
14317    /// the subscription's cursor/resume/effective-echo (E3) and version
14318    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
14319    /// nothing.
14320    fn evict_unit(
14321        &mut self,
14322        base_key: &str,
14323        base: &WindowBase,
14324        unit: &str,
14325        sub_id: &str,
14326    ) -> Result<bool, String> {
14327        let effective = self
14328            .subs
14329            .iter()
14330            .find(|s| s.id == sub_id)
14331            .and_then(|s| s.effective.clone())
14332            .unwrap_or_else(|| unit_scopes(base, unit));
14333        let pinned = self.pinned_row_ids(&base.table);
14334        let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
14335        self.delete_window_unit(base_key, unit)?;
14336        self.conn
14337            .execute(
14338                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
14339                [sub_id],
14340            )
14341            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14342        self.subs.retain(|sub| sub.id != sub_id);
14343        if deferred {
14344            self.save_pending_evict(sub_id, &base.table, &effective)?;
14345        } else {
14346            self.delete_pending_evict(sub_id)?;
14347        }
14348        Ok(remaining)
14349    }
14350
14351    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
14352    /// primary keys. Returns (more unpinned work, any scoped rows remain).
14353    /// A missing scope-column mapping fails without deleting rows.
14354    fn evict_scope_rows(
14355        &self,
14356        table_name: &str,
14357        effective: &[(String, Vec<String>)],
14358        pinned: &std::collections::HashSet<String>,
14359    ) -> Result<(bool, bool), String> {
14360        if effective.is_empty() {
14361            return Ok((false, false));
14362        }
14363        let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
14364        let mut clauses = Vec::new();
14365        let mut params: Vec<SqlValue> = Vec::new();
14366        for (variable, values) in effective {
14367            let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
14368            if values.is_empty() {
14369                return Ok((false, false));
14370            }
14371            let holes = values
14372                .iter()
14373                .map(|v| {
14374                    params.push(SqlValue::Text(v.clone()));
14375                    "?"
14376                })
14377                .collect::<Vec<_>>()
14378                .join(", ");
14379            clauses.push(format!("{} IN ({holes})", quote_ident(column)));
14380        }
14381        let scope = clauses.join(" AND ");
14382        let scope_params = params.len();
14383        let pk = quote_ident(&table.primary_key);
14384        if !pinned.is_empty() {
14385            let holes = pinned
14386                .iter()
14387                .map(|id| {
14388                    params.push(SqlValue::Text(id.clone()));
14389                    "?"
14390                })
14391                .collect::<Vec<_>>()
14392                .join(", ");
14393            clauses.push(format!("{pk} NOT IN ({holes})"));
14394        }
14395        let predicate = clauses.join(" AND ");
14396        let base = base_table(table_name);
14397        let visible = visible_table(table_name);
14398        let mut ids: Vec<SqlValue> = Vec::new();
14399        for source in [&base, &visible] {
14400            let mut stmt = self
14401                .conn
14402                .prepare_cached(&format!(
14403                    "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
14404                ))
14405                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14406            ids = stmt
14407                .query_map(rusqlite::params_from_iter(&params), |row| row.get(0))
14408                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14409                .collect::<Result<_, _>>()
14410                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14411            if !ids.is_empty() {
14412                break;
14413            }
14414        }
14415        if !ids.is_empty() {
14416            for id in &ids {
14417                self.conn
14418                    .execute(
14419                        "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
14420                        rusqlite::params![table_name, id],
14421                    )
14422                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14423            }
14424            let holes = vec!["?"; ids.len()].join(", ");
14425            for source in [&base, &visible] {
14426                self.conn
14427                    .execute(
14428                        &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
14429                        rusqlite::params_from_iter(&ids),
14430                    )
14431                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14432            }
14433        }
14434        let mut remaining = false;
14435        let mut deferred = false;
14436        for source in [&base, &visible] {
14437            remaining |= self
14438                .conn
14439                .query_row(
14440                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
14441                    rusqlite::params_from_iter(&params),
14442                    |row| row.get::<_, bool>(0),
14443                )
14444                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14445            deferred |= self
14446                .conn
14447                .query_row(
14448                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
14449                    rusqlite::params_from_iter(&params[..scope_params]),
14450                    |row| row.get::<_, bool>(0),
14451                )
14452                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14453        }
14454        Ok((remaining, deferred))
14455    }
14456
14457    /// Resume committed eviction chunks, retaining outbox pins and their durable marker.
14458    fn drain_pending_evictions(&mut self) -> Result<(), String> {
14459        let pending = self.load_pending_evictions()?;
14460        if pending.is_empty() {
14461            return Ok(());
14462        }
14463        self.rebuild_overlay_if_dirty()?;
14464        for (sub_id, table_name, effective) in pending {
14465            if self.schema.table(&table_name).is_none() {
14466                self.delete_pending_evict(&sub_id)?;
14467                continue;
14468            }
14469            loop {
14470                std::thread::yield_now();
14471                self.begin_observation("syncular_evict")?;
14472                let pinned = self.pinned_row_ids(&table_name);
14473                let result = self.evict_scope_rows(&table_name, &effective, &pinned);
14474                let (remaining, deferred) = match result {
14475                    Ok(result) => result,
14476                    Err(error) => {
14477                        self.rollback_observation("syncular_evict");
14478                        return Err(error);
14479                    }
14480                };
14481                if !deferred {
14482                    if let Err(error) = self.delete_pending_evict(&sub_id) {
14483                        self.rollback_observation("syncular_evict");
14484                        return Err(error);
14485                    }
14486                }
14487                let mut batch = ChangeAccumulator::default();
14488                self.record_scope_map(&mut batch, &table_name, &effective);
14489                if let Err(error) = self.finish_observation("syncular_evict", batch) {
14490                    self.rollback_observation("syncular_evict");
14491                    return Err(error);
14492                }
14493                if !remaining {
14494                    break;
14495                }
14496            }
14497        }
14498        Ok(())
14499    }
14500
14501    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
14502    /// commit — rows that MUST NOT be evicted until the commit drains.
14503    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
14504        let mut pinned = std::collections::HashSet::new();
14505        for commit in self
14506            .outbox
14507            .iter()
14508            .chain(self.failed_commits.iter().map(|(commit, _)| commit))
14509        {
14510            for op in &commit.ops {
14511                if op.table == table {
14512                    pinned.insert(op.row_id.clone());
14513                }
14514            }
14515        }
14516        pinned
14517    }
14518
14519    fn save_pending_evict(
14520        &self,
14521        sub_id: &str,
14522        table: &str,
14523        effective: &[(String, Vec<String>)],
14524    ) -> Result<(), String> {
14525        self.conn.execute(
14526            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
14527            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
14528        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14529    }
14530
14531    fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
14532        self.conn
14533            .execute(
14534                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
14535                rusqlite::params![sub_id],
14536            )
14537            .map(|_| ())
14538            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14539    }
14540
14541    fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
14542        let mut stmt = self
14543            .conn
14544            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
14545            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14546        let rows = stmt
14547            .query_map([], |row| {
14548                Ok((
14549                    row.get::<_, String>(0)?,
14550                    row.get::<_, String>(1)?,
14551                    row.get::<_, String>(2)?,
14552                ))
14553            })
14554            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14555        rows.map(|row| {
14556            let (sub_id, table, json) =
14557                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14558            let value: Value = serde_json::from_str(&json)
14559                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
14560            let effective = json_to_scope_map(&value)
14561                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
14562            Ok((sub_id, table, effective))
14563        })
14564        .collect()
14565    }
14566
14567    /// Select failed-intent retention; reload durable retained aggregates.
14568    pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
14569        self.retain_failed_commits = enabled;
14570        for (commit, _) in &self.failed_commits {
14571            for operation in &commit.ops {
14572                self.overlay_dirty.table(&operation.table);
14573            }
14574        }
14575        self.failed_commits = self.load_failed_commits()?;
14576        for (commit, _) in &self.failed_commits {
14577            for operation in &commit.ops {
14578                self.overlay_dirty.table(&operation.table);
14579            }
14580        }
14581        Ok(())
14582    }
14583
14584    fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
14585        let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
14586            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14587        let rows = statement
14588            .query_map([], |row| {
14589                Ok((
14590                    row.get::<_, String>(0)?,
14591                    row.get::<_, String>(1)?,
14592                    row.get::<_, String>(2)?,
14593                ))
14594            })
14595            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14596        rows.map(|row| {
14597            let (id, operations, initial) =
14598                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14599            let operations: Vec<CommitOperation> =
14600                serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
14601            let initial =
14602                serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
14603            Ok((
14604                OutboxCommit {
14605                    client_commit_id: id,
14606                    ops: operations
14607                        .into_iter()
14608                        .map(|op| OutboxOp {
14609                            upsert: op.op == "upsert",
14610                            table: op.table,
14611                            row_id: op.row_id,
14612                            base_version: op.base_version,
14613                            values: op.values,
14614                        })
14615                        .collect(),
14616                },
14617                initial,
14618            ))
14619        })
14620        .collect()
14621    }
14622
14623    fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
14624        let mut initial = Map::new();
14625        for operation in operations {
14626            let table = self
14627                .schema
14628                .table(&operation.table)
14629                .ok_or_else(|| "sync.unknown_table".to_owned())?;
14630            let initial_row = match self.stored_row(table, &operation.row_id, true)? {
14631                Some(row) => Some(row),
14632                None => self.visible_row(table, &operation.row_id)?,
14633            };
14634            if let Some((values, _)) = initial_row {
14635                let mut intended: Map<String, Value> = table
14636                    .columns
14637                    .iter()
14638                    .zip(values.iter())
14639                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
14640                    .collect();
14641                if let Some(patch) = &operation.values {
14642                    intended.extend(patch.clone());
14643                }
14644                initial.insert(
14645                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14646                        .map_err(|_| "sync.local_corrupt".to_owned())?,
14647                    Value::Object(intended),
14648                );
14649            }
14650        }
14651        self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
14652            serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
14653            serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
14654            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14655        self.failed_commits.push((
14656            OutboxCommit {
14657                client_commit_id: id.to_owned(),
14658                ops: operations.to_vec(),
14659            },
14660            initial,
14661        ));
14662        Ok(())
14663    }
14664
14665    fn drop_failed_commits(
14666        &mut self,
14667        matches: impl Fn(&str, &Map<String, Value>) -> bool,
14668    ) -> Result<Vec<OutboxCommit>, String> {
14669        let mut doomed = self.drop_acknowledged_rows(&matches)?;
14670        for (commit, initial) in &self.failed_commits {
14671            for operation in &commit.ops {
14672                let key =
14673                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14674                        .map_err(|_| "sync.local_corrupt".to_owned())?;
14675                if initial
14676                    .get(&key)
14677                    .and_then(Value::as_object)
14678                    .is_some_and(|values| matches(&operation.table, values))
14679                {
14680                    doomed.push(commit.clone());
14681                    break;
14682                }
14683            }
14684        }
14685        for commit in &doomed {
14686            self.conn
14687                .execute(
14688                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
14689                    rusqlite::params![commit.client_commit_id],
14690                )
14691                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14692        }
14693        for commit in &doomed {
14694            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))?;
14695        }
14696        self.conflicts.retain(|record| {
14697            !doomed
14698                .iter()
14699                .any(|commit| commit.client_commit_id == record.client_commit_id)
14700        });
14701        self.rejections.retain(|record| {
14702            !doomed
14703                .iter()
14704                .any(|commit| commit.client_commit_id == record.client_commit_id)
14705        });
14706        self.failed_commits.retain(|(commit, _)| {
14707            !doomed
14708                .iter()
14709                .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
14710        });
14711        for commit in &doomed {
14712            for operation in &commit.ops {
14713                self.overlay_dirty.table(&operation.table);
14714            }
14715        }
14716        Ok(doomed)
14717    }
14718
14719    /// Record one commit of local mutations. The returned commit ID is the
14720    /// §2.3 idempotency key. Failures are structured so callers read
14721    /// `code`/`message`/`details` instead of parsing a string prefix.
14722    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, ClientError> {
14723        let prior = self.storage_failure.borrow_mut().take();
14724        let outcome = (|| -> Result<String, String> {
14725            if mutations.is_empty() {
14726                return Err(
14727                    "sync.invalid_request: a commit must contain at least one operation (§6.1)"
14728                        .to_owned(),
14729                );
14730            }
14731            let mut ops = Vec::with_capacity(mutations.len());
14732            for mutation in mutations {
14733                match mutation {
14734                    Mutation::Patch {
14735                        table,
14736                        values,
14737                        base_version,
14738                    } => {
14739                        let schema_table = self
14740                            .schema
14741                            .table(&table)
14742                            .ok_or_else(|| "sync.unknown_table".to_owned())?;
14743                        let values = normalize_values_casing(schema_table, values)?;
14744                        let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
14745                        ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
14746                    }
14747                    Mutation::Upsert {
14748                        table,
14749                        values,
14750                        base_version,
14751                    } => {
14752                        let schema_table = self.schema.table(&table).ok_or_else(|| {
14753                            format!("sync.unknown_table: unknown table {table:?}")
14754                        })?;
14755                        // §5: value keys are accepted in snake_case AND the
14756                        // generated row types' camelCase; normalize to SQL truth
14757                        // before the pk lookup / codec see them.
14758                        let values = normalize_values_casing(schema_table, values)?;
14759                        let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
14760                        // §6.7 `mutate` marks every column present: fill missing
14761                        // nullable columns with NULL before the payload is built.
14762                        let values = full_row_values(schema_table, values)?;
14763                        // §5.11: validate the payload encodes with the current
14764                        // codec. A key-selection or unknown-key failure is a
14765                        // durable rejection at the push seam (§10.3), never an
14766                        // author-time error. Full-row `mutate` presents every
14767                        // column, so no stored fallback is needed.
14768                        self.validate_author_encode(schema_table, &row_id, &values)?;
14769                        ops.push(OutboxOp {
14770                            upsert: true,
14771                            table,
14772                            row_id,
14773                            base_version,
14774                            values: Some(values),
14775                        });
14776                    }
14777                    Mutation::Delete {
14778                        table,
14779                        row_id,
14780                        base_version,
14781                    } => {
14782                        if self.schema.table(&table).is_none() {
14783                            return Err(format!("sync.unknown_table: unknown table {table:?}"));
14784                        }
14785                        ops.push(OutboxOp {
14786                            upsert: false,
14787                            table,
14788                            row_id,
14789                            base_version,
14790                            values: None,
14791                        });
14792                    }
14793                }
14794            }
14795            self.record_outbox_commit(ops)
14796        })();
14797        self.finish_authoring(prior, outcome)
14798    }
14799
14800    /// Scope the retained `storage_failure` diagnostic to this call so an
14801    /// earlier operation's failure cannot classify a new one, and surface the
14802    /// call's own classified failure with its details. A fresh classified
14803    /// failure replaces the retained diagnostic; otherwise the earlier one is
14804    /// kept for the next sync.
14805    fn finish_authoring(
14806        &self,
14807        prior: Option<QueryReadFailure>,
14808        outcome: Result<String, String>,
14809    ) -> Result<String, ClientError> {
14810        let fresh = self.storage_failure.borrow_mut().take();
14811        *self.storage_failure.borrow_mut() = fresh.clone().or(prior);
14812        match outcome {
14813            Ok(id) => Ok(id),
14814            Err(message) => Err(fresh
14815                .map(ClientError::from)
14816                .unwrap_or_else(|| ClientError::from(message))),
14817        }
14818    }
14819
14820    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
14821        let commit = OutboxCommit {
14822            client_commit_id: uuid::Uuid::new_v4().to_string(),
14823            ops,
14824        };
14825        self.begin_observation("syncular_mutation")?;
14826        let mut batch = ChangeAccumulator::default();
14827        for op in &commit.ops {
14828            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
14829            if let Some(values) = &op.values {
14830                if let Some(table) = self.schema.table(&op.table) {
14831                    for scope in &table.scope_variables {
14832                        if let Some(Value::String(value)) = values.get(&scope.column) {
14833                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
14834                            precise = true;
14835                        }
14836                    }
14837                }
14838            }
14839            if !precise {
14840                batch.table(&op.table);
14841            }
14842        }
14843        if let Err(error) = self.persist_outbox_insert(&commit) {
14844            self.rollback_observation("syncular_mutation");
14845            return Err(error);
14846        }
14847        let was_dirty = self.overlay_dirty.snapshot();
14848        // A clean overlay contains the FIFO fold of every previous commit, so
14849        // the new commit applies over it. §7.1: an operation that does not
14850        // apply (a secondary unique collision) fails the whole commit; the
14851        // savepoint rollback removes the outbox entry and every visible write.
14852        if let Err(error) = self.rebuild_overlay_if_dirty() {
14853            self.rollback_observation("syncular_mutation");
14854            self.overlay_dirty.restore(was_dirty.clone());
14855            return Err(error);
14856        }
14857        if let Some(error) = commit
14858            .ops
14859            .iter()
14860            .find_map(|op| self.apply_outbox_op(op).err())
14861        {
14862            self.rollback_observation("syncular_mutation");
14863            self.overlay_dirty.restore(was_dirty.clone());
14864            return Err(error);
14865        }
14866        let id = commit.client_commit_id.clone();
14867        self.outbox.push(commit);
14868        batch.status = true;
14869        if let Err(error) = self.finish_observation("syncular_mutation", batch) {
14870            self.rollback_observation("syncular_mutation");
14871            self.outbox.pop();
14872            self.overlay_dirty.restore(was_dirty.clone());
14873            return Err(error);
14874        }
14875        Ok(id)
14876    }
14877
14878    /// Record one §6.1 sparse upsert: the primary key plus the supplied
14879    /// non-scope columns are present; every other column is absent and stays
14880    /// untouched on the server and in the local overlay. An absent local base
14881    /// refuses the whole batch with sync.row_missing before enqueueing (§7.1).
14882    /// Failures are structured so callers read `code`/`message`/`details`.
14883    pub fn patch(
14884        &mut self,
14885        table: &str,
14886        row_id: &str,
14887        partial: Map<String, Value>,
14888        base_version: Option<i64>,
14889    ) -> Result<String, ClientError> {
14890        let prior = self.storage_failure.borrow_mut().take();
14891        let outcome = self.perform_patch(table, row_id, partial, base_version);
14892        self.finish_authoring(prior, outcome)
14893    }
14894
14895    /// The unscoped patch body. Public authoring entry points scope
14896    /// `storage_failure` around the whole operation, including any pre-read.
14897    fn perform_patch(
14898        &mut self,
14899        table: &str,
14900        row_id: &str,
14901        partial: Map<String, Value>,
14902        base_version: Option<i64>,
14903    ) -> Result<String, String> {
14904        let operation = self.prepare_patch(table, row_id, partial, base_version)?;
14905        self.record_outbox_commit(vec![operation])
14906    }
14907
14908    fn prepare_patch(
14909        &self,
14910        table: &str,
14911        row_id: &str,
14912        partial: Map<String, Value>,
14913        base_version: Option<i64>,
14914    ) -> Result<OutboxOp, String> {
14915        let schema_table = self
14916            .schema
14917            .table(table)
14918            .ok_or_else(|| format!("sync.unknown_table: unknown table {table:?}"))?;
14919        let partial = normalize_values_casing(schema_table, partial)?;
14920        if let Some(pk) = partial.get(&schema_table.primary_key) {
14921            if render_row_id_json(Some(pk)).ok().as_deref() != Some(row_id) {
14922                return Err(format!(
14923                    "sync.invalid_request: table {table:?}: patch cannot change the primary key"
14924                ));
14925            }
14926        }
14927        // Existence reads only the key. Locked encrypted values stay opaque;
14928        // decoding the complete row here would reject a valid plain patch.
14929        let exists: bool = self
14930            .conn
14931            .query_row(
14932                &format!(
14933                    "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
14934                    visible_table(table),
14935                    row_id_predicate(schema_table)
14936                ),
14937                rusqlite::params![row_id],
14938                |row| row.get(0),
14939            )
14940            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14941        if !exists {
14942            return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
14943        }
14944        // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
14945        // server accepts a present scope column whose value equals the
14946        // stored row and applies it as a no-op, so a decoded envelope
14947        // round-trips. The local row is the only value the client can prove
14948        // equality against; when the row is absent locally the client cannot
14949        // prove equality against the server's stored row and fails closed.
14950        // A primary key that is also a scope column needs no stored-row
14951        // read: the primary key in a sparse payload is by construction the
14952        // row id being patched (§6.1), so its value is proven equal already.
14953        for scope in &schema_table.scope_variables {
14954            if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
14955                continue;
14956            }
14957            let Some(column) = schema_table
14958                .columns
14959                .iter()
14960                .find(|column| column.name == scope.column)
14961            else {
14962                continue;
14963            };
14964            let stored = self
14965                .conn
14966                .query_row(
14967                    &format!(
14968                        "SELECT {} FROM {} WHERE {}",
14969                        quote_ident(&scope.column),
14970                        visible_table(&schema_table.name),
14971                        row_id_predicate(schema_table)
14972                    ),
14973                    rusqlite::params![row_id],
14974                    |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
14975                )
14976                .optional()
14977                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14978            // Decode the supplied value through the same column-type path the
14979            // rest of the patch encoding uses, so a value that only equals
14980            // the stored one in another number representation still compares
14981            // equal.
14982            let incoming = json_to_column_value(column, partial.get(&scope.column))
14983                .ok()
14984                .map(|value| column_value_to_json(&value));
14985            // A stored blob never equals a patch value, matching the server's
14986            // scope-column comparison (§3.4): bytes are unstorable as scope
14987            // values.
14988            let equal = stored.as_ref().is_some_and(|stored| {
14989                stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
14990            });
14991            if !equal {
14992                return Err(format!(
14993                    "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
14994                    scope.column
14995                ));
14996            }
14997        }
14998        let mut values = partial;
14999        // Presence-set semantics: a proven-equal scope column is dropped, so
15000        // the patch leaves the stored value untouched (§6.2).
15001        for scope in &schema_table.scope_variables {
15002            values.remove(&scope.column);
15003        }
15004        let pk_value = match schema_table.columns[schema_table.pk_index].ty {
15005            // §2.4: the rowId of an integer key is its canonical decimal form.
15006            ColumnType::Integer => match row_id.parse::<i64>() {
15007                Ok(v)
15008                    if v.to_string() == row_id
15009                        && (-ssp2::primitives::I64_SAFE_MAX..=ssp2::primitives::I64_SAFE_MAX)
15010                            .contains(&v) =>
15011                {
15012                    Value::from(v)
15013                }
15014                _ => {
15015                    return Err(format!(
15016                    "sync.invalid_request: table {table:?}: invalid integer primary key {row_id:?}"
15017                ))
15018                }
15019            },
15020            ColumnType::Boolean => match row_id {
15021                "true" => Value::Bool(true),
15022                "false" => Value::Bool(false),
15023                _ => {
15024                    return Err(format!(
15025                    "sync.invalid_request: table {table:?}: invalid boolean primary key {row_id:?}"
15026                ))
15027                }
15028            },
15029            _ => Value::from(row_id.to_owned()),
15030        };
15031        values.insert(schema_table.primary_key.clone(), pk_value);
15032        // §5.11: structural validation only; an absent key-id selector is
15033        // resolved at the push seam from the stored local row, and a
15034        // key-selection or unknown-key failure becomes a durable
15035        // `client.encrypt_failed` rejection there (§10.3), never an
15036        // author-time error.
15037        self.validate_author_encode(schema_table, row_id, &values)?;
15038        Ok(OutboxOp {
15039            upsert: true,
15040            table: table.to_owned(),
15041            row_id: row_id.to_owned(),
15042            base_version,
15043            values: Some(values),
15044        })
15045    }
15046
15047    /// §5.11 key-id fallback: the stored local row's selector value as a
15048    /// positional row (only the selector slot filled). Returns None when no
15049    /// selector is configured, the row is locally absent, or the stored
15050    /// value is not a usable key id.
15051    fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
15052        let selector = self.encryption.key_id_columns.get(&table.name)?;
15053        let index = table
15054            .columns
15055            .iter()
15056            .position(|column| &column.name == selector)?;
15057        let sql = format!(
15058            "SELECT {} FROM {} WHERE {}",
15059            quote_ident(selector),
15060            visible_table(&table.name),
15061            row_id_predicate(table)
15062        );
15063        let key: Option<String> = self
15064            .conn
15065            .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
15066            .ok()?;
15067        let key = key.filter(|key| !key.is_empty())?;
15068        let mut fallback: Row = vec![None; table.columns.len()];
15069        fallback[index] = Some(ColumnValue::String(key));
15070        Some(fallback)
15071    }
15072
15073    /// §5.11/§10.3 author-time encode check: a structural failure (a
15074    /// non-nullable NULL, a value the codec rejects) is an author-time
15075    /// error; a key-selection or unknown-key failure is deferred to the push
15076    /// seam, where it becomes a durable `client.encrypt_failed` rejection.
15077    fn validate_author_encode(
15078        &self,
15079        table: &TableSchema,
15080        row_id: &str,
15081        values: &Map<String, Value>,
15082    ) -> Result<(), String> {
15083        match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
15084            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
15085            result => result.map(|_| ()),
15086        }
15087    }
15088
15089    /// §5.11/§10.3: drop every pending commit whose push encode fails with
15090    /// `client.encrypt_failed` (an unresolvable key id or an unknown key) and
15091    /// raise one durable rejection per commit. `sync()` never aborts for an
15092    /// encode failure. Mirrors §7.4.4's `drop_incompatible_outbox`, including
15093    /// undoing the dropped commit's purely-optimistic rows.
15094    /// §7.1/§7.4.4: drop pending commits whose values the current codec refuses
15095    /// (an envelope or a type an earlier version accepted). `with_encryption`
15096    /// additionally drops commits whose §5.11 encryption cannot resolve a key;
15097    /// the startup boundary leaves those to the send seam, where hosts configure
15098    /// keys after open.
15099    /// Recovery runs as one observation. An enclosing observation (the guarded
15100    /// schema reset) passes its `batch`, so the rejection, outcome, and table
15101    /// domains join that change event instead of publishing a second one.
15102    fn drop_unencodable_outbox(
15103        &mut self,
15104        with_encryption: bool,
15105        batch: Option<&mut ChangeAccumulator>,
15106    ) -> Result<bool, String> {
15107        let failures = self
15108            .outbox
15109            .iter()
15110            .filter_map(|commit| {
15111                commit
15112                    .ops
15113                    .iter()
15114                    .find_map(|op| {
15115                        let values = op.values.as_ref().filter(|_| op.upsert)?;
15116                        let table = self.schema.table(&op.table)?;
15117                        let fallback = self.stored_key_fallback(table, &op.row_id);
15118                        match encode_sparse_row_json(
15119                            table,
15120                            &op.row_id,
15121                            values,
15122                            &self.encryption,
15123                            fallback.as_ref(),
15124                        ) {
15125                            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => {
15126                                with_encryption.then_some((error, ENCRYPT_FAILED_CODE))
15127                            }
15128                            // §7.1/§7.4.4: a value the current codec refuses was
15129                            // persisted by a version that accepted it; it leaves
15130                            // through the incompatible-outbox path, so the FIFO
15131                            // queue never wedges.
15132                            Err(error) if error.starts_with(INVALID_REQUEST_CODE) => Some((
15133                                LEGACY_VALUE_REFUSAL_MESSAGE.to_owned(),
15134                                OUTBOX_INCOMPATIBLE_CODE,
15135                            )),
15136                            _ => None,
15137                        }
15138                    })
15139                    .map(|(error, code)| (commit.clone(), error, code))
15140            })
15141            .collect::<Vec<_>>();
15142        if failures.is_empty() {
15143            return Ok(false);
15144        }
15145        // §7.4.4: one observation covers the durable outcome writes, the outbox
15146        // removal, the in-memory drop, and the overlay rebuild, so a storage fault
15147        // leaves the replica and the client unchanged, a successful recovery
15148        // publishes one batch with the rejection, outcome, and table domains, and
15149        // an ordinary open with nothing to recover publishes nothing.
15150        let prior_outbox = self.outbox.clone();
15151        let prior_rejections = self.rejections.clone();
15152        // §5.11 retention and a self-published observation mutate two more
15153        // in-memory fields before this transaction ends, so the rollback below
15154        // restores them with the rest.
15155        let prior_failed_commits = self.failed_commits.clone();
15156        let prior_changes = self.change_queue.len();
15157        let prior_intents = self.sync_intent_queue.len();
15158        let prior_last_change = self.last_change.clone();
15159        let prior_overlay_dirty = self.overlay_dirty.snapshot();
15160        let owns_observation = batch.is_none();
15161        let owns_transaction = self.conn.is_autocommit();
15162        let mut domains = ChangeAccumulator::default();
15163        if owns_observation {
15164            self.begin_observation("syncular_value_recovery")?;
15165        }
15166        let dropped = (|| -> Result<(), String> {
15167            for (commit, message, code) in failures {
15168                if self.retain_failed_commits && code == ENCRYPT_FAILED_CODE {
15169                    self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
15170                }
15171                let representable = commit.ops.iter().all(Self::outbox_op_is_journalable);
15172                let rejection = RejectionRecord {
15173                    client_commit_id: commit.client_commit_id.clone(),
15174                    op_index: 0,
15175                    code: code.to_owned(),
15176                    message,
15177                    retryable: false,
15178                    details: (code == OUTBOX_INCOMPATIBLE_CODE).then(|| RejectionDetails {
15179                        field_paths: None,
15180                        reason: Some(INVALID_STORED_VALUES_REASON.to_owned()),
15181                        required_action: None,
15182                        references: None,
15183                    }),
15184                    operation: representable
15185                        .then(|| commit.ops.first())
15186                        .flatten()
15187                        .map(CommitOperation::from),
15188                };
15189                self.persist_commit_outcome(
15190                    &commit.client_commit_id,
15191                    CommitOutcomeStatus::Rejected,
15192                    &[CommitOperationOutcome::Error {
15193                        rejection: rejection.clone(),
15194                    }],
15195                    representable.then_some(commit.ops.as_slice()),
15196                )?;
15197                self.delete_outbox_persisted(&commit.client_commit_id)?;
15198                self.outbox
15199                    .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
15200                self.rejections.push(rejection);
15201                for operation in &commit.ops {
15202                    domains.table(operation.table.as_str());
15203                }
15204            }
15205            domains.status = true;
15206            domains.rejections = true;
15207            domains.outcomes = true;
15208            self.prune_commit_outcomes()?;
15209            // The visible projection is re-derived from the surviving base + outbox.
15210            self.overlay_dirty.set(true);
15211            self.rebuild_overlay()?;
15212            Ok(())
15213        })();
15214        let result = match batch {
15215            Some(batch) => {
15216                if dropped.is_ok() {
15217                    for table in domains.tables.keys() {
15218                        batch.table(table.as_str());
15219                    }
15220                    batch.status |= domains.status;
15221                    batch.rejections |= domains.rejections;
15222                    batch.outcomes |= domains.outcomes;
15223                }
15224                dropped
15225            }
15226            None => {
15227                dropped.and_then(|()| self.finish_observation("syncular_value_recovery", domains))
15228            }
15229        };
15230        if let Err(error) = result {
15231            if owns_observation {
15232                if owns_transaction {
15233                    if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
15234                        let mut failure = self.storage_failure.borrow_mut();
15235                        let failure =
15236                            failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
15237                        failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
15238                    }
15239                } else {
15240                    self.rollback_observation("syncular_value_recovery");
15241                }
15242            }
15243            self.outbox = prior_outbox;
15244            self.rejections = prior_rejections;
15245            self.failed_commits = prior_failed_commits;
15246            self.change_queue.truncate(prior_changes);
15247            self.sync_intent_queue.truncate(prior_intents);
15248            self.last_change = prior_last_change;
15249            self.overlay_dirty.restore(prior_overlay_dirty);
15250            return Err(error);
15251        }
15252        Ok(true)
15253    }
15254
15255    pub fn pending_commit_ids(&self) -> Vec<String> {
15256        self.outbox
15257            .iter()
15258            .map(|c| c.client_commit_id.clone())
15259            .collect()
15260    }
15261
15262    /// §6.1: the sparse push payload bytes of every pending upsert operation,
15263    /// FIFO — exactly what the next push round would put on the wire. Used by
15264    /// conformance B.21(h) to pin cross-core byte identity.
15265    pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
15266        let mut payloads = Vec::new();
15267        for commit in &self.outbox {
15268            for op in &commit.ops {
15269                let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
15270                    continue;
15271                };
15272                let Some(table) = self.schema.table(&op.table) else {
15273                    continue;
15274                };
15275                let fallback = self.stored_key_fallback(table, &op.row_id);
15276                if let Ok(payload) = encode_sparse_row_json(
15277                    table,
15278                    &op.row_id,
15279                    values,
15280                    &self.encryption,
15281                    fallback.as_ref(),
15282                ) {
15283                    payloads.push(payload);
15284                }
15285            }
15286        }
15287        payloads
15288    }
15289
15290    pub fn conflicts(&self) -> &[ConflictRecord] {
15291        &self.conflicts
15292    }
15293
15294    pub fn rejections(&self) -> &[RejectionRecord] {
15295        &self.rejections
15296    }
15297
15298    pub fn resolve_commit_outcome(
15299        &mut self,
15300        input: ResolveCommitOutcomeInput,
15301    ) -> Result<CommitOutcome, String> {
15302        let current = self
15303            .commit_outcome(&input.client_commit_id)?
15304            .ok_or_else(|| {
15305                format!(
15306                    "sync.outcome_not_found: no durable outcome exists for {:?}",
15307                    input.client_commit_id
15308                )
15309            })?;
15310        if current.resolution != CommitOutcomeResolution::Active {
15311            return Ok(current);
15312        }
15313        if input.resolution == CommitOutcomeResolution::Active {
15314            return Err("sync.invalid_request: resolution must leave active state".to_owned());
15315        }
15316        match input.resolution {
15317            CommitOutcomeResolution::Superseded => {
15318                let replacement = input
15319                    .replacement_client_commit_id
15320                    .as_deref()
15321                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
15322                    .ok_or_else(|| {
15323                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
15324                            .to_owned()
15325                    })?;
15326                let _ = replacement;
15327            }
15328            _ if input.replacement_client_commit_id.is_some() => {
15329                return Err(
15330                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
15331                        .to_owned(),
15332                );
15333            }
15334            _ => {}
15335        }
15336        let allowed = match current.status {
15337            CommitOutcomeStatus::Conflict => matches!(
15338                input.resolution,
15339                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
15340            ),
15341            CommitOutcomeStatus::Rejected => {
15342                matches!(
15343                    input.resolution,
15344                    CommitOutcomeResolution::Superseded
15345                        | CommitOutcomeResolution::ResolvedKeepServer
15346                )
15347            }
15348            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
15349                input.resolution == CommitOutcomeResolution::Dismissed
15350            }
15351        };
15352        if !allowed {
15353            return Err(format!(
15354                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
15355                input.resolution, current.status
15356            ));
15357        }
15358
15359        self.begin_observation("syncular_outcome_resolution")?;
15360        let previous_failed = self.failed_commits.clone();
15361        let previous_conflicts = self.conflicts.clone();
15362        let previous_rejections = self.rejections.clone();
15363        let result = (|| {
15364            self.conn
15365                .execute(
15366                    "UPDATE _syncular_commit_outcomes
15367                        SET resolution = ?1, resolved_at_ms = ?2,
15368                            replacement_client_commit_id = ?3
15369                      WHERE client_commit_id = ?4 AND resolution = 'active'",
15370                    rusqlite::params![
15371                        Self::outcome_resolution_name(input.resolution),
15372                        self.clock_now_ms(),
15373                        input.replacement_client_commit_id,
15374                        input.client_commit_id
15375                    ],
15376                )
15377                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15378            let resolved = self
15379                .commit_outcome(&current.client_commit_id)?
15380                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
15381            self.conn
15382                .execute(
15383                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
15384                    rusqlite::params![input.client_commit_id],
15385                )
15386                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15387            for (commit, _) in &self.failed_commits {
15388                if commit.client_commit_id == input.client_commit_id {
15389                    for operation in &commit.ops {
15390                        self.overlay_dirty.table(&operation.table);
15391                    }
15392                }
15393            }
15394            self.failed_commits
15395                .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
15396            self.prune_commit_outcomes()?;
15397            Ok(resolved)
15398        })();
15399        let resolved = match result {
15400            Ok(outcome) => outcome,
15401            Err(error) => {
15402                self.rollback_observation("syncular_outcome_resolution");
15403                self.failed_commits = previous_failed;
15404                self.conflicts = previous_conflicts;
15405                self.rejections = previous_rejections;
15406                return Err(error);
15407            }
15408        };
15409        self.conflicts
15410            .retain(|record| record.client_commit_id != current.client_commit_id);
15411        self.rejections
15412            .retain(|record| record.client_commit_id != current.client_commit_id);
15413        let mut batch = ChangeAccumulator {
15414            conflicts: current.status == CommitOutcomeStatus::Conflict,
15415            rejections: current.status == CommitOutcomeStatus::Rejected,
15416            outcomes: true,
15417            ..ChangeAccumulator::default()
15418        };
15419        if let Some(operations) = &current.operations {
15420            for operation in operations {
15421                batch.table(&operation.table);
15422            }
15423        }
15424        if let Err(error) = self
15425            .rebuild_overlay_if_dirty()
15426            .and_then(|()| self.finish_observation("syncular_outcome_resolution", batch))
15427        {
15428            self.rollback_observation("syncular_outcome_resolution");
15429            self.failed_commits = previous_failed;
15430            self.conflicts = previous_conflicts;
15431            self.rejections = previous_rejections;
15432            return Err(error);
15433        }
15434        Ok(resolved)
15435    }
15436
15437    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
15438        self.schema_floor.as_ref()
15439    }
15440
15441    /// §7.3.5: the client's opaque auth-lease state, if any.
15442    pub fn lease_state(&self) -> Option<&LeaseState> {
15443        self.lease_state.as_ref()
15444    }
15445
15446    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
15447    /// the two lease codes set it; other errors leave leaseState untouched.
15448    fn record_lease_error(&mut self, code: &str) {
15449        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
15450            return;
15451        }
15452        let mut next = self.lease_state.clone().unwrap_or_default();
15453        next.error_code = Some(code.to_owned());
15454        self.set_lease_state(Some(next));
15455    }
15456
15457    fn set_lease_state(&mut self, next: Option<LeaseState>) {
15458        if self.lease_state == next {
15459            return;
15460        }
15461        if self.begin_observation("syncular_lease").is_err() {
15462            return;
15463        }
15464        self.lease_state = next;
15465        if let Some(lease) = &self.lease_state {
15466            if let Ok(json) = serde_json::to_string(lease) {
15467                self.set_meta(LEASE_STATE_KEY, &json);
15468            }
15469        } else {
15470            self.delete_meta(LEASE_STATE_KEY);
15471        }
15472        let batch = ChangeAccumulator {
15473            status: true,
15474            ..ChangeAccumulator::default()
15475        };
15476        if self.finish_observation("syncular_lease", batch).is_err() {
15477            self.rollback_observation("syncular_lease");
15478        }
15479    }
15480
15481    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
15482        if self.schema_floor == next {
15483            return;
15484        }
15485        if self.begin_observation("syncular_schema_floor").is_err() {
15486            return;
15487        }
15488        self.schema_floor = next;
15489        self.stopped = self.schema_floor.is_some();
15490        if let Some(floor) = &self.schema_floor {
15491            if let Ok(json) = serde_json::to_string(floor) {
15492                self.set_meta(SCHEMA_FLOOR_KEY, &json);
15493            }
15494        } else {
15495            self.delete_meta(SCHEMA_FLOOR_KEY);
15496        }
15497        let batch = ChangeAccumulator {
15498            status: true,
15499            ..ChangeAccumulator::default()
15500        };
15501        if self
15502            .finish_observation("syncular_schema_floor", batch)
15503            .is_err()
15504        {
15505            self.rollback_observation("syncular_schema_floor");
15506        }
15507    }
15508
15509    fn set_upgrading(&mut self, value: bool) {
15510        if self.upgrading == value {
15511            return;
15512        }
15513        if self.begin_observation("syncular_upgrading").is_err() {
15514            return;
15515        }
15516        self.upgrading = value;
15517        let batch = ChangeAccumulator {
15518            status: true,
15519            ..ChangeAccumulator::default()
15520        };
15521        if self
15522            .finish_observation("syncular_upgrading", batch)
15523            .is_err()
15524        {
15525            self.rollback_observation("syncular_upgrading");
15526        }
15527    }
15528
15529    pub fn sync_needed(&self) -> bool {
15530        self.sync_needed
15531    }
15532
15533    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
15534        let sub = self.subs.iter().find(|s| s.id == id)?;
15535        Some(SubscriptionStateView {
15536            id: sub.id.clone(),
15537            table: sub.table.clone(),
15538            status: sub.state.name().to_owned(),
15539            cursor: sub.cursor,
15540            has_resume_token: sub.bootstrap_state.is_some(),
15541            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
15542            reason_code: sub.reason_code.clone(),
15543        })
15544    }
15545
15546    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
15547        let schema_table = self
15548            .schema
15549            .table(table)
15550            .ok_or_else(|| format!("unknown table {table:?}"))?;
15551        let sql = format!(
15552            "SELECT * FROM {} ORDER BY {} ASC",
15553            visible_table(table),
15554            quote_ident(&schema_table.primary_key)
15555        );
15556        let mut stmt = self
15557            .conn
15558            .prepare(&sql)
15559            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15560        let mut rows = stmt
15561            .query([])
15562            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15563        let mut out = Vec::new();
15564        while let Some(row) = rows
15565            .next()
15566            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
15567        {
15568            let mut values = Map::new();
15569            for (i, column) in schema_table.columns.iter().enumerate() {
15570                let value = row
15571                    .get_ref(i)
15572                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15573                values.insert(column.name.clone(), sql_ref_to_json(column, value));
15574            }
15575            let version: i64 = row
15576                .get(schema_table.columns.len())
15577                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15578            let row_id = match values.get(&schema_table.primary_key) {
15579                Some(Value::String(s)) => s.clone(),
15580                Some(Value::Number(n)) => n.to_string(),
15581                Some(Value::Bool(b)) => b.to_string(),
15582                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
15583            };
15584            out.push(RowState {
15585                row_id,
15586                version,
15587                values,
15588            });
15589        }
15590        Ok(out)
15591    }
15592
15593    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
15594    //
15595    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
15596    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
15597    // the op with `yrs`, re-encodes the whole doc state, and records a
15598    // baseVersion-less sparse upsert carrying only the primary key and the
15599    // crdt column (§5.10.3, §6.6). No local merge — merging is server-side;
15600    // the overlay re-materializes the edit immediately (optimistic apply,
15601    // §7.1) and the server-merged bytes arrive on the next pull, idempotently.
15602    // Byte-compatible with `@syncular/crdt-yjs`.
15603
15604    /// The current stored value of a `crdt` column for one row — the visible
15605    /// (optimistic) bytes, or `None` when the row is absent or the column is
15606    /// NULL (the empty document, §5.10.1). Errors if the column is not a
15607    /// `crdt` column (guards the app against a typo'd column name).
15608    #[cfg(feature = "crdt-yjs")]
15609    fn crdt_column_bytes(
15610        &self,
15611        table: &str,
15612        row_id: &str,
15613        column: &str,
15614    ) -> Result<Option<Vec<u8>>, String> {
15615        let schema_table = self
15616            .schema
15617            .table(table)
15618            .ok_or_else(|| format!("unknown table {table:?}"))?;
15619        let col = schema_table
15620            .columns
15621            .iter()
15622            .find(|c| c.name == column)
15623            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
15624        if col.ty != ColumnType::Crdt {
15625            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
15626        }
15627        let sql = format!(
15628            "SELECT {} FROM {} WHERE {}",
15629            quote_ident(column),
15630            visible_table(table),
15631            row_id_predicate(schema_table)
15632        );
15633        let bytes: Option<Vec<u8>> = self
15634            .conn
15635            .prepare_cached(&sql)
15636            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
15637            .query_row(rusqlite::params![row_id], |row| {
15638                row.get::<_, Option<Vec<u8>>>(0)
15639            })
15640            .map_err(|e| match e {
15641                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
15642                other => Self::sqlite_failure(&self.storage_failure, other),
15643            })?;
15644        Ok(bytes)
15645    }
15646
15647    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
15648    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
15649    /// An absent row / NULL column is the empty document (empty string).
15650    #[cfg(feature = "crdt-yjs")]
15651    pub fn crdt_text(
15652        &self,
15653        table: &str,
15654        row_id: &str,
15655        column: &str,
15656        name: &str,
15657    ) -> Result<String, String> {
15658        let bytes = self
15659            .crdt_column_bytes(table, row_id, column)?
15660            .unwrap_or_default();
15661        crate::crdt::text(&bytes, name)
15662    }
15663
15664    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and
15665    /// record the resulting crdt-only sparse operation. Returns the enqueued
15666    /// `clientCommitId`.
15667    #[cfg(feature = "crdt-yjs")]
15668    pub fn crdt_insert_text(
15669        &mut self,
15670        table: &str,
15671        row_id: &str,
15672        column: &str,
15673        name: &str,
15674        index: u32,
15675        value: &str,
15676    ) -> Result<String, ClientError> {
15677        let prior = self.storage_failure.borrow_mut().take();
15678        let outcome = (|| -> Result<String, String> {
15679            let current = self
15680                .crdt_column_bytes(table, row_id, column)?
15681                .unwrap_or_default();
15682            let update = crate::crdt::insert_text(&current, name, index, value)?;
15683            self.perform_crdt_push_update(table, row_id, column, &update)
15684        })();
15685        self.finish_authoring(prior, outcome)
15686    }
15687
15688    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and
15689    /// record the resulting crdt-only sparse operation. Returns the enqueued
15690    /// `clientCommitId`.
15691    #[cfg(feature = "crdt-yjs")]
15692    pub fn crdt_delete_text(
15693        &mut self,
15694        table: &str,
15695        row_id: &str,
15696        column: &str,
15697        name: &str,
15698        index: u32,
15699        len: u32,
15700    ) -> Result<String, ClientError> {
15701        let prior = self.storage_failure.borrow_mut().take();
15702        let outcome = (|| -> Result<String, String> {
15703            let current = self
15704                .crdt_column_bytes(table, row_id, column)?
15705                .unwrap_or_default();
15706            let update = crate::crdt::delete_text(&current, name, index, len)?;
15707            self.perform_crdt_push_update(table, row_id, column, &update)
15708        })();
15709        self.finish_authoring(prior, outcome)
15710    }
15711
15712    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
15713    /// `crdt` column's current state and record the resulting crdt-only sparse
15714    /// operation. The app authored the update with its own `yrs` model.
15715    /// Returns the enqueued `clientCommitId`.
15716    #[cfg(feature = "crdt-yjs")]
15717    pub fn crdt_apply_update(
15718        &mut self,
15719        table: &str,
15720        row_id: &str,
15721        column: &str,
15722        update: &[u8],
15723    ) -> Result<String, ClientError> {
15724        let prior = self.storage_failure.borrow_mut().take();
15725        let outcome = (|| -> Result<String, String> {
15726            let current = self
15727                .crdt_column_bytes(table, row_id, column)?
15728                .unwrap_or_default();
15729            let next = crate::crdt::apply_update(&current, update)?;
15730            self.perform_crdt_push_update(table, row_id, column, &next)
15731        })();
15732        self.finish_authoring(prior, outcome)
15733    }
15734
15735    /// Shared tail of the crdt edit methods: record a §6.1 sparse crdt-only
15736    /// upsert — the primary key plus the crdt column, no `baseVersion` — and
15737    /// enqueue it. The server merges the crdt column and leaves every other
15738    /// column untouched (§5.10.3), so a baseVersion-less crdt edit cannot
15739    /// clobber a concurrent edit to another column. A locally absent row
15740    /// records the same partial operation; the server answers per §6.2.
15741    #[cfg(feature = "crdt-yjs")]
15742    fn perform_crdt_push_update(
15743        &mut self,
15744        table: &str,
15745        row_id: &str,
15746        column: &str,
15747        crdt_bytes: &[u8],
15748    ) -> Result<String, String> {
15749        let mut bytes_obj = Map::new();
15750        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
15751        let mut partial = Map::new();
15752        partial.insert(column.to_owned(), Value::Object(bytes_obj));
15753        self.perform_patch(table, row_id, partial, None)
15754    }
15755
15756    /// Run an arbitrary read-only SQL query against the local database and
15757    /// return each row as a `column-name → JSON value` map. This is the seam
15758    /// the React `useSyncQuery` live-query API needs (it takes app-authored
15759    /// SQL over the visible tables/views, not a fixed table read like
15760    /// [`read_rows`]).
15761    ///
15762    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
15763    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
15764    /// same envelope the command surface uses everywhere else. Output BLOB
15765    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
15766    ///
15767    /// The result column typing is dynamic (SQLite's stored affinity), because
15768    /// arbitrary SQL can alias, join, and compute — there is no schema column
15769    /// to consult per output cell, unlike [`read_rows`].
15770    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
15771        query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
15772    }
15773
15774    /// Repository benchmark and conformance access, present only with `bench-internals`.
15775    #[cfg(feature = "bench-internals")]
15776    #[doc(hidden)]
15777    pub fn benchmark_connection(&mut self) -> &mut Connection {
15778        &mut self.conn
15779    }
15780
15781    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
15782    #[cfg(feature = "bench-internals")]
15783    #[doc(hidden)]
15784    pub fn benchmark_phases(
15785        &self,
15786        enabled: Option<bool>,
15787        reset: bool,
15788    ) -> Result<Option<Value>, String> {
15789        self.benchmark_phases.configure(enabled, reset)?;
15790        Ok(self.benchmark_phases.snapshot())
15791    }
15792
15793    /// One serialized, read-only authority observation, including durable coverage.
15794    pub fn authority_snapshot(&self) -> Result<Value, String> {
15795        validate_authority_reads(&self.schema, &self.limits.authority_reads)?;
15796        if self.limits.authority_reads.is_empty() {
15797            return Err("client.authority_read_forbidden: no authority reads were declared at client creation".into());
15798        }
15799        self.conn
15800            .execute_batch("SAVEPOINT authority_snapshot")
15801            .map_err(|e| e.to_string())?;
15802        let result = (|| {
15803            let revision = self
15804                .get_meta(LOCAL_REVISION_KEY)
15805                .ok_or("sync.local_corrupt: authority revision is missing")?;
15806            let parsed_revision = revision
15807                .parse::<u64>()
15808                .map_err(|_| "sync.local_corrupt: authority revision is invalid")?;
15809            if parsed_revision.to_string() != revision {
15810                return Err("sync.local_corrupt: authority revision is invalid".into());
15811            }
15812            let mut tables = Vec::new();
15813            for read in &self.limits.authority_reads {
15814                let table = self
15815                    .schema
15816                    .table(&read.table)
15817                    .ok_or("client.authority_read_forbidden: authority table is not declared")?;
15818                let scalar = |column: &str, value: &Value| -> Result<String, String> {
15819                    let ty = table
15820                        .columns
15821                        .iter()
15822                        .find(|c| c.name == column)
15823                        .expect("validated authority column")
15824                        .ty;
15825                    if ty == ColumnType::Boolean {
15826                        if let Some(value) = value.as_bool() {
15827                            return Ok(value.to_string());
15828                        }
15829                        return match value.as_i64() {
15830                            Some(0) => Ok("false".into()),
15831                            Some(1) => Ok("true".into()),
15832                            _ => Err("sync.local_corrupt: authority boolean is invalid".into()),
15833                        };
15834                    }
15835                    match value {
15836                        Value::String(s) => Ok(s.clone()),
15837                        Value::Number(n) => Ok(n.to_string()),
15838                        Value::Object(o) if ty == ColumnType::Integer && o.len() == 1 => o
15839                            .get("$bigint")
15840                            .and_then(Value::as_str)
15841                            .map(str::to_owned)
15842                            .ok_or("sync.local_corrupt: authority integer is invalid".into()),
15843                        _ => {
15844                            Err("sync.local_corrupt: authority scalar is missing or invalid".into())
15845                        }
15846                    }
15847                };
15848                let matches = |row: &QueryRow| -> Result<bool, String> {
15849                    for (variable, values) in &read.scopes {
15850                        let column = table
15851                            .scope_column(variable)
15852                            .expect("validated authority scope");
15853                        let value = row
15854                            .get(column)
15855                            .ok_or("sync.local_corrupt: authority scope column is missing")?;
15856                        if value.is_null() || !values.contains(&scalar(column, value)?) {
15857                            return Ok(false);
15858                        }
15859                    }
15860                    Ok(true)
15861                };
15862                let selected = read
15863                    .columns
15864                    .iter()
15865                    .map(|c| quote_ident(c))
15866                    .collect::<Vec<_>>()
15867                    .join(",");
15868                let mut intent = BTreeSet::new();
15869                let mut relevant = BTreeSet::new();
15870                for row in query_connection(&self.conn,
15871                    "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=?",
15872                    &[Value::String(read.table.clone())]).map_err(|e| e.to_string())? {
15873                    let id = row.get("id").and_then(Value::as_str).ok_or("sync.local_corrupt: authority intent id is invalid")?.to_owned();
15874                    intent.insert(id.clone());
15875                    if let Some(raw) = row.get("values_json").and_then(Value::as_str) {
15876                        let values: QueryRow = serde_json::from_str(raw).map_err(|_| "sync.local_corrupt: authority intent values are invalid")?;
15877                        if read.scopes.keys().all(|v| values.contains_key(table.scope_column(v).expect("validated scope"))) && matches(&values)? {relevant.insert(id);}
15878                    }
15879                }
15880                let mut rows = BTreeMap::new();
15881                for full in [base_table(&read.table), visible_table(&read.table)] {
15882                    for mut values in query_connection(
15883                        &self.conn,
15884                        &format!(
15885                            "SELECT {selected},_syncular_version AS authority_version FROM {full}"
15886                        ),
15887                        &[],
15888                    )
15889                    .map_err(|e| e.to_string())?
15890                    {
15891                        if !matches(&values)? {
15892                            continue;
15893                        }
15894                        let id = scalar(
15895                            &table.primary_key,
15896                            values
15897                                .get(&table.primary_key)
15898                                .expect("selected primary key"),
15899                        )?;
15900                        relevant.insert(id.clone());
15901                        if full == base_table(&read.table) {
15902                            let version = values
15903                                .remove("authority_version")
15904                                .expect("selected version");
15905                            let version = version
15906                                .as_i64()
15907                                .ok_or("sync.local_corrupt: authority server version is invalid")?;
15908                            if version >= 0 {
15909                                rows.insert(id.clone(), json!({"values": values, "version": version, "hasLocalIntent": intent.contains(&id)}));
15910                            }
15911                        }
15912                    }
15913                }
15914                intent.retain(|id| relevant.contains(id));
15915                let rows = rows.into_values().collect::<Vec<_>>();
15916                let mut persisted = Vec::new();
15917                for row in query_connection(
15918                    &self.conn,
15919                    "SELECT state_json FROM _syncular_subscriptions WHERE tbl=? ORDER BY rowid",
15920                    &[Value::String(read.table.clone())],
15921                )
15922                .map_err(|e| e.message)?
15923                {
15924                    let state: Value = serde_json::from_str(
15925                        row.get("state_json")
15926                            .and_then(Value::as_str)
15927                            .ok_or("sync.local_corrupt: authority subscription state is missing")?,
15928                    )
15929                    .map_err(|_| "sync.local_corrupt: authority subscription state is invalid")?;
15930                    let effective = state.get("effectiveScopes").and_then(Value::as_object);
15931                    let status = state
15932                        .get("status")
15933                        .and_then(Value::as_str)
15934                        .ok_or("sync.local_corrupt: authority subscription status is missing")?;
15935                    let cursor = state
15936                        .get("cursor")
15937                        .and_then(Value::as_i64)
15938                        .ok_or("sync.local_corrupt: authority subscription cursor is missing")?;
15939                    let complete = effective.is_some_and(|s| s.len() == read.scopes.len())
15940                        && status == "active"
15941                        && cursor >= 0
15942                        && state.get("bootstrapState").is_none_or(Value::is_null)
15943                        && state.get("params").is_none_or(Value::is_null);
15944                    let exposed = effective.map(|s| {
15945                        read.scopes
15946                            .keys()
15947                            .map(|v| (v.clone(), s.get(v).cloned().unwrap_or(json!([]))))
15948                            .collect::<Map<_, _>>()
15949                    });
15950                    let requested = read
15951                        .scopes
15952                        .keys()
15953                        .map(|v| {
15954                            (
15955                                v.clone(),
15956                                state
15957                                    .get("requested")
15958                                    .and_then(|s| s.get(v))
15959                                    .cloned()
15960                                    .unwrap_or(json!([])),
15961                            )
15962                        })
15963                        .collect::<Map<_, _>>();
15964                    persisted.push(json!({"requestedScopes": requested, "status": status, "cursor": cursor, "effectiveScopes": exposed, "complete": complete}));
15965                }
15966                fn covered(selectors: &[(&String, &Vec<String>)], candidates: &[&Value]) -> bool {
15967                    match selectors.split_first() {
15968                        None => !candidates.is_empty(),
15969                        Some(((variable, values), rest)) => values.iter().all(|value| {
15970                            covered(
15971                                rest,
15972                                &candidates
15973                                    .iter()
15974                                    .copied()
15975                                    .filter(|p| {
15976                                        p["effectiveScopes"][variable.as_str()]
15977                                            .as_array()
15978                                            .is_some_and(|held| {
15979                                                held.contains(&Value::String(value.clone()))
15980                                                    || held.contains(&Value::String("*".into()))
15981                                            })
15982                                    })
15983                                    .collect::<Vec<_>>(),
15984                            )
15985                        }),
15986                    }
15987                }
15988                let coverage = if covered(
15989                    &read.scopes.iter().collect::<Vec<_>>(),
15990                    &persisted
15991                        .iter()
15992                        .filter(|p| p["complete"] == true)
15993                        .collect::<Vec<_>>(),
15994                ) {
15995                    "complete"
15996                } else if persisted.iter().any(|p| {
15997                    p["status"] == "active"
15998                        && read.scopes.iter().all(|(v, values)| {
15999                            values.iter().any(|value| {
16000                                p["requestedScopes"][v].as_array().is_some_and(|held| {
16001                                    held.contains(&Value::String(value.clone()))
16002                                        || held.contains(&Value::String("*".into()))
16003                                })
16004                            })
16005                        })
16006                }) {
16007                    "pending"
16008                } else {
16009                    "missing"
16010                };
16011                tables.push(json!({"table": read.table, "rows": rows, "localIntentRowIds": intent, "scopes": read.scopes, "coverage": coverage, "persisted": persisted}));
16012            }
16013            Ok(
16014                json!({"revision": revision, "complete": tables.iter().all(|t| t["coverage"] == "complete"), "tables": tables}),
16015            )
16016        })();
16017        match result {
16018            Ok(snapshot) => {
16019                self.conn
16020                    .execute_batch("RELEASE authority_snapshot")
16021                    .map_err(|e| e.to_string())?;
16022                Ok(snapshot)
16023            }
16024            Err(error) => {
16025                self.conn
16026                    .execute_batch("ROLLBACK TO authority_snapshot; RELEASE authority_snapshot")
16027                    .map_err(|e| format!("{error}; rollback failed: {e}"))?;
16028                Err(error)
16029            }
16030        }
16031    }
16032
16033    /// Rows, coverage, and local revision from one SQLite read snapshot.
16034    /// §7.5 atomic snapshot read. An `owner` records a failure in
16035    /// `diagnostics_snapshot().query_failures` until its next successful read.
16036    pub fn query_snapshot(
16037        &mut self,
16038        sql: &str,
16039        params: &[QueryValue],
16040        coverage: &[WindowCoverage],
16041        owner: Option<QueryOwner<'_>>,
16042    ) -> Result<QuerySnapshot, String> {
16043        let result = snapshot_connection(&self.conn, sql, params, coverage);
16044        if let Some(owner) = owner {
16045            self.record_query_read(owner, result.as_ref().err());
16046        }
16047        result.map_err(|failure| failure.to_string())
16048    }
16049
16050    /// §7.6: record the outcome of one owned snapshot read, including reads a
16051    /// host ran on a separate connection. A success removes the owner's entry;
16052    /// an identical failure leaves it unchanged; a changed failure moves last.
16053    pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
16054        let position = self
16055            .query_failures
16056            .iter()
16057            .position(|entry| entry.id == owner.id);
16058        let Some(failure) = failure else {
16059            if let Some(index) = position {
16060                self.query_failures.remove(index);
16061            }
16062            return;
16063        };
16064        let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
16065            .into_iter()
16066            .map(str::to_owned)
16067            .collect();
16068        let code = failure.code.unwrap_or("client.query_failed").to_owned();
16069        if position.is_some_and(|index| {
16070            let entry = &self.query_failures[index];
16071            entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
16072        }) {
16073            return;
16074        }
16075        if let Some(index) = position {
16076            self.query_failures.remove(index);
16077        }
16078        self.query_failures.push(DiagnosticQueryFailure {
16079            id: owner.id.to_owned(),
16080            tables,
16081            code,
16082            sqlite_code: failure.sqlite_code,
16083            at_ms: self.clock_now_ms(),
16084        });
16085        if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
16086            self.query_failures.remove(0);
16087        }
16088    }
16089
16090    // -- request building ---------------------------------------------------------
16091
16092    fn build_request(&self, url_capable: bool) -> Result<(Message, RequestMeta), Box<SyncOutcome>> {
16093        #[cfg(feature = "bench-internals")]
16094        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
16095        let log_epoch = self.get_meta(LOG_EPOCH_KEY);
16096        let max_commits = self.limits.max_push_commits_per_request;
16097        let max_ops = self
16098            .limits
16099            .max_push_operations_per_request
16100            .unwrap_or(PUSH_OPS_PER_REQUEST);
16101        let byte_cap = self.limits.max_push_request_bytes;
16102        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
16103        // bare URL — capability negotiation, decided per transport.
16104        let accept = self.limits.accept.unwrap_or(if url_capable {
16105            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
16106        } else {
16107            DEFAULT_ACCEPT
16108        });
16109        let header = Frame::ReqHeader {
16110            client_id: self.client_id.clone(),
16111            schema_version: self.schema.version,
16112            log_epoch: log_epoch.clone(),
16113        };
16114        // §1.5: the lead frames are REQ_HEADER plus the optional ROUND_FAILURE,
16115        // which stays ahead of every push, pull and subscription frame; the
16116        // byte-capacity probes below measure this exact sequence.
16117        let mut lead = vec![header];
16118        lead.extend(
16119            self.round_failure
16120                .iter()
16121                .map(|code| Frame::RoundFailure { code: code.clone() }),
16122        );
16123        let pull = Frame::PullHeader {
16124            limit_commits: self.limits.limit_commits.unwrap_or(0),
16125            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
16126            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
16127            accept,
16128        };
16129        let mut fresh = Vec::new();
16130        let mut sub_frames = Vec::new();
16131        for sub in &self.subs {
16132            if sub.state != SubState::Active {
16133                continue;
16134            }
16135            let mut scopes = sub.requested.clone();
16136            sort_scope_map(&mut scopes);
16137            sub_frames.push(Frame::Subscription {
16138                id: sub.id.clone(),
16139                table: sub.table.clone(),
16140                scopes,
16141                params: sub.params.clone().map(RawJson),
16142                cursor: sub.cursor,
16143                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
16144            });
16145            fresh.push((
16146                sub.id.clone(),
16147                sub.cursor < 0 && sub.bootstrap_state.is_none(),
16148            ));
16149        }
16150        // The fixed frames (order-independent size) and the header+pull probe
16151        // both encode canonically, so the byte budget is the exact wire size.
16152        let fixed_bytes = match byte_cap {
16153            Some(_) => {
16154                let mut frames = lead.clone();
16155                frames.push(pull.clone());
16156                frames.extend(sub_frames.iter().cloned());
16157                encode_message(&Message {
16158                    wire_version: WIRE_VERSION,
16159                    msg_kind: MsgKind::Request,
16160                    frames,
16161                })
16162                .len()
16163            }
16164            None => 0,
16165        };
16166        if let Some(cap) = byte_cap {
16167            if fixed_bytes > cap {
16168                return Err(Self::push_capacity_outcome("bytes", cap, fixed_bytes, None));
16169            }
16170        }
16171        let base_bytes = match byte_cap {
16172            Some(_) => {
16173                let mut frames = lead.clone();
16174                frames.push(pull.clone());
16175                encode_message(&Message {
16176                    wire_version: WIRE_VERSION,
16177                    msg_kind: MsgKind::Request,
16178                    frames,
16179                })
16180                .len()
16181            }
16182            None => 0,
16183        };
16184        let total_outbox = if log_epoch.is_some() {
16185            self.outbox.len()
16186        } else {
16187            0
16188        };
16189        let mut push_frames: Vec<Frame> = Vec::new();
16190        let mut pushed_ids = Vec::new();
16191        let mut ops_in_request = 0usize;
16192        let mut bytes_in_request = 0usize;
16193        let mut deferred_commits = 0usize;
16194        #[cfg(feature = "bench-internals")]
16195        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
16196        for (index, commit) in self.outbox.iter().take(total_outbox).enumerate() {
16197            // §6.1/§7.1: the commit-count and operation caps are hard. A
16198            // first commit over the operation cap is a typed capacity error
16199            // and stays queued; a later one defers with the complete suffix.
16200            if max_commits.is_some_and(|cap| push_frames.len() >= cap) {
16201                deferred_commits = total_outbox - index;
16202                break;
16203            }
16204            if ops_in_request + commit.ops.len() > max_ops {
16205                if push_frames.is_empty() {
16206                    return Err(Self::push_capacity_outcome(
16207                        "operations",
16208                        max_ops,
16209                        commit.ops.len(),
16210                        Some(commit.client_commit_id.as_str()),
16211                    ));
16212                }
16213                deferred_commits = total_outbox - index;
16214                break;
16215            }
16216            let operations = commit
16217                .ops
16218                .iter()
16219                .map(|op| {
16220                    let payload = op.values.as_ref().and_then(|values| {
16221                        let table = self.schema.table(&op.table)?;
16222                        // §0: outbox entries encode at send time with the
16223                        // current codec (validated at mutate()). §6.1: the
16224                        // values map's keys are the sparse presence set.
16225                        // §5.11: encrypted columns are encrypted here, with
16226                        // an absent key-id selector read from the stored row.
16227                        // `sync_inner` runs `drop_unencodable_outbox` first,
16228                        // so an encrypt failure cannot reach this encode; the
16229                        // author seam validated every other failure.
16230                        let fallback = self.stored_key_fallback(table, &op.row_id);
16231                        encode_sparse_row_json(
16232                            table,
16233                            &op.row_id,
16234                            values,
16235                            &self.encryption,
16236                            fallback.as_ref(),
16237                        )
16238                        .ok()
16239                    });
16240                    ssp2::model::Operation {
16241                        table: op.table.clone(),
16242                        row_id: op.row_id.clone(),
16243                        op: if op.upsert { Op::Upsert } else { Op::Delete },
16244                        base_version: op.base_version,
16245                        payload,
16246                    }
16247                })
16248                .collect();
16249            let frame = Frame::PushCommit {
16250                client_commit_id: commit.client_commit_id.clone(),
16251                operations,
16252            };
16253            let frame_bytes = match byte_cap {
16254                Some(_) => {
16255                    let mut frames = lead.clone();
16256                    frames.push(frame.clone());
16257                    frames.push(pull.clone());
16258                    encode_message(&Message {
16259                        wire_version: WIRE_VERSION,
16260                        msg_kind: MsgKind::Request,
16261                        frames,
16262                    })
16263                    .len()
16264                        - base_bytes
16265                }
16266                None => 0,
16267            };
16268            // The head is always encoded so the caller can report the full
16269            // projected request size; a later commit defers once it no longer
16270            // fits.
16271            if let Some(cap) = byte_cap {
16272                if !push_frames.is_empty() && fixed_bytes + bytes_in_request + frame_bytes > cap {
16273                    deferred_commits = total_outbox - index;
16274                    break;
16275                }
16276            }
16277            push_frames.push(frame);
16278            pushed_ids.push(commit.client_commit_id.clone());
16279            ops_in_request += commit.ops.len();
16280            bytes_in_request += frame_bytes;
16281        }
16282        #[cfg(feature = "bench-internals")]
16283        drop(outbox_phase);
16284        if let Some(cap) = byte_cap {
16285            if fixed_bytes + bytes_in_request > cap {
16286                // The head alone does not fit this budget; the commit stays
16287                // queued and no transport round is attempted.
16288                let blocked = if push_frames.is_empty() {
16289                    None
16290                } else {
16291                    self.outbox.first().map(|c| c.client_commit_id.as_str())
16292                };
16293                return Err(Self::push_capacity_outcome(
16294                    "bytes",
16295                    cap,
16296                    fixed_bytes + bytes_in_request,
16297                    blocked,
16298                ));
16299            }
16300        }
16301        let mut frames = lead;
16302        frames.extend(push_frames);
16303        frames.push(pull);
16304        frames.extend(sub_frames);
16305        let message = Message {
16306            wire_version: WIRE_VERSION,
16307            msg_kind: MsgKind::Request,
16308            frames,
16309        };
16310        Ok((
16311            message,
16312            RequestMeta {
16313                pushed_ids,
16314                fresh,
16315                accept,
16316                deferred_commits,
16317            },
16318        ))
16319    }
16320
16321    /// §7.1: a configured push capacity limit cannot carry this request.
16322    fn push_capacity_outcome(
16323        kind: &str,
16324        limit: usize,
16325        size: usize,
16326        client_commit_id: Option<&str>,
16327    ) -> Box<SyncOutcome> {
16328        let mut details = Map::new();
16329        details.insert("kind".to_owned(), Value::from(kind));
16330        details.insert("limit".to_owned(), Value::from(limit as u64));
16331        details.insert("size".to_owned(), Value::from(size as u64));
16332        if let Some(id) = client_commit_id {
16333            details.insert("clientCommitId".to_owned(), Value::from(id));
16334        }
16335        Box::new(SyncOutcome::Failed {
16336            error_code: "client.push_request_too_large".into(),
16337            message: "the push request exceeds a configured capacity limit".into(),
16338            details: Some(Value::Object(details)),
16339        })
16340    }
16341
16342    // -- sync -------------------------------------------------------------------
16343
16344    /// Cloneable observation handle; no client lock is needed to read it.
16345    pub fn progress(&self) -> ProgressObserver {
16346        self.progress.clone()
16347    }
16348
16349    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
16350        let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
16351            Ok(prepared) => prepared,
16352            Err(outcome) => return *outcome,
16353        };
16354        // The round's network deadline rides `PreparedSyncRound` and is scoped
16355        // to each exchange, so uploads, continuations, the main request, and
16356        // every segment fetch share one monotonic anchor without leaving
16357        // deadline state on a reused transport.
16358        let mut next = prepared;
16359        loop {
16360            match self.apply_sync_round(next.exchange(transport)) {
16361                crate::AppliedSyncRound::Continue(prepared) => next = prepared,
16362                crate::AppliedSyncRound::Complete {
16363                    outcome, controls, ..
16364                } => {
16365                    if self.transport_enabled {
16366                        for text in controls {
16367                            let _ = transport.realtime_send(&text);
16368                        }
16369                    } else {
16370                        self.disconnect_realtime(transport);
16371                    }
16372                    return outcome;
16373                }
16374            }
16375        }
16376    }
16377
16378    fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
16379        if let SyncOutcome::Failed {
16380            error_code,
16381            message,
16382            details,
16383        } = &mut outcome
16384        {
16385            if let Some(failure) = self.storage_failure.borrow().as_ref() {
16386                let code = failure
16387                    .code
16388                    .expect("retained storage failures are classified");
16389                *error_code = code.to_owned();
16390                *message = match code {
16391                    "client.storage_busy" => "local SQLite storage is busy",
16392                    "client.storage_full" => "local SQLite storage is full",
16393                    "client.storage_io" => "local SQLite storage I/O failed",
16394                    "client.storage_corrupt" => "local SQLite storage is corrupt",
16395                    _ => unreachable!("unrecognized classified storage failure"),
16396                }
16397                .to_owned();
16398                *details = failure.details();
16399            }
16400        }
16401        let retry_delay_ms = self.round_retry_delay_ms;
16402        self.progress.update(|p| match &outcome {
16403            SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report)
16404                if report.failed.is_empty() =>
16405            {
16406                p.state = ProgressState::Complete
16407            }
16408            SyncOutcome::Ok(_) | SyncOutcome::BudgetExhausted(_) => {
16409                p.state = ProgressState::Failed;
16410                p.error_code = Some("sync.scope_revoked".into());
16411            }
16412            SyncOutcome::Failed { error_code, .. } => {
16413                p.state = ProgressState::Failed;
16414                p.error_code = Some(Self::diagnostic_code(error_code));
16415                p.retry_delay_ms = retry_delay_ms;
16416            }
16417            SyncOutcome::RealtimeUnavailable { .. } => {
16418                p.state = ProgressState::Failed;
16419                p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
16420                p.retry_delay_ms = retry_delay_ms;
16421            }
16422        });
16423        let completed_at_ms = self.clock_now_ms();
16424        self.last_round = Some(match &outcome {
16425            SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report) => DiagnosticLastRound {
16426                status: "succeeded".to_owned(),
16427                started_at_ms,
16428                completed_at_ms,
16429                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
16430                counters: Some(DiagnosticRoundCounters {
16431                    pushed: report.pushed,
16432                    applied: report.applied.len(),
16433                    rejected: report.rejected.len(),
16434                    retryable: report.retryable.len(),
16435                    conflicts: report.conflicts,
16436                    commits_applied: report.commits_applied,
16437                    segment_rows_applied: report.segment_rows_applied,
16438                    bootstrapping: report.bootstrapping.len(),
16439                    resets: report.resets.len(),
16440                    revoked: report.revoked.len(),
16441                    failed: report.failed.len(),
16442                    deferred_commits: report.deferred_commits,
16443                }),
16444                error_code: None,
16445            },
16446            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
16447                status: "failed".to_owned(),
16448                started_at_ms,
16449                completed_at_ms,
16450                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
16451                counters: None,
16452                error_code: Some(Self::diagnostic_code(error_code)),
16453            },
16454            SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
16455                status: "failed".to_owned(),
16456                started_at_ms,
16457                completed_at_ms,
16458                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
16459                counters: None,
16460                error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
16461            },
16462        });
16463        outcome
16464    }
16465
16466    /// Capture one request on the client owner. Mutations made after this
16467    /// call remain in the outbox for a later round; no network I/O occurs here.
16468    pub fn prepare_sync_round(
16469        &mut self,
16470        url_capable: bool,
16471    ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
16472        if !self.transport_enabled {
16473            return Err(Box::new(SyncOutcome::Failed {
16474                error_code: "sync.offline".into(),
16475                message: "transport gate is closed".into(),
16476                details: None,
16477            }));
16478        }
16479        if self.active_round.is_some() {
16480            return Err(Box::new(SyncOutcome::Failed {
16481                error_code: "client.round_in_progress".into(),
16482                message: "a sync round is already in flight".into(),
16483                details: None,
16484            }));
16485        }
16486        self.storage_failure.borrow_mut().take();
16487        self.progress.start();
16488        self.round_retry_delay_ms = None;
16489        let started_at_ms = self.clock_now_ms();
16490        let prepared = (|| {
16491            if self.stopped {
16492                return Err(Box::new(SyncOutcome::Ok(SyncReport {
16493                    schema_floor: self.schema_floor.clone(),
16494                    ..SyncReport::default()
16495                })));
16496            }
16497            if self.security_preflight {
16498                return Err(Box::new(SyncOutcome::Failed {
16499                    error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
16500                    message: "security preflight must complete before syncing".into(),
16501                    details: None,
16502                }));
16503            }
16504            self.set_sync_needed(false, false);
16505            self.drain_pending_evictions()
16506                .and_then(|_| self.drop_incompatible_outbox())
16507                .and_then(|_| self.drop_unencodable_outbox(true, None))
16508                .map_err(|message| {
16509                    Box::new(SyncOutcome::Failed {
16510                        error_code: "storage.failed".into(),
16511                        message,
16512                        details: None,
16513                    })
16514                })?;
16515            let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
16516                self.pending_blob_uploads().map_err(|e| {
16517                    Box::new(SyncOutcome::Failed {
16518                        error_code: e.code,
16519                        message: e.message,
16520                        details: e.details,
16521                    })
16522                })?
16523            } else {
16524                Vec::new()
16525            };
16526            if self.realtime_state != RealtimeState::Connected
16527                && self.realtime_policy == RealtimePolicy::Required
16528            {
16529                let retry_delay_ms = self.schedule_background_retry();
16530                self.realtime_retry_delay_ms = Some(retry_delay_ms);
16531                return Err(Box::new(SyncOutcome::RealtimeUnavailable {
16532                    state: self.realtime_state,
16533                    reason_code: self.realtime_reason_code.clone(),
16534                    retry_delay_ms,
16535                }));
16536            }
16537            let (message, meta) = self.build_request(url_capable)?;
16538            let id = uuid::Uuid::new_v4();
16539            self.active_round = Some(id);
16540            Ok(crate::PreparedSyncRound {
16541                id,
16542                started_at_ms,
16543                message,
16544                meta,
16545                uploads: uploads.into(),
16546                realtime: self.realtime_state == RealtimeState::Connected,
16547                scopes: self
16548                    .subs
16549                    .iter()
16550                    .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
16551                    .collect(),
16552                #[cfg(feature = "bench-internals")]
16553                benchmark_phases: self.benchmark_phases.clone(),
16554                progress: self.progress.clone(),
16555                fixed_now: self.now_ms,
16556                round_deadline_at: None,
16557            })
16558        })();
16559        prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
16560    }
16561
16562    /// Discard the reply of an old authorization/subscription context. Its
16563    /// submitted commits stay durable and can be retried with the same ids.
16564    pub fn cancel_sync_round(&mut self) {
16565        self.active_round = None;
16566    }
16567
16568    /// Apply captured network results on the owner, preserving all normal
16569    /// version, schema, content-address and cursor checks. No network I/O.
16570    pub fn apply_sync_round(
16571        &mut self,
16572        completed: crate::CompletedSyncRound,
16573    ) -> crate::AppliedSyncRound {
16574        let prepared = completed.prepared;
16575        if self.active_round != Some(prepared.id) || self.security_preflight {
16576            return crate::AppliedSyncRound::Complete {
16577                outcome: SyncOutcome::Failed {
16578                    error_code: "client.round_cancelled".into(),
16579                    message: "sync round belongs to a released client context".into(),
16580                    details: None,
16581                },
16582                controls: Vec::new(),
16583                more: false,
16584                bootstrap_advanced: false,
16585            };
16586        }
16587        self.storage_failure.borrow_mut().take();
16588        let (response, mut downloads, transport_failed) = match completed.exchange {
16589            crate::round::ExchangeResult::Upload { id, result } => {
16590                let result = result.and_then(|()| {
16591                    self.conn
16592                        .execute(
16593                            "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
16594                            rusqlite::params![id],
16595                        )
16596                        .map(|_| ())
16597                        .map_err(|error| {
16598                            TransportError::new(
16599                                "client.failed",
16600                                Self::sqlite_failure(&self.storage_failure, error),
16601                            )
16602                        })
16603                });
16604                match result {
16605                    Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
16606                    Err(TransportError {
16607                        code,
16608                        message,
16609                        details,
16610                    }) => {
16611                        self.active_round = None;
16612                        if Self::retryable_failure_code(&code) {
16613                            self.schedule_background_retry();
16614                        }
16615                        let outcome = self.finish_sync_round(
16616                            prepared.started_at_ms,
16617                            SyncOutcome::Failed {
16618                                error_code: code,
16619                                message,
16620                                details,
16621                            },
16622                        );
16623                        return crate::AppliedSyncRound::Complete {
16624                            outcome,
16625                            controls: Vec::new(),
16626                            more: false,
16627                            bootstrap_advanced: false,
16628                        };
16629                    }
16630                }
16631            }
16632            crate::round::ExchangeResult::Reply {
16633                response,
16634                downloads,
16635                transport_failed,
16636            } => (response, downloads, transport_failed),
16637        };
16638        self.active_round = None;
16639        let before = self
16640            .subs
16641            .iter()
16642            .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
16643            .collect::<Vec<_>>();
16644        let outcome = (|| {
16645            let response = match response {
16646                Ok(response) => {
16647                    // §1.5: a complete, decodable response ends the report, an
16648                    // in-band ERROR included (the server already recorded it).
16649                    self.round_failure = None;
16650                    response
16651                }
16652                Err(TransportError {
16653                    code,
16654                    message,
16655                    details,
16656                }) => {
16657                    // §1.5: the server cannot see a round whose transport
16658                    // failed or whose response never decoded, so its code rides
16659                    // the following requests. A code the frame grammar rejects
16660                    // is not reportable, and the previous code stays.
16661                    if ssp2::model::valid_round_failure_code(&code) {
16662                        self.round_failure = Some(code.clone());
16663                    }
16664                    if transport_failed {
16665                        if prepared.realtime {
16666                            self.set_realtime_state(RealtimeState::Lost, Some(&code));
16667                        }
16668                        self.record_lease_error(&code);
16669                    }
16670                    if Self::retryable_failure_code(&code) {
16671                        let delay = self.schedule_background_retry();
16672                        if prepared.realtime && transport_failed {
16673                            self.realtime_retry_delay_ms = Some(delay);
16674                        }
16675                    }
16676                    return SyncOutcome::Failed {
16677                        error_code: code,
16678                        message,
16679                        details,
16680                    };
16681                }
16682            };
16683            let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
16684            if let SyncOutcome::Ok(report) = &mut outcome {
16685                report.deferred_commits = prepared.meta.deferred_commits;
16686            }
16687            match &outcome {
16688                SyncOutcome::Ok(_) => self.reset_background_retry(),
16689                SyncOutcome::Failed { error_code, .. }
16690                    if Self::retryable_failure_code(error_code) =>
16691                {
16692                    self.schedule_background_retry();
16693                }
16694                _ => {}
16695            }
16696            if prepared.meta.deferred_commits > 0 {
16697                self.set_sync_needed(true, true);
16698            }
16699            self.previous_version_lifetime_check();
16700            outcome
16701        })();
16702        let more = self.transport_enabled
16703            && matches!(&outcome, SyncOutcome::Ok(report)
16704                if report.schema_floor.is_none()
16705                    && (!report.bootstrapping.is_empty()
16706                        || report.commits_applied > 0
16707                        || report.segment_rows_applied > 0
16708                        || !report.resets.is_empty()
16709                        || report.deferred_commits > 0
16710                        || self.sync_needed));
16711        let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
16712            && before
16713                != self
16714                    .subs
16715                    .iter()
16716                    .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
16717                    .collect::<Vec<_>>();
16718        crate::AppliedSyncRound::Complete {
16719            outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
16720            controls: if self.transport_enabled {
16721                downloads.controls
16722            } else {
16723                Vec::new()
16724            },
16725            more,
16726            bootstrap_advanced,
16727        }
16728    }
16729
16730    pub fn sync_until_idle(
16731        &mut self,
16732        transport: &mut dyn Transport,
16733        max_rounds: Option<u32>,
16734    ) -> SyncOutcome {
16735        // §7.7: an explicit budget is a strict positive round count. Zero is a
16736        // caller error, not an exhaustion.
16737        if max_rounds == Some(0) {
16738            return SyncOutcome::Failed {
16739                details: None,
16740                error_code: "sync.invalid_request".into(),
16741                message: "maxRounds must be a positive integer".into(),
16742            };
16743        }
16744        let rounds = max_rounds.unwrap_or(20);
16745        let mut aggregate = SyncReport::default();
16746        let mut spent = 0;
16747        while spent < rounds {
16748            spent += 1;
16749            let before = self
16750                .subs
16751                .iter()
16752                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
16753                .collect::<Vec<_>>();
16754            match self.sync(transport) {
16755                SyncOutcome::Failed {
16756                    error_code,
16757                    message,
16758                    details,
16759                } => {
16760                    return SyncOutcome::Failed {
16761                        details,
16762                        error_code,
16763                        message,
16764                    };
16765                }
16766                outcome @ SyncOutcome::RealtimeUnavailable { .. } => return outcome,
16767                SyncOutcome::BudgetExhausted(_) => {
16768                    unreachable!("sync() never exhausts a round budget")
16769                }
16770                SyncOutcome::Ok(report) => {
16771                    if max_rounds.is_none()
16772                        && report.segment_rows_applied > 0
16773                        && !report.bootstrapping.is_empty()
16774                        && before
16775                            != self
16776                                .subs
16777                                .iter()
16778                                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
16779                                .collect::<Vec<_>>()
16780                    {
16781                        spent = 0;
16782                    }
16783                    // TS parity: a disabled transport or a schema floor ends
16784                    // the loop. The latest round still merges first.
16785                    let stop = !self.transport_enabled() || report.schema_floor.is_some();
16786                    aggregate.merge(&report);
16787                    if stop {
16788                        return SyncOutcome::Ok(aggregate);
16789                    }
16790                    // §4.5: pull again whenever the response contained
16791                    // commits or segments; resets re-bootstrap; a pending
16792                    // resume token continues paging (§4.7); deferred outbox
16793                    // commits and a raised sync-needed signal push on the next
16794                    // round.
16795                    let more = !report.bootstrapping.is_empty()
16796                        || report.commits_applied > 0
16797                        || report.segment_rows_applied > 0
16798                        || !report.resets.is_empty()
16799                        || report.deferred_commits > 0
16800                        || self.sync_needed;
16801                    if !more {
16802                        return SyncOutcome::Ok(aggregate);
16803                    }
16804                }
16805            }
16806        }
16807        // §7.7: partial success. The aggregate report is retained and the run
16808        // is explicitly not idle.
16809        SyncOutcome::BudgetExhausted(aggregate)
16810    }
16811
16812    fn process_response(
16813        &mut self,
16814        transport: &mut dyn Transport,
16815        response: Message,
16816        meta: &RequestMeta,
16817    ) -> SyncOutcome {
16818        #[cfg(feature = "bench-internals")]
16819        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
16820        let mut report = SyncReport {
16821            pushed: meta.pushed_ids.len() as u32,
16822            ..SyncReport::default()
16823        };
16824        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
16825            HashMap::new();
16826        let pushed_ids = meta
16827            .pushed_ids
16828            .iter()
16829            .map(String::as_str)
16830            .collect::<HashSet<_>>();
16831        let mut last_final_push_result_id: Option<String> = None;
16832        for frame in &response.frames {
16833            match frame {
16834                Frame::PushResultDetails {
16835                    client_commit_id,
16836                    entries,
16837                } => {
16838                    let details = rejection_details_by_commit
16839                        .entry(client_commit_id.clone())
16840                        .or_default();
16841                    for entry in entries {
16842                        let parsed = match RejectionDetails::parse(&entry.details.0) {
16843                            Ok(value) => value,
16844                            Err(message) => {
16845                                return SyncOutcome::Failed {
16846                                    details: None,
16847                                    error_code: "sync.invalid_request".to_owned(),
16848                                    message,
16849                                };
16850                            }
16851                        };
16852                        details.insert(entry.op_index, parsed);
16853                    }
16854                }
16855                Frame::PushResult {
16856                    client_commit_id,
16857                    status,
16858                    results,
16859                    ..
16860                } if pushed_ids.contains(client_commit_id.as_str())
16861                    && Self::push_result_is_final(*status, results) =>
16862                {
16863                    last_final_push_result_id = Some(client_commit_id.clone());
16864                }
16865                _ => {}
16866            }
16867        }
16868        let mut frames = response.frames.into_iter().peekable();
16869        if response.wire_version < 2 {
16870            return SyncOutcome::Failed {
16871                details: None,
16872                error_code: "client.invalid_host_response".to_owned(),
16873                message: "server response does not carry wire version 2 log-epoch state".to_owned(),
16874            };
16875        }
16876        match frames.next() {
16877            Some(Frame::RespHeader {
16878                required_schema_version,
16879                latest_schema_version,
16880                log_epoch,
16881                reset_required,
16882            }) => {
16883                let Some(log_epoch) = log_epoch else {
16884                    return SyncOutcome::Failed {
16885                        details: None,
16886                        error_code: "client.invalid_host_response".to_owned(),
16887                        message: "response header omits logEpoch".to_owned(),
16888                    };
16889                };
16890                let Some(reset_required) = reset_required else {
16891                    return SyncOutcome::Failed {
16892                        details: None,
16893                        error_code: "client.invalid_host_response".to_owned(),
16894                        message: "response header omits resetRequired".to_owned(),
16895                    };
16896                };
16897                if let Some(required) = required_schema_version {
16898                    // §1.6 schema-floor response: nothing else is processed;
16899                    // stop syncing and surface the upgrade requirement.
16900                    let floor = SchemaFloor {
16901                        required_schema_version: Some(required),
16902                        latest_schema_version,
16903                    };
16904                    self.set_schema_floor(Some(floor.clone()));
16905                    report.schema_floor = Some(floor);
16906                    return SyncOutcome::Ok(report);
16907                }
16908                if reset_required {
16909                    if frames.next().is_some() {
16910                        return SyncOutcome::Failed {
16911                            details: None,
16912                            error_code: "client.invalid_host_response".to_owned(),
16913                            message: "log-epoch reset response contains body frames".to_owned(),
16914                        };
16915                    }
16916                    match self.run_log_epoch_reset(&log_epoch) {
16917                        Ok(resets) => {
16918                            report.resets = resets;
16919                            return SyncOutcome::Ok(report);
16920                        }
16921                        Err(message) => {
16922                            return SyncOutcome::Failed {
16923                                details: None,
16924                                error_code: "sync.local_corrupt".to_owned(),
16925                                message,
16926                            };
16927                        }
16928                    }
16929                }
16930                if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
16931                    return SyncOutcome::Failed {
16932                        details: None,
16933                        error_code: "client.invalid_host_response".to_owned(),
16934                        message: "server changed logEpoch without requiring a reset".to_owned(),
16935                    };
16936                }
16937            }
16938            _ => {
16939                return SyncOutcome::Failed {
16940                    details: None,
16941                    error_code: "sync.invalid_request".to_owned(),
16942                    message: "response does not start with RESP_HEADER".to_owned(),
16943                };
16944            }
16945        }
16946
16947        if let Err(message) = self.begin_coalesced_apply() {
16948            return SyncOutcome::Failed {
16949                details: None,
16950                error_code: "storage.failed".into(),
16951                message,
16952            };
16953        }
16954        let mut failure: Option<(String, String)> = None;
16955        while let Some(frame) = frames.next() {
16956            match frame {
16957                frame @ Frame::PushResult { .. } => {
16958                    let successful = matches!(
16959                        &frame,
16960                        Frame::PushResult {
16961                            status: PushStatus::Applied | PushStatus::Cached,
16962                            ..
16963                        }
16964                    );
16965                    let mut results = vec![frame];
16966                    if successful {
16967                        while matches!(
16968                            frames.peek(),
16969                            Some(Frame::PushResult {
16970                                status: PushStatus::Applied | PushStatus::Cached,
16971                                ..
16972                            })
16973                        ) {
16974                            if let Some(next) = frames.next() {
16975                                results.push(next);
16976                            }
16977                        }
16978                    }
16979                    if self
16980                        .handle_push_results(
16981                            &results,
16982                            &pushed_ids,
16983                            &rejection_details_by_commit,
16984                            &mut report,
16985                            last_final_push_result_id.as_deref(),
16986                        )
16987                        .is_err()
16988                    {
16989                        failure = Some((
16990                            "client.outcome_persistence_failed".to_owned(),
16991                            "local commit outcome could not be persisted".to_owned(),
16992                        ));
16993                        break;
16994                    }
16995                }
16996                Frame::PushResultDetails { .. } => {}
16997                Frame::SubStart {
16998                    id,
16999                    status,
17000                    reason_code,
17001                    effective_scopes,
17002                    bootstrap: _,
17003                } => {
17004                    let mut body = Vec::new();
17005                    let mut sub_end: Option<(i64, Option<String>)> = None;
17006                    for inner in frames.by_ref() {
17007                        match inner {
17008                            Frame::SubEnd {
17009                                next_cursor,
17010                                bootstrap_state,
17011                            } => {
17012                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
17013                                break;
17014                            }
17015                            Frame::Unknown { .. } => {}
17016                            other => body.push(other),
17017                        }
17018                    }
17019                    if let Err(SectionError::Abort(code, message)) = self.process_section(
17020                        transport,
17021                        &id,
17022                        status,
17023                        &reason_code,
17024                        effective_scopes,
17025                        body,
17026                        sub_end,
17027                        meta,
17028                        &mut report,
17029                    ) {
17030                        failure = Some((code, message));
17031                        break;
17032                    }
17033                }
17034                Frame::Lease {
17035                    lease_id,
17036                    expires_at_ms,
17037                } => {
17038                    // §7.3.5: persist the opaque lease; a fresh lease clears
17039                    // any prior lease error (the outage/revocation is over).
17040                    self.set_lease_state(Some(LeaseState {
17041                        lease_id: Some(lease_id),
17042                        expires_at_ms: Some(expires_at_ms),
17043                        error_code: None,
17044                    }));
17045                }
17046                Frame::Error { code, message, .. } => {
17047                    // §1.6: the whole request failed; already-completed
17048                    // subscriptions keep their applied data and cursors.
17049                    failure = Some((code, message));
17050                    break;
17051                }
17052                Frame::Unknown { .. } => {}
17053                _ => {
17054                    failure = Some((
17055                        "sync.invalid_request".to_owned(),
17056                        "unexpected frame in response".to_owned(),
17057                    ));
17058                    break;
17059                }
17060            }
17061        }
17062
17063        let applied = match failure {
17064            Some(failure) => Err(failure),
17065            // Local apply boundaries already reconciled their affected overlay.
17066            // Refuse round success if any remaining dirty work fails to
17067            // reconcile. §4.8 E1: the push half may have drained commits that
17068            // pinned rows of a shrunk window unit, so retry deferred evictions.
17069            None => self
17070                .rebuild_overlay_if_dirty()
17071                .and_then(|()| self.drain_pending_evictions())
17072                .map_err(|message| ("storage.failed".to_owned(), message)),
17073        };
17074        // §1.4 rule 4: frames completed before a failure stay committed; a
17075        // failed COMMIT of the coalesced transaction fails the round.
17076        if let Err(message) = self.finish_coalesced_apply() {
17077            return SyncOutcome::Failed {
17078                details: None,
17079                error_code: "storage.failed".into(),
17080                message,
17081            };
17082        }
17083        if let Err((error_code, message)) = applied {
17084            return SyncOutcome::Failed {
17085                details: None,
17086                error_code,
17087                message,
17088            };
17089        }
17090        self.ack_after_pull(transport);
17091        // §7.4.5: the reset is over once the first post-reset pull round
17092        // leaves no subscription mid-bootstrap — the tables are rebuilt.
17093        if self.upgrading && report.bootstrapping.is_empty() {
17094            self.set_upgrading(false);
17095        }
17096        SyncOutcome::Ok(report)
17097    }
17098
17099    // -- push results (§6.3, §7.2) ------------------------------------------------
17100
17101    fn handle_push_results(
17102        &mut self,
17103        frames: &[Frame],
17104        pushed_ids: &HashSet<&str>,
17105        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
17106        report: &mut SyncReport,
17107        last_final_push_result_id: Option<&str>,
17108    ) -> Result<(), String> {
17109        self.begin_observation("syncular_push_results")?;
17110        let previous_failed = self.failed_commits.clone();
17111        let previous_sync_needed = self.sync_needed;
17112        let conflict_count = self.conflicts.len();
17113        let rejection_count = self.rejections.len();
17114        let applied_count = report.applied.len();
17115        let rejected_count = report.rejected.len();
17116        let retryable_count = report.retryable.len();
17117        let was_dirty = self.overlay_dirty.snapshot();
17118        let mut removed = Vec::new();
17119        let mut batch = ChangeAccumulator::default();
17120        let persisted = (|| {
17121            for frame in frames {
17122                let Frame::PushResult {
17123                    client_commit_id,
17124                    status,
17125                    results,
17126                    commit_seq,
17127                } = frame
17128                else {
17129                    return Err("unexpected acknowledgement frame".to_owned());
17130                };
17131                if !pushed_ids.contains(client_commit_id.as_str()) {
17132                    continue;
17133                }
17134                let Some(index) = self
17135                    .outbox
17136                    .iter()
17137                    .position(|commit| commit.client_commit_id == *client_commit_id)
17138                else {
17139                    continue;
17140                };
17141                let status = *status;
17142                let rejection_details = rejection_details_by_commit.get(client_commit_id);
17143                let operations = self.outbox[index].ops.clone();
17144                match status {
17145                    PushStatus::Applied | PushStatus::Cached => {
17146                        // §7.2: a lost ack replays as `cached` — proceed as if the
17147                        // ack had arrived.
17148                        let sequence =
17149                            commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
17150                        let affected: HashSet<_> = operations
17151                            .iter()
17152                            .map(|op| (op.table.as_str(), op.row_id.as_str()))
17153                            .collect();
17154                        let prior = self.acknowledged_operations(Some(&affected), None)?;
17155                        for (op_index, operation) in operations.iter().enumerate() {
17156                            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))
17157                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17158                            if delivered {
17159                                continue;
17160                            }
17161                            let table = self
17162                                .schema
17163                                .table(&operation.table)
17164                                .ok_or_else(|| "sync.unknown_table".to_owned())?;
17165                            let mut intended: Map<String, Value> = self
17166                                .stored_row(table, &operation.row_id, true)?
17167                                .map(|(row, _)| {
17168                                    table
17169                                        .columns
17170                                        .iter()
17171                                        .zip(&row)
17172                                        .map(|(column, value)| {
17173                                            (column.name.clone(), column_value_to_json(value))
17174                                        })
17175                                        .collect()
17176                                })
17177                                .unwrap_or_default();
17178                            let mut scope_image = intended.clone();
17179                            for op in prior
17180                                .iter()
17181                                .chain(
17182                                    self.failed_commits
17183                                        .iter()
17184                                        .flat_map(|(commit, _)| &commit.ops),
17185                                )
17186                                .chain(
17187                                    self.outbox
17188                                        .iter()
17189                                        .take(index)
17190                                        .flat_map(|commit| &commit.ops),
17191                                )
17192                                .chain(operations.iter().take(op_index + 1))
17193                            {
17194                                if op.table != operation.table || op.row_id != operation.row_id {
17195                                    continue;
17196                                }
17197                                if let Some(values) = &op.values {
17198                                    intended.extend(values.clone());
17199                                } else {
17200                                    scope_image = intended.clone();
17201                                    intended.clear();
17202                                }
17203                            }
17204                            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)",
17205                                rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
17206                                    serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
17207                                    serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
17208                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17209                        }
17210                        let journal_results = results
17211                            .iter()
17212                            .map(|result| {
17213                                let op_index = match result {
17214                                    OpResult::Applied { op_index }
17215                                    | OpResult::Conflict { op_index, .. }
17216                                    | OpResult::Error { op_index, .. } => *op_index,
17217                                };
17218                                CommitOperationOutcome::Applied { op_index }
17219                            })
17220                            .collect::<Vec<_>>();
17221                        let outcome_status = if status == PushStatus::Applied {
17222                            CommitOutcomeStatus::Applied
17223                        } else {
17224                            CommitOutcomeStatus::Cached
17225                        };
17226                        let persisted = self
17227                            .persist_commit_outcome(
17228                                client_commit_id,
17229                                outcome_status,
17230                                &journal_results,
17231                                None,
17232                            )
17233                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
17234                        persisted?;
17235                        for operation in &operations {
17236                            self.overlay_dirty.table(&operation.table);
17237                        }
17238                        self.sync_needed = true;
17239                        batch.status = true;
17240                        batch.outcomes = true;
17241                    }
17242                    PushStatus::Rejected => {
17243                        if results.iter().any(|result| {
17244                            matches!(
17245                                result,
17246                                OpResult::Error {
17247                                    code,
17248                                    retryable: true,
17249                                    ..
17250                                } if code == "sync.idempotency_cache_miss"
17251                            )
17252                        }) {
17253                            // §6.3/§7.2: a serving failure, not an outcome — keep the
17254                            // exact commit queued for an identical retry.
17255                            report.retryable.push(client_commit_id.to_owned());
17256                            continue;
17257                        }
17258
17259                        let mut journal_results = Vec::with_capacity(results.len());
17260                        let mut conflicts = Vec::new();
17261                        let mut rejections = Vec::new();
17262                        for result in results {
17263                            match result {
17264                                OpResult::Applied { op_index } => {
17265                                    journal_results.push(CommitOperationOutcome::Applied {
17266                                        op_index: *op_index,
17267                                    });
17268                                }
17269                                OpResult::Conflict {
17270                                    op_index,
17271                                    code,
17272                                    message,
17273                                    server_version,
17274                                    server_row,
17275                                    conflict_columns: conflict_columns_bitmap,
17276                                } => {
17277                                    let operation = operations
17278                                        .get(*op_index as usize)
17279                                        .map(CommitOperation::from);
17280                                    let (table, row_id) = operation
17281                                        .as_ref()
17282                                        .map(|op| (op.table.clone(), op.row_id.clone()))
17283                                        .unwrap_or_default();
17284                                    let server_row_json = self
17285                                        .schema
17286                                        .table(&table)
17287                                        .and_then(|t| {
17288                                            decode_row_bytes(t, server_row, &self.encryption)
17289                                                .ok()
17290                                                .map(|row| (t, row))
17291                                        })
17292                                        .map(|(t, row)| {
17293                                            let mut map = Map::new();
17294                                            for (i, column) in t.columns.iter().enumerate() {
17295                                                map.insert(
17296                                                    column.name.clone(),
17297                                                    column_value_to_json(
17298                                                        row.get(i).unwrap_or(&None),
17299                                                    ),
17300                                                );
17301                                            }
17302                                            map
17303                                        })
17304                                        .unwrap_or_default();
17305                                    // §6.3: decode the presence-layout bitmap
17306                                    // into the marked column names.
17307                                    let conflict_columns = self
17308                                        .schema
17309                                        .table(&table)
17310                                        .map(|t| {
17311                                            t.columns
17312                                                .iter()
17313                                                .enumerate()
17314                                                .filter(|(index, _)| {
17315                                                    conflict_columns_bitmap
17316                                                        .get(index / 8)
17317                                                        .is_some_and(|byte| {
17318                                                            byte & (1 << (index % 8)) != 0
17319                                                        })
17320                                                })
17321                                                .map(|(_, column)| column.name.clone())
17322                                                .collect::<Vec<_>>()
17323                                        })
17324                                        .unwrap_or_default();
17325                                    let conflict = ConflictRecord {
17326                                        client_commit_id: client_commit_id.to_owned(),
17327                                        op_index: *op_index,
17328                                        table,
17329                                        row_id,
17330                                        code: code.clone(),
17331                                        message: message.clone(),
17332                                        server_version: *server_version,
17333                                        server_row: server_row_json,
17334                                        conflict_columns,
17335                                        operation,
17336                                    };
17337                                    journal_results.push(CommitOperationOutcome::Conflict {
17338                                        conflict: conflict.clone(),
17339                                    });
17340                                    conflicts.push(conflict);
17341                                }
17342                                OpResult::Error {
17343                                    op_index,
17344                                    code,
17345                                    message,
17346                                    retryable,
17347                                } => {
17348                                    let rejection = RejectionRecord {
17349                                        client_commit_id: client_commit_id.to_owned(),
17350                                        op_index: *op_index,
17351                                        code: code.clone(),
17352                                        message: message.clone(),
17353                                        retryable: *retryable,
17354                                        details: rejection_details
17355                                            .and_then(|details| details.get(op_index))
17356                                            .cloned(),
17357                                        operation: operations
17358                                            .get(*op_index as usize)
17359                                            .map(CommitOperation::from),
17360                                    };
17361                                    journal_results.push(CommitOperationOutcome::Error {
17362                                        rejection: rejection.clone(),
17363                                    });
17364                                    rejections.push(rejection);
17365                                }
17366                            }
17367                        }
17368                        if self.retain_failed_commits {
17369                            self.retain_failed_commit(client_commit_id, &operations)?;
17370                        }
17371                        let outcome_status = if conflicts.is_empty() {
17372                            CommitOutcomeStatus::Rejected
17373                        } else {
17374                            CommitOutcomeStatus::Conflict
17375                        };
17376                        let persisted = self
17377                            .persist_commit_outcome(
17378                                client_commit_id,
17379                                outcome_status,
17380                                &journal_results,
17381                                Some(&operations),
17382                            )
17383                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
17384                        persisted?;
17385                        batch.conflicts = !conflicts.is_empty();
17386                        batch.rejections = !rejections.is_empty();
17387                        batch.status = true;
17388                        batch.outcomes = true;
17389                        self.conflicts.extend(conflicts);
17390                        self.rejections.extend(rejections);
17391                        for operation in &operations {
17392                            self.overlay_dirty.table(&operation.table);
17393                        }
17394                    }
17395                }
17396                removed.push((index, self.outbox.remove(index)));
17397                if batch.status {
17398                    for operation in &operations {
17399                        if !self.record_row_scopes(
17400                            &mut batch,
17401                            &operation.table,
17402                            &operation.row_id,
17403                            false,
17404                        ) {
17405                            batch.table(&operation.table);
17406                        }
17407                    }
17408                }
17409                match status {
17410                    PushStatus::Applied | PushStatus::Cached => {
17411                        report.applied.push(client_commit_id.to_owned())
17412                    }
17413                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
17414                }
17415            }
17416            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)) {
17417                self.prune_commit_outcomes()?;
17418            }
17419            self.rebuild_overlay_if_dirty()?;
17420            self.finish_observation("syncular_push_results", batch)
17421        })();
17422        if let Err(error) = persisted {
17423            self.rollback_observation("syncular_push_results");
17424            self.failed_commits = previous_failed;
17425            self.sync_needed = previous_sync_needed;
17426            for (index, commit) in removed.into_iter().rev() {
17427                self.outbox.insert(index, commit);
17428            }
17429            self.conflicts.truncate(conflict_count);
17430            self.rejections.truncate(rejection_count);
17431            self.overlay_dirty.restore(was_dirty.clone());
17432            report.applied.truncate(applied_count);
17433            report.rejected.truncate(rejected_count);
17434            report.retryable.truncate(retryable_count);
17435            return Err(error);
17436        }
17437        if !previous_sync_needed && self.sync_needed {
17438            self.sync_intent_queue.push_back(SyncIntent::Interactive);
17439        }
17440        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
17441        Ok(())
17442    }
17443
17444    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
17445        status != PushStatus::Rejected
17446            || !results.iter().any(|result| {
17447                matches!(
17448                    result,
17449                    OpResult::Error {
17450                        code,
17451                        retryable: true,
17452                        ..
17453                    } if code == "sync.idempotency_cache_miss"
17454                )
17455            })
17456    }
17457
17458    // -- subscription sections ------------------------------------------------------
17459
17460    #[allow(clippy::too_many_arguments)]
17461    fn process_section(
17462        &mut self,
17463        transport: &mut dyn Transport,
17464        id: &str,
17465        status: SubStatus,
17466        reason_code: &str,
17467        effective_scopes: Vec<(String, Vec<String>)>,
17468        body: Vec<Frame>,
17469        sub_end: Option<(i64, Option<String>)>,
17470        meta: &RequestMeta,
17471        report: &mut SyncReport,
17472    ) -> Result<(), SectionError> {
17473        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
17474            return Ok(()); // unknown echo: ignore
17475        };
17476        if status != SubStatus::Active && sub_end.is_none() {
17477            return Err(SectionError::Abort(
17478                "sync.invalid_request".into(),
17479                "subscription section without SUB_END".into(),
17480            ));
17481        }
17482        match status {
17483            SubStatus::Revoked => {
17484                self.begin_observation("syncular_revocation")
17485                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
17486                let previous = self.subs[sub_index].clone();
17487                let was_dirty = self.overlay_dirty.snapshot();
17488                let previous_outbox = self.outbox.clone();
17489                let previous_failed = self.failed_commits.clone();
17490                let previous_rejections = self.rejections.clone();
17491                let previous_conflicts = self.conflicts.clone();
17492                let revoked_count = report.revoked.len();
17493                let failed_count = report.failed.len();
17494                let result = (|| {
17495                    let mut batch = ChangeAccumulator::default();
17496                    let registered = self.window_unit_by_sub_id(id);
17497                    // §3.3: stop pulling, purge exactly the last effective grant.
17498                    let (table, effective) = {
17499                        let sub = &self.subs[sub_index];
17500                        (sub.table.clone(), sub.effective.clone().unwrap_or_default())
17501                    };
17502                    let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
17503                        SectionError::Abort(
17504                            "sync.unknown_table".to_owned(),
17505                            "revoked scope references an unknown table".to_owned(),
17506                        )
17507                    })?;
17508                    let dropped = self
17509                        .drop_failed_commits(|name, values| {
17510                            name == table
17511                                && !effective.is_empty()
17512                                && effective.iter().all(|(variable, allowed)| {
17513                                    scoped_table
17514                                        .scope_column(variable)
17515                                        .and_then(|column| values.get(column))
17516                                        .and_then(Value::as_str)
17517                                        .is_some_and(|value| {
17518                                            allowed.iter().any(|allowed| allowed == value)
17519                                        })
17520                                })
17521                        })
17522                        .map_err(|message| {
17523                            SectionError::Abort("storage.failed".to_owned(), message)
17524                        })?;
17525                    for commit in dropped {
17526                        batch.conflicts = true;
17527                        batch.rejections = true;
17528                        batch.outcomes = true;
17529                        for operation in commit.ops {
17530                            batch.table(&operation.table);
17531                        }
17532                    }
17533                    let purged = self.purge_scope_rows(&table, &effective);
17534                    match purged {
17535                        Ok(()) => {
17536                            self.record_scope_map(&mut batch, &table, &effective);
17537                            let sub = &mut self.subs[sub_index];
17538                            sub.state = SubState::Revoked;
17539                            sub.reason_code = Some(if reason_code.is_empty() {
17540                                "sync.scope_revoked".to_owned()
17541                            } else {
17542                                reason_code.to_owned()
17543                            });
17544                            report.revoked.push(id.to_owned());
17545                            let doomed_effective = effective;
17546                            let sub_table = table;
17547                            self.persist_sub(&self.subs[sub_index].clone())
17548                                .map_err(|message| {
17549                                    SectionError::Abort("storage.failed".into(), message)
17550                                })?;
17551                            let dropped = self
17552                                .drop_doomed_outbox(&sub_table, &doomed_effective)
17553                                .map_err(|message| {
17554                                    SectionError::Abort("storage.failed".to_owned(), message)
17555                                })?;
17556                            if dropped {
17557                                batch.status = true;
17558                                batch.rejections = true;
17559                                batch.outcomes = true;
17560                            }
17561                            // §5.9.7 B2: revocation deletes now-unauthorized blob
17562                            // bodies (evicted ≠ revoked).
17563                            self.delete_unreferenced_cached_blobs().map_err(|message| {
17564                                SectionError::Abort("storage.failed".into(), message)
17565                            })?;
17566                        }
17567                        Err(()) => {
17568                            // §3.3 fail closed: no local mapping — never clear by
17569                            // approximation; fatal configuration error.
17570                            let sub = &mut self.subs[sub_index];
17571                            sub.state = SubState::Failed;
17572                            sub.reason_code = Some("sync.scope_revoked".to_owned());
17573                            report.failed.push(id.to_owned());
17574                            self.persist_sub(&self.subs[sub_index].clone())
17575                                .map_err(|message| {
17576                                    SectionError::Abort("storage.failed".into(), message)
17577                                })?;
17578                        }
17579                    }
17580                    if let Some((base_key, unit)) = registered {
17581                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
17582                    }
17583                    self.rebuild_overlay_if_dirty()
17584                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17585                    self.finish_observation("syncular_revocation", batch)
17586                        .map_err(|message| {
17587                            SectionError::Abort("storage.failed".to_owned(), message)
17588                        })?;
17589                    Ok(())
17590                })();
17591                if result.is_err() {
17592                    self.rollback_observation("syncular_revocation");
17593                    self.subs[sub_index] = previous;
17594                    self.overlay_dirty.restore(was_dirty.clone());
17595                    self.outbox = previous_outbox;
17596                    self.failed_commits = previous_failed;
17597                    self.rejections = previous_rejections;
17598                    self.conflicts = previous_conflicts;
17599                    report.revoked.truncate(revoked_count);
17600                    report.failed.truncate(failed_count);
17601                }
17602                result
17603            }
17604            SubStatus::Reset => {
17605                self.begin_observation("syncular_reset")
17606                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
17607                let previous = self.subs[sub_index].clone();
17608                let was_dirty = self.overlay_dirty.snapshot();
17609                let reset_count = report.resets.len();
17610                let result = (|| {
17611                    let mut batch = ChangeAccumulator::default();
17612                    let registered = self.window_unit_by_sub_id(id);
17613                    // §4.6: discard cursor + bootstrap state, keep local rows —
17614                    // reset is a staleness signal, not a purge signal.
17615                    let sub = &mut self.subs[sub_index];
17616                    sub.cursor = -1;
17617                    sub.bootstrap_state = None;
17618                    sub.reason_code = Some(if reason_code.is_empty() {
17619                        "sync.cursor_expired".to_owned()
17620                    } else {
17621                        reason_code.to_owned()
17622                    });
17623                    report.resets.push(id.to_owned());
17624                    self.persist_sub(&self.subs[sub_index].clone())
17625                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17626                    if let Some((base_key, unit)) = registered {
17627                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
17628                    }
17629                    self.finish_observation("syncular_reset", batch)
17630                        .map_err(|message| {
17631                            SectionError::Abort("storage.failed".to_owned(), message)
17632                        })?;
17633                    Ok(())
17634                })();
17635                if result.is_err() {
17636                    self.rollback_observation("syncular_reset");
17637                    self.subs[sub_index] = previous;
17638                    self.overlay_dirty.restore(was_dirty.clone());
17639                    report.resets.truncate(reset_count);
17640                }
17641                result
17642            }
17643            SubStatus::Active => {
17644                let fresh = meta
17645                    .fresh
17646                    .iter()
17647                    .find(|(fid, _)| fid == id)
17648                    .map(|(_, fresh)| *fresh)
17649                    .unwrap_or(false);
17650                let previous = self.subs[sub_index].clone();
17651                self.subs[sub_index].effective = Some(effective_scopes);
17652                let outcome = self
17653                    .apply_section_body(transport, sub_index, body, fresh, meta, report)
17654                    .and_then(|()| {
17655                        let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
17656                            SectionError::Abort(
17657                                "sync.invalid_request".into(),
17658                                "subscription section without SUB_END".into(),
17659                            )
17660                        })?;
17661                        self.apply_sub_end(sub_index, cursor, bootstrap_state)
17662                            .map_err(|message| {
17663                                SectionError::Abort("storage.failed".into(), message)
17664                            })
17665                    });
17666                match outcome {
17667                    Ok(()) => {
17668                        if self.subs[sub_index].bootstrap_state.is_some() {
17669                            report.bootstrapping.push(id.to_owned());
17670                        }
17671                        Ok(())
17672                    }
17673                    Err(SectionError::FailClosed) => {
17674                        self.subs[sub_index] = previous.clone();
17675                        self.begin_observation("syncular_section_failure")
17676                            .map_err(|message| {
17677                                SectionError::Abort("storage.failed".into(), message)
17678                            })?;
17679                        let persisted = (|| {
17680                            let mut batch = ChangeAccumulator::default();
17681                            if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
17682                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
17683                            }
17684                            self.subs[sub_index].state = SubState::Failed;
17685                            self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
17686                            self.persist_sub(&self.subs[sub_index])?;
17687                            self.finish_observation("syncular_section_failure", batch)
17688                        })();
17689                        if let Err(message) = persisted {
17690                            self.rollback_observation("syncular_section_failure");
17691                            self.subs[sub_index] = previous;
17692                            return Err(SectionError::Abort("storage.failed".into(), message));
17693                        }
17694                        report.failed.push(id.to_owned());
17695                        Ok(())
17696                    }
17697                    Err(error) => {
17698                        // Frame/block transactions already completed. Preserve their
17699                        // rows and revisions, but leave this subscription cursor unchanged.
17700                        self.subs[sub_index] = previous;
17701                        Err(error)
17702                    }
17703                }
17704            }
17705        }
17706    }
17707
17708    fn apply_sub_end(
17709        &mut self,
17710        sub_index: usize,
17711        cursor: i64,
17712        bootstrap_state: Option<String>,
17713    ) -> Result<(), String> {
17714        #[cfg(feature = "bench-internals")]
17715        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
17716        let previous = self.subs[sub_index].clone();
17717        self.begin_observation("syncular_sub_end")?;
17718        let persisted = (|| {
17719            let mut batch = ChangeAccumulator::default();
17720            let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
17721                && cursor >= 0
17722                && bootstrap_state.is_none();
17723            if completed {
17724                let table = self
17725                    .schema
17726                    .table(&previous.table)
17727                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
17728                let effective = previous.effective.as_deref().unwrap_or(&[]);
17729                let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
17730                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17731                let rows = statement
17732                    .query_map(rusqlite::params![previous.table, cursor], |row| {
17733                        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
17734                    })
17735                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17736                let mut covered = HashSet::new();
17737                for row in rows {
17738                    let (id, intent) =
17739                        row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17740                    let values: Map<String, Value> = serde_json::from_str(&intent)
17741                        .map_err(|_| "sync.local_corrupt".to_owned())?;
17742                    if !effective.is_empty()
17743                        && effective.iter().all(|(variable, allowed)| {
17744                            table
17745                                .scope_column(variable)
17746                                .and_then(|column| values.get(column))
17747                                .is_some_and(|value| {
17748                                    allowed.contains(
17749                                        &value
17750                                            .as_str()
17751                                            .map_or_else(|| value.to_string(), str::to_owned),
17752                                    )
17753                                })
17754                        })
17755                    {
17756                        covered.insert(id);
17757                    }
17758                }
17759                drop(statement);
17760                for id in covered {
17761                    self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
17762                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17763                    self.overlay_dirty.table(&previous.table);
17764                    batch.table(&previous.table);
17765                }
17766                self.rebuild_overlay_if_dirty()?;
17767                if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
17768                    batch.window(&base_key, &previous.table, &unit);
17769                }
17770            }
17771            let sub = &mut self.subs[sub_index];
17772            sub.cursor = cursor;
17773            sub.bootstrap_state = bootstrap_state;
17774            // §4.6: a completed (or continuing) active end clears the reset /
17775            // revocation reason, matching the TS core's active-state persist.
17776            sub.reason_code = None;
17777            sub.synced_once = true;
17778            self.persist_sub(&self.subs[sub_index])?;
17779            self.finish_observation("syncular_sub_end", batch)
17780        })();
17781        if persisted.is_err() {
17782            self.rollback_observation("syncular_sub_end");
17783            self.subs[sub_index] = previous;
17784        }
17785        persisted
17786    }
17787
17788    fn apply_section_body(
17789        &mut self,
17790        transport: &mut dyn Transport,
17791        sub_index: usize,
17792        body: Vec<Frame>,
17793        fresh: bool,
17794        meta: &RequestMeta,
17795        report: &mut SyncReport,
17796    ) -> Result<(), SectionError> {
17797        let mut saw_segment = false;
17798        for frame in body {
17799            match frame {
17800                Frame::Commit {
17801                    tables,
17802                    changes,
17803                    commit_seq,
17804                    ..
17805                } => {
17806                    self.apply_commit_frame(&tables, &changes, Some(commit_seq))
17807                        .map_err(|(c, m)| SectionError::Abort(c, m))?;
17808                    report.commits_applied += 1;
17809                }
17810                Frame::SegmentInline { payload } => {
17811                    let segment = decode_rows_segment(&payload)
17812                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
17813                    self.progress.update(|p| {
17814                        p.phase = ProgressPhase::Import;
17815                        p.subscription_id = Some(self.subs[sub_index].id.clone());
17816                        p.table = Some(self.subs[sub_index].table.clone());
17817                        p.segment_id = None;
17818                        p.bytes_received = payload.len() as u64;
17819                        p.bytes_total = Some(payload.len() as u64);
17820                        p.rows_processed = 0;
17821                        p.rows_total =
17822                            Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
17823                    });
17824                    let first = !saw_segment;
17825                    saw_segment = true;
17826                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
17827                    report.segment_rows_applied += applied;
17828                }
17829                Frame::SegmentRef {
17830                    segment_id,
17831                    byte_length,
17832                    media_type,
17833                    table,
17834                    row_count,
17835                    as_of_commit_seq,
17836                    scope_digest,
17837                    row_cursor,
17838                    next_row_cursor,
17839                    url,
17840                    url_expires_at_ms,
17841                    ..
17842                } => {
17843                    // §4.2: reject a descriptor whose mediaType was not
17844                    // advertised — never skip or guess.
17845                    let advertised = match media_type {
17846                        MediaType::Rows => {
17847                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
17848                                || meta.accept & ACCEPT_INLINE_ROWS != 0
17849                        }
17850                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
17851                    };
17852                    if !advertised {
17853                        return Err(SectionError::Abort(
17854                            "sync.invalid_request".to_owned(),
17855                            format!(
17856                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
17857                                media_type.name()
17858                            ),
17859                        ));
17860                    }
17861                    self.progress.update(|p| {
17862                        p.phase = ProgressPhase::Download;
17863                        p.subscription_id = Some(self.subs[sub_index].id.clone());
17864                        p.table = Some(table.clone());
17865                        p.segment_id = Some(segment_id.clone());
17866                        p.bytes_received = 0;
17867                        p.bytes_total = u64::try_from(byte_length).ok();
17868                        p.rows_processed = 0;
17869                        p.rows_total = u64::try_from(row_count).ok();
17870                    });
17871                    let observer = self.progress.clone();
17872                    let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
17873                    let bytes = if let Some(url) = url {
17874                        // §5.4: a url-carrying descriptor MUST be fetched
17875                        // from that URL; failure invalidates the whole
17876                        // descriptor (no fall-through to §5.5 — re-pull
17877                        // recovers, §1.4 rule 5).
17878                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
17879                            return Err(SectionError::Abort(
17880                                "sync.invalid_request".to_owned(),
17881                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
17882                                    .to_owned(),
17883                            ));
17884                        }
17885                        // §5.4: MUST NOT start a fetch at/past expiry.
17886                        if url_expires_at_ms.is_some_and(|exp| {
17887                            exp <= transport
17888                                .segment_fetch_started_at(&url)
17889                                .unwrap_or_else(|| self.clock_now_ms())
17890                        }) {
17891                            return Err(SectionError::Abort(
17892                                "sync.segment_expired".to_owned(),
17893                                format!(
17894                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
17895                                ),
17896                            ));
17897                        }
17898                        transport
17899                            .fetch_url(&url, &mut on_progress)
17900                            .map_err(|e| SectionError::Abort(e.code, e.message))?
17901                    } else {
17902                        let requested_scopes_json =
17903                            canonical_scope_json(&self.subs[sub_index].requested);
17904                        transport
17905                            .download_segment(
17906                                &SegmentRequest {
17907                                    segment_id: segment_id.clone(),
17908                                    table,
17909                                    requested_scopes_json,
17910                                },
17911                                &mut on_progress,
17912                            )
17913                            .map_err(|e| SectionError::Abort(e.code, e.message))?
17914                    };
17915                    // §5.1: verify the content address before applying.
17916                    on_progress(bytes.len() as u64);
17917                    let digest = Sha256::digest(&bytes);
17918                    let expected = segment_id
17919                        .strip_prefix("sha256:")
17920                        .unwrap_or(segment_id.as_str());
17921                    if bytes_to_hex(&digest) != expected {
17922                        // §5.1: the client discards the segment and re-pulls.
17923                        self.schedule_background_retry();
17924                        return Err(SectionError::Abort(
17925                            "sync.invalid_request".to_owned(),
17926                            "segment bytes do not match the content address (§5.1)".to_owned(),
17927                        ));
17928                    }
17929                    self.progress.update(|p| p.phase = ProgressPhase::Import);
17930                    if media_type == MediaType::Sqlite {
17931                        // §5.3: images are whole-table — a paged sqlite
17932                        // descriptor is invalid.
17933                        if row_cursor.is_some() || next_row_cursor.is_some() {
17934                            return Err(SectionError::Abort(
17935                                "sync.invalid_request".to_owned(),
17936                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
17937                                    .to_owned(),
17938                            ));
17939                        }
17940                        let first = !saw_segment;
17941                        saw_segment = true;
17942                        let applied = self.apply_sqlite_segment(
17943                            sub_index,
17944                            &bytes,
17945                            fresh && first,
17946                            row_count,
17947                            as_of_commit_seq,
17948                            &scope_digest,
17949                        )?;
17950                        report.segment_rows_applied += applied;
17951                    } else {
17952                        let segment = decode_rows_segment(&bytes).map_err(|e| {
17953                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
17954                        })?;
17955                        let first = row_cursor.is_none();
17956                        saw_segment = true;
17957                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
17958                        report.segment_rows_applied += applied;
17959                    }
17960                }
17961                Frame::Unknown { .. } => {}
17962                Frame::Error { code, message, .. } => {
17963                    return Err(SectionError::Abort(code, message))
17964                }
17965                _ => {
17966                    return Err(SectionError::Abort(
17967                        "sync.invalid_request".to_owned(),
17968                        "unexpected frame inside a subscription section".to_owned(),
17969                    ));
17970                }
17971            }
17972        }
17973        Ok(())
17974    }
17975
17976    // Both pull and realtime frames use the same durable observation boundary.
17977    fn apply_commit_frame(
17978        &mut self,
17979        tables: &[String],
17980        changes: &[ssp2::model::Change],
17981        commit_seq: Option<i64>,
17982    ) -> Result<(), (String, String)> {
17983        #[cfg(feature = "bench-internals")]
17984        let _phase = self.benchmark_phases.start(Phase::CommitApply);
17985        let was_dirty = self.overlay_dirty.snapshot();
17986        self.coalesce_unit(changes.len())
17987            .and_then(|()| self.begin_observation("syncular_commit"))
17988            .map_err(|message| ("storage.failed".into(), message))?;
17989        let applied = (|| {
17990            let mut batch = ChangeAccumulator::default();
17991            self.record_commit_changes(&mut batch, tables, changes);
17992            if let Some(sequence) = commit_seq {
17993                for change in changes {
17994                    let table = tables.get(change.table_index as usize).ok_or_else(|| {
17995                        (
17996                            "sync.invalid_request".to_owned(),
17997                            "change tableIndex out of range".to_owned(),
17998                        )
17999                    })?;
18000                    if self
18001                        .outbox
18002                        .iter()
18003                        .flat_map(|commit| &commit.ops)
18004                        .any(|op| op.table == *table && op.row_id == change.row_id)
18005                    {
18006                        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])
18007                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
18008                    }
18009                    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])
18010                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
18011                }
18012            }
18013            self.apply_commit_changes(tables, changes)?;
18014            self.rebuild_overlay_if_dirty()
18015                .map_err(|message| ("storage.failed".into(), message))?;
18016            self.finish_observation("syncular_commit", batch)
18017                .map_err(|message| ("storage.failed".into(), message))
18018        })();
18019        if applied.is_err() {
18020            self.rollback_observation("syncular_commit");
18021            self.overlay_dirty.restore(was_dirty.clone());
18022        }
18023        applied
18024    }
18025
18026    fn apply_commit_changes(
18027        &mut self,
18028        tables: &[String],
18029        changes: &[ssp2::model::Change],
18030    ) -> Result<(), (String, String)> {
18031        // A clean overlay can reconcile changed primary keys independently
18032        // when their tables have no secondary unique constraints. Such a
18033        // constraint can change whether another row's pending write succeeds,
18034        // so those frames still require the complete FIFO replay below.
18035        let mirror_visible = !self.overlay_dirty.get()
18036            && self.failed_commits.is_empty()
18037            && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
18038                || changes.iter().all(|change| {
18039                    tables
18040                        .get(change.table_index as usize)
18041                        .and_then(|name| self.schema.table(name))
18042                        .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
18043                }));
18044        for change in changes {
18045            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
18046                (
18047                    "sync.invalid_request".to_owned(),
18048                    "change tableIndex out of range".to_owned(),
18049                )
18050            })?;
18051            let table = self.schema.table(table_name).ok_or_else(|| {
18052                (
18053                    "sync.schema_mismatch".to_owned(),
18054                    format!("change targets unknown table {table_name:?}"),
18055                )
18056            })?;
18057            match change.op {
18058                Op::Upsert => {
18059                    let payload = change.row.as_ref().ok_or_else(|| {
18060                        (
18061                            "sync.invalid_request".to_owned(),
18062                            "upsert change without row payload".to_owned(),
18063                        )
18064                    })?;
18065                    // §5.11: decrypt encrypted columns on apply.
18066                    let row = {
18067                        #[cfg(feature = "bench-internals")]
18068                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
18069                        decode_row_bytes(table, payload, &self.encryption)
18070                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?
18071                    };
18072                    let version = change.row_version.unwrap_or(0);
18073                    let table_name = table.name.clone();
18074                    self.write_base_row(&table_name, &row, version)
18075                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
18076                    if mirror_visible {
18077                        self.write_row(&visible_table(&table_name), &table_name, &row, version)
18078                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
18079                    }
18080                }
18081                Op::Delete => {
18082                    self.delete_base_row(table_name, &change.row_id)
18083                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
18084                    if mirror_visible {
18085                        let sql = format!(
18086                            "DELETE FROM {} WHERE {}",
18087                            visible_table(table_name),
18088                            row_id_predicate(table)
18089                        );
18090                        self.conn
18091                            .prepare_cached(&sql)
18092                            .and_then(|mut statement| {
18093                                statement.execute(rusqlite::params![change.row_id])
18094                            })
18095                            .map_err(|error| {
18096                                ("sync.invalid_request".to_owned(), error.to_string())
18097                            })?;
18098                    }
18099                }
18100            }
18101        }
18102        if mirror_visible {
18103            {
18104                let changed_rows: HashSet<(&str, &str)> = changes
18105                    .iter()
18106                    .map(|change| {
18107                        (
18108                            tables[change.table_index as usize].as_str(),
18109                            change.row_id.as_str(),
18110                        )
18111                    })
18112                    .collect();
18113                let acknowledged = self
18114                    .acknowledged_operations(Some(&changed_rows), None)
18115                    .map_err(|error| ("storage.failed".to_owned(), error))?;
18116                self.apply_outbox_ops(&acknowledged)
18117                    .map_err(|message| ("storage.failed".into(), message))?;
18118                self.apply_outbox_ops(
18119                    self.outbox
18120                        .iter()
18121                        .flat_map(|commit| &commit.ops)
18122                        .filter(|op| {
18123                            changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
18124                        }),
18125                )
18126                .map_err(|message| ("storage.failed".into(), message))?;
18127            }
18128            self.overlay_dirty.set(false);
18129        }
18130        Ok(())
18131    }
18132
18133    /// §5.6 segment application: validate against the generated schema,
18134    /// clear the grant on a fresh bootstrap's first page (fail closed
18135    /// without a mapping), then replace-or-upsert each row with its
18136    /// segment-carried server version (§5.2).
18137    fn apply_segment(
18138        &mut self,
18139        sub_index: usize,
18140        segment: &RowsSegment,
18141        first_fresh_page: bool,
18142    ) -> Result<u32, SectionError> {
18143        let (sub_table, effective) = {
18144            let sub = &self.subs[sub_index];
18145            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
18146        };
18147        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
18148            SectionError::Abort(
18149                "sync.schema_mismatch".to_owned(),
18150                format!("subscription table {sub_table:?} is not in the client schema"),
18151            )
18152        })?;
18153        // §5.2: the column table validates against the generated schema —
18154        // order, names, types, nullability; mismatch is fatal. §5.11: the
18155        // server sends the WIRE types (bytes for an encrypted column), so
18156        // validate against wire_columns.
18157        let matches = segment.table == table.name
18158            && segment.schema_version == self.schema.version
18159            && segment.columns.len() == table.wire_columns.len()
18160            && segment
18161                .columns
18162                .iter()
18163                .zip(table.wire_columns.iter())
18164                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
18165        if !matches {
18166            return Err(SectionError::Abort(
18167                "sync.schema_mismatch".to_owned(),
18168                "segment column table does not match the generated schema (§5.2)".to_owned(),
18169            ));
18170        }
18171        let mut applied = 0u32;
18172        let empty = Vec::new();
18173        let blocks = if segment.blocks.is_empty() {
18174            std::slice::from_ref(&empty)
18175        } else {
18176            &segment.blocks
18177        };
18178        for (index, block) in blocks.iter().enumerate() {
18179            let clear = first_fresh_page && index == 0;
18180            if !clear && block.is_empty() {
18181                continue;
18182            }
18183            let was_dirty = self.overlay_dirty.snapshot();
18184            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
18185                && self
18186                    .outbox
18187                    .iter()
18188                    .flat_map(|commit| &commit.ops)
18189                    .any(|op| op.table == table.name);
18190            let mirror_visible = !reconcile_pending
18191                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
18192            self.coalesce_unit(block.len())
18193                .and_then(|()| self.begin_observation("syncular_segment_block"))
18194                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18195            let outcome = (|| {
18196                self.rebuild_overlay_if_dirty()
18197                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18198                let mut changed_keys = Vec::new();
18199                let mut batch = ChangeAccumulator::default();
18200                if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
18201                    batch.table(&table.name);
18202                }
18203                if clear {
18204                    self.purge_scope_rows(&table.name, &effective)
18205                        .map_err(|()| SectionError::FailClosed)?;
18206                }
18207                for (row_index, row) in block.iter().enumerate() {
18208                    let decrypted;
18209                    let values = if table.has_encrypted_columns() {
18210                        let mut values = row.values.clone();
18211                        crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
18212                            .map_err(|message| {
18213                            SectionError::Abort("client.decrypt_failed".into(), message)
18214                        })?;
18215                        decrypted = values;
18216                        &decrypted
18217                    } else {
18218                        &row.values
18219                    };
18220                    self.write_base_row(&table.name, values, row.server_version)
18221                        .map_err(|message| {
18222                            SectionError::Abort("sync.invalid_request".into(), message)
18223                        })?;
18224                    if reconcile_pending {
18225                        changed_keys.push(
18226                            owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
18227                                |message| SectionError::Abort("storage.failed".into(), message),
18228                            )?,
18229                        );
18230                    }
18231                    if mirror_visible {
18232                        self.write_row(
18233                            &visible_table(&table.name),
18234                            &table.name,
18235                            values,
18236                            row.server_version,
18237                        )
18238                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18239                    }
18240                    let processed = u64::from(applied) + row_index as u64 + 1;
18241                    if processed.is_multiple_of(1024) {
18242                        self.progress.update(|p| p.rows_processed = processed);
18243                    }
18244                }
18245                if reconcile_pending {
18246                    self.reconcile_imported_rows(&table, &mut changed_keys)
18247                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18248                }
18249                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
18250                    self.apply_outbox_ops(
18251                        self.outbox
18252                            .iter()
18253                            .flat_map(|commit| &commit.ops)
18254                            .filter(|op| op.table == table.name),
18255                    )
18256                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18257                    self.overlay_dirty.set(false);
18258                } else {
18259                    self.rebuild_overlay_if_dirty()
18260                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18261                }
18262                self.finish_observation("syncular_segment_block", batch)
18263                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
18264            })();
18265            if let Err(error) = outcome {
18266                self.rollback_observation("syncular_segment_block");
18267                self.overlay_dirty.restore(was_dirty.clone());
18268                return Err(error);
18269            }
18270            applied += block.len() as u32;
18271            self.progress
18272                .update(|p| p.rows_processed = u64::from(applied));
18273            std::thread::yield_now();
18274        }
18275        self.progress
18276            .update(|p| p.rows_processed = u64::from(applied));
18277        Ok(applied)
18278    }
18279
18280    /// §5.3 sqlite-image application: validate the in-file metadata
18281    /// against the descriptor, validate column names/order against the
18282    /// generated schema, run the §5.6 first-page clear when fresh, then
18283    /// replace-or-upsert every image row with its `_syncular_version`.
18284    /// Mechanics: the image lands in a temp file read through a second
18285    /// rusqlite connection, equivalent to ATTACH + INSERT…SELECT.
18286    /// Each image chunk commits its writes and revision before readers continue.
18287    fn apply_sqlite_segment(
18288        &mut self,
18289        sub_index: usize,
18290        bytes: &[u8],
18291        first_fresh_page: bool,
18292        row_count: i64,
18293        as_of_commit_seq: i64,
18294        scope_digest: &str,
18295    ) -> Result<u32, SectionError> {
18296        let invalid = |detail: &str| {
18297            SectionError::Abort(
18298                "sync.invalid_request".to_owned(),
18299                format!("sqlite segment rejected: {detail} (§5.3)"),
18300            )
18301        };
18302        let (sub_table, effective) = {
18303            let sub = &self.subs[sub_index];
18304            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
18305        };
18306        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
18307            SectionError::Abort(
18308                "sync.schema_mismatch".to_owned(),
18309                format!("subscription table {sub_table:?} is not in the client schema"),
18310            )
18311        })?;
18312
18313        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
18314        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
18315        let img = match rusqlite::Connection::open_with_flags(
18316            &path,
18317            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
18318        ) {
18319            Ok(conn) => conn,
18320            Err(_) => {
18321                let _ = std::fs::remove_file(&path);
18322                return Err(invalid("bytes do not open as a SQLite database"));
18323            }
18324        };
18325        let outcome = self.apply_sqlite_image(
18326            &img,
18327            &table,
18328            first_fresh_page,
18329            &effective,
18330            row_count,
18331            as_of_commit_seq,
18332            scope_digest,
18333        );
18334        drop(img);
18335        let _ = std::fs::remove_file(&path);
18336        outcome
18337    }
18338
18339    #[allow(clippy::too_many_arguments)]
18340    fn apply_sqlite_image(
18341        &mut self,
18342        img: &rusqlite::Connection,
18343        table: &crate::schema::TableSchema,
18344        first_fresh_page: bool,
18345        effective: &[(String, Vec<String>)],
18346        row_count: i64,
18347        as_of_commit_seq: i64,
18348        scope_digest: &str,
18349    ) -> Result<u32, SectionError> {
18350        let invalid = |detail: String| {
18351            SectionError::Abort(
18352                "sync.invalid_request".to_owned(),
18353                format!("sqlite segment rejected: {detail} (§5.3)"),
18354            )
18355        };
18356
18357        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
18358        //    one row).
18359        type MetaRow = (i64, String, i64, i64, String, i64, i64);
18360        let meta: MetaRow = img
18361            .query_row(
18362                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
18363                        \"scopeDigest\", \"rowCount\",
18364                        (SELECT count(*) FROM _syncular_segment)
18365                 FROM _syncular_segment",
18366                [],
18367                |row| {
18368                    Ok((
18369                        row.get(0)?,
18370                        row.get(1)?,
18371                        row.get(2)?,
18372                        row.get(3)?,
18373                        row.get(4)?,
18374                        row.get(5)?,
18375                        row.get(6)?,
18376                    ))
18377                },
18378            )
18379            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
18380        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
18381        if meta_count != 1 {
18382            return Err(invalid(format!(
18383                "_syncular_segment must contain exactly one row, found {meta_count}"
18384            )));
18385        }
18386        if format != 1 {
18387            return Err(invalid(format!("format {format}")));
18388        }
18389        if meta_table != table.name {
18390            return Err(invalid(format!("image table {meta_table:?}")));
18391        }
18392        if schema_version != i64::from(self.schema.version) {
18393            return Err(invalid(format!("schemaVersion {schema_version}")));
18394        }
18395        if pin != as_of_commit_seq {
18396            return Err(invalid(format!("asOfCommitSeq {pin}")));
18397        }
18398        if digest != scope_digest {
18399            return Err(invalid("scopeDigest mismatch".to_owned()));
18400        }
18401        if meta_rows != row_count {
18402            return Err(invalid(format!("rowCount {meta_rows}")));
18403        }
18404
18405        // 2. Column names and order vs the generated schema (§5.3 rule 3).
18406        let mut names: Vec<String> = Vec::new();
18407        let mut primary_keys: Vec<String> = Vec::new();
18408        {
18409            let mut stmt = img
18410                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
18411                .map_err(|_| invalid("image data table missing".to_owned()))?;
18412            let mut rows = stmt
18413                .query([])
18414                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
18415            while let Some(row) = rows
18416                .next()
18417                .map_err(|_| invalid("image data table unreadable".to_owned()))?
18418            {
18419                if row
18420                    .get::<_, i64>(5)
18421                    .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
18422                    > 0
18423                {
18424                    primary_keys.push(row.get(1).map_err(|error| {
18425                        invalid(Self::sqlite_failure(&self.storage_failure, error))
18426                    })?);
18427                }
18428                names.push(
18429                    row.get::<_, String>(1)
18430                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
18431                );
18432            }
18433        }
18434        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
18435        expected.push("_syncular_version");
18436        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
18437            return Err(SectionError::Abort(
18438                "sync.schema_mismatch".to_owned(),
18439                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
18440            ));
18441        }
18442
18443        if primary_keys != vec![table.primary_key.clone()] {
18444            return Err(invalid(
18445                "image primary key does not match generated schema".into(),
18446            ));
18447        }
18448        let actual_count: i64 = img
18449            .query_row(
18450                &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
18451                [],
18452                |row| row.get(0),
18453            )
18454            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
18455        if actual_count != row_count {
18456            return Err(invalid("image row count does not match descriptor".into()));
18457        }
18458        let insert = self.insert_row_sql(&base_table(&table.name), table);
18459        let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
18460        let column_list = names
18461            .iter()
18462            .map(|n| quote_ident(n))
18463            .collect::<Vec<_>>()
18464            .join(", ");
18465        let mut stmt = img
18466            .prepare(&format!(
18467                "SELECT {column_list} FROM {} ORDER BY {}",
18468                quote_ident(&table.name),
18469                quote_ident(&table.primary_key)
18470            ))
18471            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
18472        let mut rows = stmt
18473            .query([])
18474            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
18475        let mut applied = 0u32;
18476        loop {
18477            let clear = applied == 0 && first_fresh_page;
18478            let was_dirty = self.overlay_dirty.snapshot();
18479            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
18480                && self
18481                    .outbox
18482                    .iter()
18483                    .flat_map(|commit| &commit.ops)
18484                    .any(|op| op.table == table.name);
18485            let mirror_visible = !reconcile_pending
18486                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
18487            // A chunk holds up to 1,024 rows (§5.3).
18488            self.coalesce_unit(1024)
18489                .and_then(|()| self.begin_observation("syncular_image_chunk"))
18490                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18491            let result = (|| {
18492                self.rebuild_overlay_if_dirty()
18493                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18494                let mut changed_keys = Vec::new();
18495                let mut batch = ChangeAccumulator::default();
18496                if clear {
18497                    if self.scoped_rows_exist(&table.name, effective) {
18498                        batch.table(&table.name);
18499                    }
18500                    self.purge_scope_rows(&table.name, effective)
18501                        .map_err(|()| SectionError::FailClosed)?;
18502                }
18503                let mut processed = 0u32;
18504                {
18505                    let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
18506                        invalid(Self::sqlite_failure(&self.storage_failure, error))
18507                    })?;
18508                    let mut mirror = if mirror_visible {
18509                        Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
18510                            invalid(Self::sqlite_failure(&self.storage_failure, error))
18511                        })?)
18512                    } else {
18513                        None
18514                    };
18515                    for _ in 0..1024 {
18516                        let Some(row) = rows.next().map_err(|error| {
18517                            invalid(Self::sqlite_failure(&self.storage_failure, error))
18518                        })?
18519                        else {
18520                            break;
18521                        };
18522                        for (i, column) in table.columns.iter().enumerate() {
18523                            let cell = row.get_ref(i).map_err(|error| {
18524                                invalid(Self::sqlite_failure(&self.storage_failure, error))
18525                            })?;
18526                            let param = image_cell_param(column, cell).map_err(invalid)?;
18527                            ins.raw_bind_parameter(i + 1, &param).map_err(|error| {
18528                                invalid(Self::sqlite_failure(&self.storage_failure, error))
18529                            })?;
18530                            if let Some(mirror) = &mut mirror {
18531                                mirror.raw_bind_parameter(i + 1, &param).map_err(|error| {
18532                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
18533                                })?;
18534                            }
18535                        }
18536                        let version_index = table.columns.len();
18537                        let version: i64 = row.get(version_index).map_err(|error| {
18538                            invalid(Self::sqlite_failure(&self.storage_failure, error))
18539                        })?;
18540                        if version < 1 {
18541                            return Err(invalid("image row version must be positive".into()));
18542                        }
18543                        ins.raw_bind_parameter(version_index + 1, version)
18544                            .map_err(|error| {
18545                                invalid(Self::sqlite_failure(&self.storage_failure, error))
18546                            })?;
18547                        ins.raw_execute().map_err(|error| {
18548                            invalid(Self::sqlite_failure(&self.storage_failure, error))
18549                        })?;
18550                        if let Some(mirror) = &mut mirror {
18551                            mirror
18552                                .raw_bind_parameter(version_index + 1, version)
18553                                .map_err(|error| {
18554                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
18555                                })?;
18556                            mirror.raw_execute().map_err(|error| {
18557                                invalid(Self::sqlite_failure(&self.storage_failure, error))
18558                            })?;
18559                        }
18560                        if reconcile_pending {
18561                            changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
18562                                |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
18563                            )?);
18564                        }
18565                        processed += 1;
18566                    }
18567                }
18568                if processed > 0 {
18569                    batch.table(&table.name);
18570                    self.overlay_dirty.table(&table.name);
18571                }
18572                if reconcile_pending {
18573                    self.reconcile_imported_rows(table, &mut changed_keys)
18574                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18575                }
18576                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
18577                    self.apply_outbox_ops(
18578                        self.outbox
18579                            .iter()
18580                            .flat_map(|commit| &commit.ops)
18581                            .filter(|op| op.table == table.name),
18582                    )
18583                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18584                    self.overlay_dirty.set(false);
18585                } else {
18586                    self.rebuild_overlay_if_dirty()
18587                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18588                }
18589                self.finish_observation("syncular_image_chunk", batch)
18590                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18591                Ok(processed)
18592            })();
18593            let processed = match result {
18594                Ok(processed) => processed,
18595                Err(error) => {
18596                    self.rollback_observation("syncular_image_chunk");
18597                    self.overlay_dirty.restore(was_dirty.clone());
18598                    return Err(error);
18599                }
18600            };
18601            applied += processed;
18602            self.progress
18603                .update(|p| p.rows_processed = u64::from(applied));
18604            std::thread::yield_now();
18605            if i64::from(applied) == actual_count {
18606                break;
18607            }
18608        }
18609        Ok(applied)
18610    }
18611
18612    /// Restore only imported and pending row identities before FIFO replay. All pending
18613    /// identities participate: a server row can free a unique value for a different edit.
18614    /// The caller owns the chunk transaction and publishes its revision after replay.
18615    fn reconcile_imported_rows(
18616        &self,
18617        table: &TableSchema,
18618        keys: &mut Vec<SqlValue>,
18619    ) -> Result<(), String> {
18620        let visible = visible_table(&table.name);
18621        let base = base_table(&table.name);
18622        let pk = quote_ident(&table.primary_key);
18623        let predicate = row_id_predicate(table);
18624        let mut lookup = self.conn.prepare_cached(&format!(
18625            "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
18626        )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18627        let mut deleted = HashSet::new();
18628        for op in self
18629            .outbox
18630            .iter()
18631            .flat_map(|commit| &commit.ops)
18632            .filter(|op| op.table == table.name)
18633        {
18634            if op.upsert {
18635                if let Some(values) = &op.values {
18636                    let key = json_to_column_value(
18637                        &table.columns[table.pk_index],
18638                        values.get(&table.primary_key),
18639                    )?;
18640                    keys.push(owned_sql_value(RowParam::Cell(&key))?);
18641                }
18642            } else if deleted.insert(&op.row_id) {
18643                keys.extend(
18644                    lookup
18645                        .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
18646                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
18647                        .collect::<Result<Vec<_>, _>>()
18648                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
18649                );
18650            }
18651        }
18652        // Delete the complete affected set before restoring any base value, so
18653        // old optimistic unique values cannot collide with the new server rows.
18654        for chunk in keys.chunks(400) {
18655            let holes = vec!["?"; chunk.len()].join(",");
18656            self.conn
18657                .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
18658                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
18659                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18660        }
18661        let updates = table
18662            .columns
18663            .iter()
18664            .map(|column| column.name.as_str())
18665            .chain(std::iter::once("_syncular_version"))
18666            .filter(|name| *name != table.primary_key)
18667            .map(|name| {
18668                let name = quote_ident(name);
18669                format!("{name}=excluded.{name}")
18670            })
18671            .collect::<Vec<_>>()
18672            .join(",");
18673        for chunk in keys.chunks(400) {
18674            let holes = vec!["?"; chunk.len()].join(",");
18675            self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
18676                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
18677                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18678        }
18679        Ok(())
18680    }
18681
18682    // -- application-authorized local purge ---------------------------------------
18683
18684    fn compile_local_data_purge(
18685        &self,
18686        input: &LocalDataPurgeInput,
18687    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
18688        let invalid = |message: String| format!("sync.invalid_request: {message}");
18689        if input.purge_id.is_empty()
18690            || input.purge_id.len() > 128
18691            || !is_local_operation_code_like(&input.purge_id)
18692        {
18693            return Err(invalid(
18694                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
18695            ));
18696        }
18697        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
18698            return Err(invalid(format!(
18699                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
18700            )));
18701        }
18702
18703        let mut deduplicated: BTreeMap<
18704            String,
18705            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
18706        > = BTreeMap::new();
18707        for target in &input.targets {
18708            let table = self.schema.table(&target.table).ok_or_else(|| {
18709                invalid(format!(
18710                    "local purge names unknown table {:?}",
18711                    target.table
18712                ))
18713            })?;
18714            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
18715                return Err(invalid(format!(
18716                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
18717                    target.table
18718                )));
18719            }
18720            let mut selectors = Vec::with_capacity(target.selectors.len());
18721            let mut canonical_selectors = BTreeMap::new();
18722            for (column_name, raw_values) in &target.selectors {
18723                let Some((column_index, column)) = table
18724                    .columns
18725                    .iter()
18726                    .enumerate()
18727                    .find(|(_, column)| column.name == *column_name)
18728                else {
18729                    return Err(invalid(format!(
18730                        "local purge target {:?} names unknown column {:?}",
18731                        target.table, column_name
18732                    )));
18733                };
18734                let encrypted = table
18735                    .encrypted_columns
18736                    .iter()
18737                    .any(|candidate| candidate.index == column_index);
18738                if column.ty != ColumnType::String || encrypted {
18739                    return Err(invalid(format!(
18740                        "local purge selector {:?}.{:?} must be a plaintext string column",
18741                        target.table, column_name
18742                    )));
18743                }
18744                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
18745                    return Err(invalid(format!(
18746                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
18747                        target.table, column_name
18748                    )));
18749                }
18750                let mut values = raw_values.clone();
18751                values.sort();
18752                values.dedup();
18753                if values.iter().any(|value| {
18754                    value.is_empty()
18755                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
18756                        || !is_local_operation_code_like(value)
18757                }) {
18758                    return Err(invalid(format!(
18759                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
18760                    )));
18761                }
18762                selectors.push((column_name.clone(), values.clone()));
18763                canonical_selectors.insert(column_name.clone(), values);
18764            }
18765            let canonical = LocalDataPurgeTarget {
18766                table: target.table.clone(),
18767                selectors: canonical_selectors,
18768            };
18769            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
18770            deduplicated.insert(
18771                key,
18772                (
18773                    CompiledLocalDataPurgeTarget {
18774                        table: target.table.clone(),
18775                        selectors,
18776                    },
18777                    canonical,
18778                ),
18779            );
18780        }
18781        let targets = deduplicated
18782            .values()
18783            .map(|(compiled, _)| compiled.clone())
18784            .collect::<Vec<_>>();
18785        let canonical = deduplicated
18786            .values()
18787            .map(|(_, target)| target.clone())
18788            .collect::<Vec<_>>();
18789        let canonical_plan =
18790            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
18791        Ok((targets, canonical_plan))
18792    }
18793
18794    fn local_purge_base_row_ids(
18795        &self,
18796        targets: &[CompiledLocalDataPurgeTarget],
18797    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
18798        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
18799        for target in targets {
18800            let table = self
18801                .schema
18802                .table(&target.table)
18803                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
18804            let mut clauses = Vec::with_capacity(target.selectors.len());
18805            let mut params = Vec::new();
18806            for (column, values) in &target.selectors {
18807                clauses.push(format!(
18808                    "{} IN ({})",
18809                    quote_ident(column),
18810                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
18811                ));
18812                params.extend(values.iter().cloned().map(SqlValue::Text));
18813            }
18814            let sql = format!(
18815                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
18816                quote_ident(&table.primary_key),
18817                base_table(&target.table),
18818                clauses.join(" AND ")
18819            );
18820            let mut statement = self
18821                .conn
18822                .prepare(&sql)
18823                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18824            let rows = statement
18825                .query_map(rusqlite::params_from_iter(params), |row| {
18826                    let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
18827                    render_row_id_json(Some(&value))
18828                        .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
18829                })
18830                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18831            let ids = by_table.entry(target.table.clone()).or_default();
18832            for row in rows {
18833                ids.insert(
18834                    row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
18835                );
18836            }
18837        }
18838        Ok(by_table)
18839    }
18840
18841    fn local_purge_values_match(
18842        target: &CompiledLocalDataPurgeTarget,
18843        values: &Map<String, Value>,
18844    ) -> bool {
18845        target.selectors.iter().all(|(column, allowed)| {
18846            values
18847                .get(column)
18848                .and_then(Value::as_str)
18849                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
18850        })
18851    }
18852
18853    /// Apply one host-authorized local security purge. The host MUST gate the
18854    /// corresponding subscriptions first; this primitive owns local SQLite
18855    /// cleanup and never grants/revokes server authority by itself.
18856    pub fn purge_local_data(
18857        &mut self,
18858        input: &LocalDataPurgeInput,
18859    ) -> Result<LocalDataPurgeResult, String> {
18860        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
18861        let meta_key = format!("localPurge:{}", input.purge_id);
18862        let applied_plan = self
18863            .conn
18864            .query_row(
18865                "SELECT value FROM _syncular_meta WHERE key = ?1",
18866                rusqlite::params![meta_key],
18867                |row| row.get::<_, String>(0),
18868            )
18869            .optional()
18870            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18871        if let Some(applied_plan) = applied_plan {
18872            if applied_plan != canonical_plan {
18873                return Err(format!(
18874                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
18875                    input.purge_id
18876                ));
18877            }
18878            return Ok(LocalDataPurgeResult {
18879                already_applied: true,
18880                purged_rows: 0,
18881                dropped_commits: 0,
18882            });
18883        }
18884
18885        // RFC 0005 D7: the container file cannot be a purge target (its name is
18886        // not in the running schema), so purge drops it as a fixed step, before
18887        // any mirror row is touched and unconditionally.
18888        let _ = self.drop_previous_version();
18889
18890        let prior_outbox = self.outbox.clone();
18891        let prior_failed = self.failed_commits.clone();
18892        let prior_rejections = self.rejections.clone();
18893        let prior_conflicts = self.conflicts.clone();
18894        let prior_overlay_dirty = self.overlay_dirty.snapshot();
18895        self.begin_observation("syncular_local_purge")?;
18896        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
18897            let row_ids = self.local_purge_base_row_ids(&targets)?;
18898            let retained_dropped = self.drop_failed_commits(|table, values| {
18899                targets.iter().any(|target| {
18900                    target.table == table && Self::local_purge_values_match(target, values)
18901                })
18902            })?;
18903            let doomed = self
18904                .outbox
18905                .iter()
18906                .filter(|commit| {
18907                    commit.ops.iter().any(|operation| {
18908                        let matching_targets = targets
18909                            .iter()
18910                            .filter(|target| target.table == operation.table)
18911                            .collect::<Vec<_>>();
18912                        if matching_targets.is_empty() {
18913                            return false;
18914                        }
18915                        if row_ids
18916                            .get(&operation.table)
18917                            .is_some_and(|ids| ids.contains(&operation.row_id))
18918                        {
18919                            return true;
18920                        }
18921                        operation.values.as_ref().is_some_and(|values| {
18922                            matching_targets
18923                                .iter()
18924                                .any(|target| Self::local_purge_values_match(target, values))
18925                        })
18926                    })
18927                })
18928                .cloned()
18929                .collect::<Vec<_>>();
18930            let doomed_ids = doomed
18931                .iter()
18932                .map(|commit| commit.client_commit_id.clone())
18933                .collect::<BTreeSet<_>>();
18934            let mut batch = ChangeAccumulator::default();
18935            for commit in &retained_dropped {
18936                batch.conflicts = true;
18937                batch.rejections = true;
18938                batch.outcomes = true;
18939                for operation in &commit.ops {
18940                    batch.table(&operation.table);
18941                }
18942            }
18943            let mut rejections = Vec::new();
18944            for commit in &doomed {
18945                for operation in &commit.ops {
18946                    batch.table(&operation.table);
18947                }
18948                let results = commit
18949                    .ops
18950                    .iter()
18951                    .enumerate()
18952                    .map(|(op_index, operation)| {
18953                        let rejection = RejectionRecord {
18954                            client_commit_id: commit.client_commit_id.clone(),
18955                            op_index: op_index as i32,
18956                            code: "client.local_data_purged".to_owned(),
18957                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
18958                            retryable: false,
18959                            details: None,
18960                            operation: Some(CommitOperation::from(operation)),
18961                        };
18962                        rejections.push(rejection.clone());
18963                        CommitOperationOutcome::Error { rejection }
18964                    })
18965                    .collect::<Vec<_>>();
18966                self.persist_commit_outcome(
18967                    &commit.client_commit_id,
18968                    CommitOutcomeStatus::Rejected,
18969                    &results,
18970                    Some(&commit.ops),
18971                )?;
18972                self.delete_outbox_persisted(&commit.client_commit_id)?;
18973            }
18974            if !doomed.is_empty() {
18975                self.outbox
18976                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
18977                self.rejections.extend(rejections);
18978                self.prune_commit_outcomes()?;
18979                batch.status = true;
18980                batch.rejections = true;
18981                batch.outcomes = true;
18982            }
18983
18984            let mut purged_rows = 0usize;
18985            for (table, ids) in &row_ids {
18986                if ids.is_empty() {
18987                    continue;
18988                }
18989                batch.table(table);
18990                for row_id in ids {
18991                    self.delete_base_row(table, row_id)?;
18992                    purged_rows += 1;
18993                }
18994            }
18995            self.rebuild_overlay_if_dirty()?;
18996            self.delete_unreferenced_cached_blobs()?;
18997            self.conn
18998                .execute(
18999                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
19000                    rusqlite::params![meta_key, canonical_plan],
19001                )
19002                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19003            Ok((
19004                batch,
19005                LocalDataPurgeResult {
19006                    already_applied: false,
19007                    purged_rows,
19008                    dropped_commits: doomed.len(),
19009                },
19010            ))
19011        })();
19012        let (batch, result) = match applied {
19013            Ok(value) => value,
19014            Err(error) => {
19015                self.rollback_observation("syncular_local_purge");
19016                self.outbox = prior_outbox;
19017                self.failed_commits = prior_failed;
19018                self.rejections = prior_rejections;
19019                self.conflicts = prior_conflicts;
19020                self.overlay_dirty.restore(prior_overlay_dirty.clone());
19021                return Err(error);
19022            }
19023        };
19024        if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
19025            self.rollback_observation("syncular_local_purge");
19026            self.outbox = prior_outbox;
19027            self.failed_commits = prior_failed;
19028            self.rejections = prior_rejections;
19029            self.conflicts = prior_conflicts;
19030            self.overlay_dirty.restore(prior_overlay_dirty.clone());
19031            return Err(error);
19032        }
19033        self.cancel_sync_round();
19034        Ok(result)
19035    }
19036
19037    /// Application-authorized recovery of the replicated projection. Unlike a
19038    /// local security purge, this retains the entire outbox, device identity,
19039    /// lease, outcomes, subscription registrations, and protected bookkeeping.
19040    /// The projection reset, subscription rewind, optimistic replay, and
19041    /// idempotency marker share one savepoint for interruption safety.
19042    pub fn rebootstrap_local_data(
19043        &mut self,
19044        input: &LocalDataRebootstrapInput,
19045    ) -> Result<LocalDataRebootstrapResult, String> {
19046        if input.rebootstrap_id.is_empty()
19047            || input.rebootstrap_id.len() > 128
19048            || !is_local_operation_code_like(&input.rebootstrap_id)
19049        {
19050            return Err(
19051                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
19052                    .to_owned(),
19053            );
19054        }
19055        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
19056        if let Some(persisted) = self.get_meta_strict(&meta_key)? {
19057            let (retained_commits, reset_subscriptions) =
19058                decode_local_rebootstrap_receipt(&persisted)?;
19059            return Ok(LocalDataRebootstrapResult {
19060                already_applied: true,
19061                retained_commits,
19062                reset_subscriptions,
19063            });
19064        }
19065        if self.stopped || self.schema_floor.is_some() {
19066            return Err(
19067                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
19068                    .to_owned(),
19069            );
19070        }
19071
19072        let retained_commits = self.outbox.len();
19073        let reset_subscriptions = self.subs.len();
19074        let prior_subs = self.subs.clone();
19075        let prior_upgrading = self.upgrading;
19076        let prior_stopped = self.stopped;
19077        let prior_schema_floor = self.schema_floor.clone();
19078        let prior_overlay_dirty = self.overlay_dirty.snapshot();
19079        let prior_sync_needed = self.sync_needed;
19080        let prior_sync_intents = self.sync_intent_queue.clone();
19081        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
19082
19083        let prior_active_round = self.active_round;
19084        self.begin_observation("syncular_local_rebootstrap")?;
19085        let mut batch = ChangeAccumulator::default();
19086        let applied = (|| -> Result<(), String> {
19087            self.run_schema_reset_observed(&mut batch, false)?;
19088            self.conn
19089                .execute(
19090                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
19091                    rusqlite::params![meta_key, receipt],
19092                )
19093                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19094            self.sync_needed = true;
19095            self.sync_intent_queue.push_back(SyncIntent::Interactive);
19096            batch.status = true;
19097            Ok(())
19098        })();
19099        if let Err(error) = applied {
19100            self.rollback_observation("syncular_local_rebootstrap");
19101            self.active_round = prior_active_round;
19102            self.subs = prior_subs;
19103            self.upgrading = prior_upgrading;
19104            self.stopped = prior_stopped;
19105            self.schema_floor = prior_schema_floor;
19106            self.overlay_dirty.restore(prior_overlay_dirty.clone());
19107            self.sync_needed = prior_sync_needed;
19108            self.sync_intent_queue = prior_sync_intents;
19109            return Err(error);
19110        }
19111        if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
19112            self.rollback_observation("syncular_local_rebootstrap");
19113            self.active_round = prior_active_round;
19114            self.subs = prior_subs;
19115            self.upgrading = prior_upgrading;
19116            self.stopped = prior_stopped;
19117            self.schema_floor = prior_schema_floor;
19118            self.overlay_dirty.restore(prior_overlay_dirty.clone());
19119            self.sync_needed = prior_sync_needed;
19120            self.sync_intent_queue = prior_sync_intents;
19121            return Err(error);
19122        }
19123
19124        Ok(LocalDataRebootstrapResult {
19125            already_applied: false,
19126            retained_commits,
19127            reset_subscriptions,
19128        })
19129    }
19130
19131    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
19132
19133    /// Delete base rows matching the effective scopes; `Err(())` = no local
19134    /// scope-column mapping for a key (the fail-closed case).
19135    fn purge_scope_rows(
19136        &mut self,
19137        table_name: &str,
19138        effective: &[(String, Vec<String>)],
19139    ) -> Result<(), ()> {
19140        if effective.is_empty() {
19141            return Ok(());
19142        }
19143        let table = self.schema.table(table_name).ok_or(())?.clone();
19144        let mut clauses = Vec::new();
19145        let mut params: Vec<SqlValue> = Vec::new();
19146        for (variable, values) in effective {
19147            let column = table.scope_column(variable).ok_or(())?;
19148            let placeholders: Vec<String> = values
19149                .iter()
19150                .map(|v| {
19151                    params.push(SqlValue::Text(v.clone()));
19152                    "?".to_owned()
19153                })
19154                .collect();
19155            clauses.push(format!(
19156                "{} IN ({})",
19157                quote_ident(column),
19158                placeholders.join(", ")
19159            ));
19160        }
19161        let sql = format!(
19162            "DELETE FROM {} WHERE {}",
19163            base_table(table_name),
19164            clauses.join(" AND ")
19165        );
19166        self.overlay_dirty.table(table_name);
19167        self.conn
19168            .execute(&sql, rusqlite::params_from_iter(&params))
19169            .map_err(|_| ())?;
19170        self.conn
19171            .execute(
19172                &format!(
19173                    "DELETE FROM {} WHERE {}",
19174                    visible_table(table_name),
19175                    clauses.join(" AND ")
19176                ),
19177                rusqlite::params_from_iter(&params),
19178            )
19179            .map_err(|_| ())?;
19180        Ok(())
19181    }
19182
19183    /// §3.3: drop pending commits whose upserts provably land in the
19184    /// revoked effective scopes — whole-commit, never per-operation.
19185    fn drop_doomed_outbox(
19186        &mut self,
19187        table_name: &str,
19188        effective: &[(String, Vec<String>)],
19189    ) -> Result<bool, String> {
19190        if effective.is_empty() {
19191            return Ok(false);
19192        }
19193        let Some(table) = self.schema.table(table_name).cloned() else {
19194            return Ok(false);
19195        };
19196        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
19197        for (variable, values) in effective {
19198            match table.scope_column(variable) {
19199                Some(column) => mappings.push((column, values)),
19200                None => return Ok(false), // not provable without a mapping
19201            }
19202        }
19203        let doomed: Vec<OutboxCommit> = self
19204            .outbox
19205            .iter()
19206            .filter(|commit| {
19207                commit.ops.iter().any(|op| {
19208                    op.upsert
19209                        && op.table == table_name
19210                        && op.values.as_ref().is_some_and(|values| {
19211                            mappings
19212                                .iter()
19213                                .all(|(column, allowed)| match values.get(*column) {
19214                                    Some(Value::String(s)) => allowed.contains(s),
19215                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
19216                                    Some(Value::Bool(b)) => allowed.contains(&b.to_string()),
19217                                    _ => false,
19218                                })
19219                        })
19220                })
19221            })
19222            .cloned()
19223            .collect();
19224        if doomed.is_empty() {
19225            return Ok(false);
19226        }
19227        let mut rejections = Vec::new();
19228        for commit in &doomed {
19229            let results = commit
19230                .ops
19231                .iter()
19232                .enumerate()
19233                .map(|(op_index, operation)| {
19234                    let rejection = RejectionRecord {
19235                        client_commit_id: commit.client_commit_id.clone(),
19236                        op_index: op_index as i32,
19237                        code: "sync.scope_revoked".to_owned(),
19238                        message: "the commit was dropped because its effective scope was revoked"
19239                            .to_owned(),
19240                        retryable: false,
19241                        details: None,
19242                        operation: Some(CommitOperation::from(operation)),
19243                    };
19244                    rejections.push(rejection.clone());
19245                    CommitOperationOutcome::Error { rejection }
19246                })
19247                .collect::<Vec<_>>();
19248            self.persist_commit_outcome(
19249                &commit.client_commit_id,
19250                CommitOutcomeStatus::Rejected,
19251                &results,
19252                Some(&commit.ops),
19253            )?;
19254            self.delete_outbox_persisted(&commit.client_commit_id)?;
19255        }
19256        self.prune_commit_outcomes()?;
19257        let doomed_ids = doomed
19258            .iter()
19259            .map(|commit| commit.client_commit_id.as_str())
19260            .collect::<BTreeSet<_>>();
19261        self.outbox
19262            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
19263        self.rejections.extend(rejections);
19264        Ok(true)
19265    }
19266
19267    // -- blobs (§5.9) ----------------------------------------------------------------
19268
19269    /// §5.9.7: hash bytes into the content address, cache them, queue the
19270    /// upload (flushed before the next push, B4). Returns the canonical
19271    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
19272    /// column value.
19273    pub fn upload_blob(
19274        &mut self,
19275        bytes: &[u8],
19276        media_type: Option<String>,
19277        name: Option<String>,
19278    ) -> Result<Value, String> {
19279        let blob_id = blob_id_for(bytes);
19280        let now = self.clock_now_ms();
19281        let tx = self
19282            .conn
19283            .transaction()
19284            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19285        tx.execute(
19286            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
19287             ON CONFLICT(blob_id) DO NOTHING",
19288            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
19289        )
19290        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19291        tx.execute(
19292            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
19293            rusqlite::params![blob_id, media_type, now],
19294        )
19295        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19296        tx.commit()
19297            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19298        self.enforce_blob_cache_cap();
19299        let mut obj = Map::new();
19300        obj.insert("blobId".to_owned(), Value::from(blob_id));
19301        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
19302        if let Some(mt) = media_type {
19303            obj.insert("mediaType".to_owned(), Value::from(mt));
19304        }
19305        if let Some(n) = name {
19306            obj.insert("name".to_owned(), Value::from(n));
19307        }
19308        Ok(Value::Object(obj))
19309    }
19310
19311    /// §5.9.7: resolve an owned blob body without driver encoding. A
19312    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
19313    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
19314    pub fn fetch_blob_bytes(
19315        &mut self,
19316        transport: &mut dyn Transport,
19317        blob_id_or_ref: &str,
19318    ) -> Result<FetchedBlob, (String, String)> {
19319        let simple = |m: String| ("client.failed".to_owned(), m);
19320        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
19321            blob_id_or_ref.to_owned()
19322        } else {
19323            let value: Value = serde_json::from_str(blob_id_or_ref)
19324                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
19325            value
19326                .get("blobId")
19327                .and_then(Value::as_str)
19328                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
19329                .to_owned()
19330        };
19331        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
19332            return Ok(cached);
19333        }
19334        if !self.transport_enabled {
19335            return Err(("sync.offline".into(), "transport gate is closed".into()));
19336        }
19337        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
19338        // blob.not_found) verbatim so the harness can assert on it. The
19339        // authorized endpoint serves bytes inline OR (always-issue, presign
19340        // configured) a signed url the client fetches directly — no host auth,
19341        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
19342        #[cfg(feature = "bench-internals")]
19343        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
19344        let bytes = match transport
19345            .blob_download(&blob_id)
19346            .map_err(|e| (e.code, e.message))?
19347        {
19348            BlobDownload::Bytes(bytes) => bytes,
19349            BlobDownload::Url {
19350                url,
19351                url_expires_at_ms,
19352            } => {
19353                // §5.9.5: MUST NOT start a fetch at/past expiry.
19354                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
19355                    return Err((
19356                        "sync.segment_expired".to_owned(),
19357                        format!(
19358                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
19359                        ),
19360                    ));
19361                }
19362                transport
19363                    .fetch_blob_url(&url)
19364                    .map_err(|e| (e.code, e.message))?
19365            }
19366        };
19367        #[cfg(feature = "bench-internals")]
19368        drop(download_phase);
19369        #[cfg(feature = "bench-internals")]
19370        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
19371        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
19372        if blob_id_for(&bytes) != blob_id {
19373            return Err(simple(format!(
19374                "blob content address mismatch for {blob_id}"
19375            )));
19376        }
19377        #[cfg(feature = "bench-internals")]
19378        drop(validate_phase);
19379        #[cfg(feature = "bench-internals")]
19380        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
19381        let now = self.clock_now_ms();
19382        self.conn
19383            .execute(
19384                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
19385                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
19386            )
19387            .map_err(|e| simple(e.to_string()))?;
19388        self.conn
19389            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
19390            .map_err(|e| simple(e.to_string()))?;
19391        self.enforce_blob_cache_cap();
19392        #[cfg(feature = "bench-internals")]
19393        drop(insert_phase);
19394        self.get_cached_blob_bytes(&blob_id)
19395            .map_err(simple)?
19396            .ok_or_else(|| simple("blob cache write failed".to_owned()))
19397    }
19398
19399    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
19400        #[cfg(feature = "bench-internals")]
19401        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
19402        let mut stmt = self
19403            .conn
19404            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
19405            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19406        let mut rows = stmt
19407            .query(rusqlite::params![blob_id])
19408            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19409        if let Some(row) = rows
19410            .next()
19411            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
19412        {
19413            let bytes: Vec<u8> = row
19414                .get(0)
19415                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19416            let byte_length: i64 = row
19417                .get(1)
19418                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19419            let media_type: Option<String> = row
19420                .get(2)
19421                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19422            return Ok(Some(FetchedBlob {
19423                blob_id: blob_id.to_owned(),
19424                byte_length,
19425                bytes,
19426                media_type,
19427            }));
19428        }
19429        Ok(None)
19430    }
19431
19432    /// §5.9.7 B4: upload every queued blob before push.
19433    fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
19434        let pending: Vec<(String, Option<String>)> = {
19435            let mut stmt = self
19436                .conn
19437                .prepare(
19438                    "SELECT blob_id, media_type FROM (
19439                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
19440                      UNION ALL
19441                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
19442                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
19443                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
19444                    ) ORDER BY created_at_ms, blob_id",
19445                )
19446                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19447            let rows = stmt
19448                .query_map([], |row| {
19449                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
19450                })
19451                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19452            rows.collect::<Result<_, _>>()
19453                .map_err(|error| match error {
19454                    rusqlite::Error::InvalidColumnType(..)
19455                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
19456                        "sync.local_corrupt",
19457                        "Pending blob upload metadata is invalid",
19458                    ),
19459                    _ => TransportError::new("client.failed", error.to_string()),
19460                })?
19461        };
19462        let mut uploads = Vec::new();
19463        for (blob_id, media_type) in pending {
19464            let bytes = (|| {
19465                let stored: Option<(SqlValue, SqlValue)> = self
19466                    .conn
19467                    .query_row(
19468                        "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
19469                        rusqlite::params![blob_id],
19470                        |row| Ok((row.get(0)?, row.get(1)?)),
19471                    )
19472                    .optional()
19473                    .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19474                let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
19475                    return Err(TransportError::new(
19476                        "sync.local_corrupt",
19477                        "Pending blob upload body is missing or corrupt",
19478                    ));
19479                };
19480                if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
19481                    return Err(TransportError::new(
19482                        "sync.local_corrupt",
19483                        "Pending blob upload body is missing or corrupt",
19484                    ));
19485                }
19486                Ok(bytes)
19487            })();
19488            let failed = bytes.is_err();
19489            uploads.push(crate::round::PendingUpload {
19490                id: blob_id,
19491                bytes,
19492                media_type,
19493            });
19494            if failed {
19495                break;
19496            }
19497        }
19498        Ok(uploads)
19499    }
19500
19501    #[cfg(test)]
19502    fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
19503        for upload in self.pending_blob_uploads()? {
19504            self.upload_one(
19505                transport,
19506                &upload.id,
19507                &upload.bytes?,
19508                upload.media_type.as_deref(),
19509            )?;
19510            self.conn
19511                .execute(
19512                    "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
19513                    rusqlite::params![upload.id],
19514                )
19515                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
19516        }
19517        Ok(())
19518    }
19519
19520    /// §5.9.3: upload one blob, preferring the presigned direct-to-storage
19521    /// grant when the transport supports it, else streaming through the direct
19522    /// host-authenticated endpoint (capability, not fallback). A `Url` grant
19523    /// PUTs direct with no host auth; on a grant PUT failure the client streams
19524    /// through the direct endpoint — a *different, host-authenticated
19525    /// capability*, not a fall-through of the grant's authority.
19526    #[cfg(test)]
19527    fn upload_one(
19528        &self,
19529        transport: &mut dyn Transport,
19530        blob_id: &str,
19531        bytes: &[u8],
19532        media_type: Option<&str>,
19533    ) -> Result<(), TransportError> {
19534        crate::round::upload_one(transport, blob_id, bytes, media_type, self.now_ms)
19535    }
19536
19537    fn visible_blob_ids_sql(&self) -> String {
19538        let mut selects = Vec::new();
19539        for table in &self.schema.tables {
19540            for column in table
19541                .columns
19542                .iter()
19543                .filter(|column| column.ty == ColumnType::BlobRef)
19544            {
19545                let identifier = quote_ident(&column.name);
19546                selects.push(format!(
19547                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
19548                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
19549                     AND json_type({identifier}, '$.blobId') = 'text'",
19550                    quote_ident(&table.name),
19551                ));
19552            }
19553        }
19554        if selects.is_empty() {
19555            "SELECT NULL AS blob_id WHERE 0".to_owned()
19556        } else {
19557            selects.join(" UNION ")
19558        }
19559    }
19560
19561    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
19562    /// staged uploads pin bodies directly; no derived refcount is stored.
19563    fn enforce_blob_cache_cap(&self) {
19564        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
19565            return;
19566        };
19567        let mut total: i64 = self
19568            .conn
19569            .query_row(
19570                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
19571                [],
19572                |row| row.get(0),
19573            )
19574            .unwrap_or(0);
19575        if total <= max_bytes {
19576            return;
19577        }
19578        let query = format!(
19579            "SELECT blob_id, byte_length FROM _syncular_blobs
19580             WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
19581               AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
19582               AND blob_id NOT IN ({})
19583             ORDER BY created_at_ms ASC, blob_id ASC",
19584            self.visible_blob_ids_sql(),
19585        );
19586        let candidates: Vec<(String, i64)> = {
19587            let Ok(mut stmt) = self.conn.prepare(&query) else {
19588                return;
19589            };
19590            let Ok(rows) = stmt.query_map([], |row| {
19591                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
19592            }) else {
19593                return;
19594            };
19595            rows.filter_map(Result::ok).collect()
19596        };
19597        for (blob_id, byte_length) in candidates {
19598            if total <= max_bytes {
19599                break;
19600            }
19601            let _ = self.conn.execute(
19602                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
19603                rusqlite::params![blob_id],
19604            );
19605            total -= byte_length;
19606        }
19607    }
19608
19609    fn delete_unreferenced_cached_blobs(&mut self) -> Result<(), String> {
19610        #[cfg(feature = "bench-internals")]
19611        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
19612        if !self.schema_has_blobs() {
19613            return Ok(());
19614        }
19615        self.rebuild_overlay_if_dirty()?;
19616        self.conn
19617            .execute(
19618                &format!(
19619                    "DELETE FROM _syncular_blobs
19620                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
19621                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
19622                   AND blob_id NOT IN ({})",
19623                    self.visible_blob_ids_sql(),
19624                ),
19625                [],
19626            )
19627            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19628        Ok(())
19629    }
19630
19631    // -- local row storage ----------------------------------------------------------
19632
19633    /// The cached per-table primary-key upsert SQL for `full_table` (see
19634    /// the `insert_sql` field: built once, reused per row).
19635    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
19636        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
19637            return sql.clone();
19638        }
19639        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
19640        columns.push(quote_ident("_syncular_version"));
19641        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
19642        let primary_key = quote_ident(&table.primary_key);
19643        let updates = columns
19644            .iter()
19645            .filter(|column| **column != primary_key)
19646            .map(|column| format!("{column}=excluded.{column}"))
19647            .collect::<Vec<_>>()
19648            .join(", ");
19649        let sql = format!(
19650            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
19651            columns.join(", "),
19652            placeholders.join(", ")
19653        );
19654        self.insert_sql
19655            .borrow_mut()
19656            .insert(full_table.to_owned(), sql.clone());
19657        sql
19658    }
19659
19660    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
19661        self.overlay_dirty.table(table_name);
19662        self.write_row(&base_table(table_name), table_name, row, version)
19663    }
19664
19665    fn write_row(
19666        &self,
19667        full_table: &str,
19668        table_name: &str,
19669        row: &Row,
19670        version: i64,
19671    ) -> Result<(), String> {
19672        #[cfg(feature = "bench-internals")]
19673        let _phase = self.benchmark_phases.start(Phase::RowWrite);
19674        let table = self
19675            .schema
19676            .table(table_name)
19677            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
19678        let sql = self.insert_row_sql(full_table, table);
19679        let mut stmt = self
19680            .conn
19681            .prepare_cached(&sql)
19682            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19683        let params = row
19684            .iter()
19685            .map(RowParam::Cell)
19686            .chain(std::iter::once(RowParam::Version(version)));
19687        stmt.execute(rusqlite::params_from_iter(params))
19688            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19689        Ok(())
19690    }
19691
19692    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
19693        let table = self
19694            .schema
19695            .table(table_name)
19696            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
19697        self.overlay_dirty.table(table_name);
19698        let sql = format!(
19699            "DELETE FROM {} WHERE {}",
19700            base_table(table_name),
19701            row_id_predicate(table)
19702        );
19703        let mut stmt = self
19704            .conn
19705            .prepare_cached(&sql)
19706            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19707        stmt.execute(rusqlite::params![row_id])
19708            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19709        Ok(())
19710    }
19711
19712    fn has_acknowledged_rows(&self) -> bool {
19713        self.conn
19714            .query_row(
19715                "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
19716                [],
19717                |row| row.get(0),
19718            )
19719            .expect("read acknowledged intent")
19720    }
19721
19722    fn acknowledged_operations(
19723        &self,
19724        rows: Option<&HashSet<(&str, &str)>>,
19725        tables: Option<&BTreeSet<&str>>,
19726    ) -> Result<Vec<OutboxOp>, String> {
19727        let keys = rows.map(|rows| {
19728            serde_json::to_string(
19729                &rows
19730                    .iter()
19731                    .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
19732                    .collect::<Vec<_>>(),
19733            )
19734            .expect("row keys")
19735        });
19736        let mut statement = self
19737            .conn
19738            .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")
19739            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19740        let rows = statement
19741            .query_map(
19742                rusqlite::params![
19743                    keys,
19744                    tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
19745                ],
19746                |row| {
19747                    #[cfg(test)]
19748                    self.acknowledged_replay_count
19749                        .set(self.acknowledged_replay_count.get() + 1);
19750                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
19751                },
19752            )
19753            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19754        rows.map(|row| {
19755            let (op, intent) =
19756                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19757            let op: CommitOperation =
19758                serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
19759            let table = self
19760                .schema
19761                .table(&op.table)
19762                .ok_or_else(|| "sync.unknown_table".to_owned())?;
19763            let values = if op.op == "upsert" && self.visible_row(table, &op.row_id)?.is_none() {
19764                Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
19765            } else {
19766                op.values
19767            };
19768            Ok(OutboxOp {
19769                table: op.table,
19770                row_id: op.row_id,
19771                upsert: op.op == "upsert",
19772                base_version: op.base_version,
19773                values,
19774            })
19775        })
19776        .collect()
19777    }
19778
19779    fn drop_acknowledged_rows(
19780        &self,
19781        matches: impl Fn(&str, &Map<String, Value>) -> bool,
19782    ) -> Result<Vec<OutboxCommit>, String> {
19783        let mut statement = self
19784            .conn
19785            .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
19786            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19787        let rows = statement
19788            .query_map([], |row| {
19789                Ok((
19790                    row.get::<_, String>(0)?,
19791                    row.get::<_, String>(1)?,
19792                    row.get::<_, String>(2)?,
19793                    row.get::<_, String>(3)?,
19794                ))
19795            })
19796            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19797        let mut doomed = HashSet::new();
19798        for row in rows {
19799            let (id, table, intent, row_id) =
19800                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19801            let values =
19802                serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
19803            let base_matches = self
19804                .schema
19805                .table(&table)
19806                .map(|schema| self.stored_row(schema, &row_id, true))
19807                .transpose()?
19808                .flatten()
19809                .is_some_and(|(base, _)| {
19810                    let schema = self.schema.table(&table).expect("acknowledged table");
19811                    let values = schema
19812                        .columns
19813                        .iter()
19814                        .zip(base.iter())
19815                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
19816                        .collect();
19817                    matches(&table, &values)
19818                });
19819            if matches(&table, &values) || base_matches {
19820                doomed.insert(id);
19821            }
19822        }
19823        let mut dropped = Vec::new();
19824        for id in doomed {
19825            let mut statement = self
19826                .conn
19827                .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
19828                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19829            let operations = statement
19830                .query_map([&id], |row| row.get::<_, String>(0))
19831                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19832            let mut ops = Vec::new();
19833            for operation in operations {
19834                let json = operation
19835                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19836                let op: CommitOperation =
19837                    serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
19838                self.overlay_dirty.table(&op.table);
19839                ops.push(OutboxOp {
19840                    table: op.table,
19841                    row_id: op.row_id,
19842                    upsert: op.op == "upsert",
19843                    base_version: op.base_version,
19844                    values: op.values,
19845                });
19846            }
19847            dropped.push(OutboxCommit {
19848                client_commit_id: id.clone(),
19849                ops,
19850            });
19851            self.conn
19852                .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
19853                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19854        }
19855        Ok(dropped)
19856    }
19857
19858    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
19859    /// the outbox changed since the last rebuild (the no-op sync round).
19860    fn rebuild_overlay_if_dirty(&mut self) -> Result<(), String> {
19861        if self.overlay_dirty.get() {
19862            self.rebuild_overlay()?;
19863        }
19864        Ok(())
19865    }
19866
19867    /// §7.1: local reads see outbox state applied optimistically — rebuild
19868    /// every visible table as (base server state) + (pending outbox replay
19869    /// on top). Optimistic rows carry version `-1`.
19870    fn rebuild_overlay(&mut self) -> Result<(), String> {
19871        #[cfg(feature = "bench-internals")]
19872        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
19873        #[cfg(test)]
19874        self.overlay_rebuild_count
19875            .set(self.overlay_rebuild_count.get() + 1);
19876        let dirty = self.overlay_dirty.snapshot();
19877        let tables: Vec<_> = self
19878            .schema
19879            .tables
19880            .iter()
19881            .filter(|table| {
19882                dirty
19883                    .as_ref()
19884                    .is_none_or(|names| names.is_empty() || names.contains(&table.name))
19885            })
19886            .cloned()
19887            .collect();
19888        let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
19889        self.begin_observation("syncular_overlay")?;
19890        let rebuilt = (|| {
19891            for table in &tables {
19892                for index in &table.fts_indexes {
19893                    self.drop_fts_triggers(index)?;
19894                }
19895                let visible = visible_table(&table.name);
19896                let base = base_table(&table.name);
19897                self.conn
19898                    .execute_batch(&format!(
19899                        "DELETE FROM {visible}; INSERT INTO {visible} SELECT * FROM {base}"
19900                    ))
19901                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19902            }
19903            let acknowledged = self.acknowledged_operations(None, Some(&names))?;
19904            self.apply_outbox_ops(
19905                acknowledged
19906                    .iter()
19907                    .filter(|op| names.contains(op.table.as_str())),
19908            )?;
19909            for (commit, initial) in &self.failed_commits {
19910                for operation in &commit.ops {
19911                    if !names.contains(operation.table.as_str()) || !operation.upsert {
19912                        continue;
19913                    }
19914                    if let Some(table) = self.schema.table(&operation.table) {
19915                        if self.visible_row(table, &operation.row_id)?.is_none()
19916                            && operation.values.as_ref().is_some_and(|values| {
19917                                table
19918                                    .columns
19919                                    .iter()
19920                                    .all(|column| values.contains_key(&column.name))
19921                            })
19922                        {
19923                            let key = serde_json::to_string(&(
19924                                operation.table.clone(),
19925                                operation.row_id.clone(),
19926                            ))
19927                            .expect("row key serialization");
19928                            if let Some(Value::Object(values)) = initial.get(&key) {
19929                                self.apply_outbox_ops(&[OutboxOp {
19930                                    values: Some(values.clone()),
19931                                    ..operation.clone()
19932                                }])?;
19933                            }
19934                        }
19935                    }
19936                }
19937                self.apply_outbox_ops(
19938                    commit
19939                        .ops
19940                        .iter()
19941                        .filter(|op| names.contains(op.table.as_str())),
19942                )?;
19943            }
19944            for commit in &self.outbox {
19945                self.apply_outbox_ops(
19946                    commit
19947                        .ops
19948                        .iter()
19949                        .filter(|op| names.contains(op.table.as_str())),
19950                )?;
19951            }
19952            for table in &tables {
19953                for index in &table.fts_indexes {
19954                    self.rebuild_fts_projection(table, index)?;
19955                    self.create_fts_triggers(table, index)?;
19956                }
19957            }
19958            self.conn
19959                .execute_batch("RELEASE syncular_overlay")
19960                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19961            Ok(())
19962        })();
19963        if rebuilt.is_err() {
19964            self.conn
19965                .execute_batch("ROLLBACK TO syncular_overlay; RELEASE syncular_overlay")
19966                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19967        } else {
19968            self.overlay_dirty.set(false);
19969        }
19970        rebuilt
19971    }
19972
19973    /// §7.1 replay: every operation is applied over the current visible
19974    /// state. An operation that no longer applies (a server row now holds its
19975    /// unique value) stays pending and invisible until a later replay admits
19976    /// it or the server answers its push.
19977    fn apply_outbox_ops<'a>(
19978        &self,
19979        ops: impl IntoIterator<Item = &'a OutboxOp>,
19980    ) -> Result<(), String> {
19981        #[cfg(feature = "bench-internals")]
19982        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
19983        for op in ops {
19984            #[cfg(feature = "bench-internals")]
19985            if let Some(phase) = &mut phase {
19986                phase.unit();
19987            }
19988            if op.upsert {
19989                if let (Some(table), Some(values)) = (
19990                    self.schema
19991                        .table(&op.table)
19992                        .filter(|table| table.indexes.iter().any(|index| index.unique)),
19993                    op.values.as_ref(),
19994                ) {
19995                    let mut intended: Map<String, Value> = self
19996                        .visible_row(table, &op.row_id)?
19997                        .map(|(row, _)| {
19998                            table
19999                                .columns
20000                                .iter()
20001                                .zip(&row)
20002                                .map(|(column, value)| {
20003                                    (column.name.clone(), column_value_to_json(value))
20004                                })
20005                                .collect()
20006                        })
20007                        .unwrap_or_default();
20008                    intended.extend(values.clone());
20009                    if !self.unique_conflicts(table, &intended, false)?.is_empty() {
20010                        continue;
20011                    }
20012                }
20013            }
20014            self.apply_outbox_op(op)?;
20015        }
20016        Ok(())
20017    }
20018
20019    fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
20020        let Some(table) = self.schema.table(&op.table) else {
20021            return Ok(());
20022        };
20023        let visible = visible_table(&table.name);
20024        if !op.upsert {
20025            let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
20026            self.conn
20027                .prepare_cached(&sql)
20028                .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
20029                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20030            return Ok(());
20031        }
20032        let Some(values) = op.values.as_ref() else {
20033            return Ok(());
20034        };
20035        // §7.1: the overlay applies the operation's PRESENT columns
20036        // over the current local row. A partial operation over an
20037        // absent local row leaves it absent — the server answers it
20038        // with sync.row_deleted or sync.row_missing (§6.2).
20039        let full = table
20040            .columns
20041            .iter()
20042            .all(|column| values.contains_key(&column.name));
20043        let local = self.visible_row(table, &op.row_id)?;
20044        if local.is_none() && !full {
20045            return Ok(());
20046        }
20047        let mut row: Row = Vec::with_capacity(table.columns.len());
20048        for (index, column) in table.columns.iter().enumerate() {
20049            let incoming = match values.get(&column.name) {
20050                Some(raw) => json_to_column_value(
20051                    if raw.get("$bytes").is_some()
20052                        && table
20053                            .encrypted_columns
20054                            .iter()
20055                            .any(|encrypted| encrypted.index == index)
20056                    {
20057                        &table.wire_columns[index]
20058                    } else {
20059                        column
20060                    },
20061                    Some(raw),
20062                ),
20063                None => Ok(local
20064                    .as_ref()
20065                    .and_then(|(values, _)| values.get(index).cloned())
20066                    .flatten()),
20067            };
20068            row.push(incoming.map_err(|_| {
20069                "sync.local_corrupt: persisted optimistic row value is invalid".to_owned()
20070            })?);
20071        }
20072        let version = local.as_ref().map_or(-1, |(_, version)| *version);
20073        self.write_row(&visible, &table.name, &row, version)
20074            .map_err(|error| {
20075                if error.starts_with("UNIQUE constraint failed:") {
20076                    "sync.constraint_violation: local write violates a unique constraint".to_owned()
20077                } else {
20078                    error
20079                }
20080            })
20081    }
20082
20083    /// The visible (optimistic) row's values plus version, or `None` when the
20084    /// row is absent from the overlay.
20085    fn visible_row(&self, table: &TableSchema, row_id: &str) -> Result<Option<(Row, i64)>, String> {
20086        self.stored_row(table, row_id, false)
20087    }
20088
20089    fn stored_row(
20090        &self,
20091        table: &TableSchema,
20092        row_id: &str,
20093        base: bool,
20094    ) -> Result<Option<(Row, i64)>, String> {
20095        let sql = format!(
20096            "SELECT * FROM {} WHERE {}",
20097            if base {
20098                base_table(&table.name)
20099            } else {
20100                visible_table(&table.name)
20101            },
20102            row_id_predicate(table)
20103        );
20104        let mut stmt = self
20105            .conn
20106            .prepare_cached(&sql)
20107            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20108        let mut rows = stmt
20109            .query(rusqlite::params![row_id])
20110            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20111        let Some(row) = rows
20112            .next()
20113            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
20114        else {
20115            return Ok(None);
20116        };
20117        let mut values = Vec::with_capacity(table.columns.len());
20118        for (index, column) in table.columns.iter().enumerate() {
20119            let raw = row
20120                .get_ref(index)
20121                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20122            if let rusqlite::types::ValueRef::Blob(bytes) = raw {
20123                if table
20124                    .encrypted_columns
20125                    .iter()
20126                    .any(|encrypted| encrypted.index == index)
20127                {
20128                    values.push(Some(ColumnValue::Bytes(bytes.to_vec())));
20129                    continue;
20130                }
20131            }
20132            let json = sql_ref_to_json(column, raw);
20133            values.push(json_to_column_value(column, Some(&json)).map_err(|_| {
20134                "sync.local_corrupt: persisted local row value is invalid".to_owned()
20135            })?);
20136        }
20137        let version: i64 = row
20138            .get(table.columns.len())
20139            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20140        Ok(Some((values, version)))
20141    }
20142
20143    // -- realtime (§8) ---------------------------------------------------------------
20144
20145    /// §8.8: the configured realtime policy.
20146    #[must_use]
20147    pub fn realtime_policy(&self) -> RealtimePolicy {
20148        self.realtime_policy
20149    }
20150
20151    /// §8.8: the explicit realtime availability state.
20152    #[must_use]
20153    pub fn realtime_state(&self) -> RealtimeState {
20154        self.realtime_state
20155    }
20156
20157    /// §8.8: set the realtime policy. Refused while the socket is connected:
20158    /// a policy change never silently re-routes an ownership state.
20159    pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
20160        if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
20161            return Err(
20162                "sync.invalid_request: disconnect realtime before changing the realtime policy"
20163                    .to_owned(),
20164            );
20165        }
20166        self.realtime_policy = policy;
20167        match policy {
20168            RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
20169            RealtimePolicy::Required | RealtimePolicy::Optional
20170                if self.realtime_state == RealtimeState::Disabled =>
20171            {
20172                self.set_realtime_state(RealtimeState::Disconnected, None);
20173            }
20174            RealtimePolicy::Required | RealtimePolicy::Optional => {}
20175        }
20176        Ok(())
20177    }
20178
20179    /// Record an availability transition, keeping the reason code bounded and
20180    /// code-like (§7.6) and clearing it where it cannot apply.
20181    fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
20182        self.realtime_state = state;
20183        self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
20184        if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
20185            self.realtime_reason_code = None;
20186            self.realtime_retry_delay_ms = None;
20187        }
20188    }
20189
20190    #[must_use]
20191    fn realtime_connected(&self) -> bool {
20192        self.realtime_state == RealtimeState::Connected
20193    }
20194
20195    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
20196        if !self.transport_enabled {
20197            return Err("sync.offline: transport gate is closed".into());
20198        }
20199        if self.realtime_policy == RealtimePolicy::Off {
20200            return Err(
20201                "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
20202            );
20203        }
20204        if self.realtime_connected() {
20205            return Ok(());
20206        }
20207        self.realtime_state = RealtimeState::Connecting;
20208        match transport.realtime_connect_for_client(&self.client_id) {
20209            Ok(()) => {
20210                self.set_realtime_state(RealtimeState::Connected, None);
20211                Ok(())
20212            }
20213            Err(error) => {
20214                self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
20215                Err(format!("{}: {}", error.code, error.message))
20216            }
20217        }
20218    }
20219
20220    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
20221        self.cancel_sync_round();
20222        // A deliberately disconnected or policy-disabled client has nothing
20223        // to release; a `lost` socket still does (the host owns the handle).
20224        if self.realtime_state == RealtimeState::Disconnected
20225            || self.realtime_state == RealtimeState::Disabled
20226        {
20227            return;
20228        }
20229        let _ = transport.realtime_close();
20230        self.set_realtime_state(
20231            if self.realtime_policy == RealtimePolicy::Off {
20232                RealtimeState::Disabled
20233            } else {
20234                RealtimeState::Disconnected
20235            },
20236            None,
20237        );
20238        self.presence.clear(); // §8.6.1: presence is per-connection
20239    }
20240
20241    /// §8.8: the host observed the socket end without disconnecting it (a
20242    /// peer close, an I/O error, a failed send; the native transport reports
20243    /// these as `Inbound::Disconnected`). Releases the transport, moves a
20244    /// `Connected` state to `Lost` with the transport's code as the reason, and schedules
20245    /// one background retry intent so the host reconnects and catches up.
20246    pub fn realtime_lost(&mut self, transport: &mut dyn Transport, error: &TransportError) {
20247        if self.realtime_state != RealtimeState::Connected {
20248            return;
20249        }
20250        self.cancel_sync_round();
20251        let _ = transport.realtime_close();
20252        self.set_realtime_state(RealtimeState::Lost, Some(&error.code));
20253        self.realtime_retry_delay_ms = Some(self.schedule_background_retry());
20254        self.presence.clear();
20255    }
20256
20257    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
20258    /// document for `scope_key`. Requires a live socket; the document is
20259    /// ephemeral (lost on disconnect). Authorization is the connection's
20260    /// registration (§8.6.3) — an unheld key is rejected loudly by the
20261    /// server with `presence.forbidden`.
20262    pub fn set_presence(
20263        &mut self,
20264        transport: &mut dyn Transport,
20265        scope_key: &str,
20266        doc: Option<&Value>,
20267    ) -> Result<(), String> {
20268        if !self.transport_enabled {
20269            return Err("sync.offline: transport gate is closed".into());
20270        }
20271        if !self.realtime_connected() {
20272            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
20273        }
20274        let text = encode_presence_publish(scope_key, doc);
20275        transport
20276            .realtime_send(&text)
20277            .map_err(|e| format!("{}: {}", e.code, e.message))
20278    }
20279
20280    /// §8.6: the peers currently present on a scope key (ephemeral).
20281    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
20282        self.presence
20283            .get(scope_key)
20284            .map(|peers| peers.values().cloned().collect())
20285            .unwrap_or_default()
20286    }
20287
20288    /// §8.6 apply an inbound presence fanout to the local map.
20289    fn apply_presence(
20290        &mut self,
20291        scope_key: String,
20292        kind: Option<PresenceKind>,
20293        actor_id: Option<String>,
20294        client_id: Option<String>,
20295        doc: Option<Value>,
20296        error: Option<String>,
20297    ) {
20298        // The publisher-directed error variant is out-of-band; nothing to
20299        // record in the peer map.
20300        if error.is_some() {
20301            return;
20302        }
20303        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
20304            return;
20305        };
20306        let peer_key = format!("{actor_id} {client_id}");
20307        match kind {
20308            PresenceKind::Leave => {
20309                if let Some(peers) = self.presence.get_mut(&scope_key) {
20310                    peers.remove(&peer_key);
20311                    if peers.is_empty() {
20312                        self.presence.remove(&scope_key);
20313                    }
20314                }
20315            }
20316            _ => {
20317                let doc = match doc {
20318                    Some(Value::Object(_)) => doc.unwrap(),
20319                    _ => return,
20320                };
20321                self.presence.entry(scope_key).or_default().insert(
20322                    peer_key,
20323                    PresencePeer {
20324                        actor_id,
20325                        client_id,
20326                        doc,
20327                    },
20328                );
20329            }
20330        }
20331    }
20332
20333    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
20334    pub fn on_realtime_text(&mut self, text: &str) {
20335        if self.stopped || !self.transport_enabled || self.security_preflight {
20336            return;
20337        }
20338        match parse_control(text) {
20339            Ok(ControlMessage::Hello { requires_sync, .. }) => {
20340                if requires_sync {
20341                    // §8.1: pull before trusting the socket for continuity.
20342                    self.set_sync_needed(true, true);
20343                }
20344            }
20345            Ok(ControlMessage::Presence {
20346                scope_key,
20347                kind,
20348                actor_id,
20349                client_id,
20350                doc,
20351                error,
20352                ..
20353            }) => {
20354                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
20355            }
20356            Ok(ControlMessage::Wake { .. }) => {
20357                // §8.3: any wake-up means "run a pull soon", never data.
20358                self.set_sync_needed(true, true);
20359            }
20360            _ => {}
20361        }
20362    }
20363
20364    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
20365    /// a pull response per section; an unapplied delta is a wake-up.
20366    /// Mailbox hosts send durable acknowledgements on their I/O executor.
20367    pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
20368        let mut transport = crate::round::DownloadResults::default();
20369        self.on_realtime_binary(&mut transport, bytes);
20370        transport.controls
20371    }
20372
20373    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
20374        if self.stopped || !self.transport_enabled || self.security_preflight {
20375            return;
20376        }
20377        #[cfg(feature = "bench-internals")]
20378        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
20379        let message = match decode_message(bytes) {
20380            Ok(m) if m.msg_kind == MsgKind::Response => m,
20381            _ => {
20382                self.set_sync_needed(true, true);
20383                return;
20384            }
20385        };
20386        #[cfg(feature = "bench-internals")]
20387        drop(decode_phase);
20388        let mut frames = message.frames.into_iter();
20389        let mut applied_cursor: Option<i64> = None;
20390        let mut any_covered = false;
20391        let mut dropped = self.begin_coalesced_apply().is_err();
20392        while let Some(frame) = frames.next() {
20393            let Frame::SubStart {
20394                id,
20395                status,
20396                effective_scopes,
20397                ..
20398            } = frame
20399            else {
20400                continue;
20401            };
20402            let mut body = Vec::new();
20403            let mut next_cursor: Option<i64> = None;
20404            for inner in frames.by_ref() {
20405                match inner {
20406                    Frame::SubEnd {
20407                        next_cursor: nc, ..
20408                    } => {
20409                        next_cursor = Some(nc);
20410                        break;
20411                    }
20412                    Frame::Unknown { .. } => {}
20413                    other => body.push(other),
20414                }
20415            }
20416            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
20417                dropped = true;
20418                continue;
20419            };
20420            let sub = &self.subs[sub_index];
20421            // §8.2: only locally active, not mid-bootstrap subscriptions
20422            // apply; skipped sections are repaired by the next pull.
20423            if status != SubStatus::Active
20424                || sub.state != SubState::Active
20425                || sub.bootstrap_state.is_some()
20426                || !sub.synced_once
20427            {
20428                dropped = true;
20429                continue;
20430            }
20431            if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
20432                // Idempotent redelivery of an already-covered window.
20433                any_covered = true;
20434                continue;
20435            }
20436            let previous_effective = self.subs[sub_index].effective.clone();
20437            let previous_cursor = self.subs[sub_index].cursor;
20438            self.subs[sub_index].effective = Some(effective_scopes);
20439            let mut failed = false;
20440            for inner in body {
20441                match inner {
20442                    Frame::Commit {
20443                        tables,
20444                        changes,
20445                        commit_seq,
20446                        ..
20447                    } => {
20448                        if self
20449                            .apply_commit_frame(&tables, &changes, Some(commit_seq))
20450                            .is_err()
20451                        {
20452                            failed = true;
20453                            break;
20454                        }
20455                    }
20456                    _ => {
20457                        failed = true;
20458                        break;
20459                    }
20460                }
20461            }
20462            let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
20463                self.apply_sub_end(sub_index, cursor, None)
20464                    .ok()
20465                    .map(|()| cursor)
20466            });
20467            let Some(next_cursor) = committed else {
20468                self.subs[sub_index].effective = previous_effective;
20469                self.subs[sub_index].cursor = previous_cursor;
20470                dropped = true;
20471                continue;
20472            };
20473            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
20474        }
20475        // §1.4 rule 4: the ack follows the COMMIT. A failed COMMIT restored
20476        // the cursors, so the delta counts as a wake-up.
20477        if self.finish_coalesced_apply().is_err() {
20478            applied_cursor = None;
20479            dropped = true;
20480        }
20481        if let Some(cursor) = applied_cursor {
20482            // §8.2 ack point: the highest applied SUB_END.nextCursor.
20483            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
20484            let _ = transport.realtime_send(&ack);
20485        } else if !any_covered || dropped {
20486            // §8.2: a delta not applied at all is treated as a wake-up.
20487            self.set_sync_needed(true, true);
20488        }
20489    }
20490
20491    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
20492    /// cursor across active, non-bootstrapping subscriptions that have
20493    /// synced at least once. No such subscription, no ack.
20494    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
20495        if !self.realtime_connected() {
20496            return;
20497        }
20498        let floor = self
20499            .subs
20500            .iter()
20501            .filter(|s| {
20502                s.state == SubState::Active
20503                    && s.bootstrap_state.is_none()
20504                    && s.synced_once
20505                    && s.cursor >= 0
20506            })
20507            .map(|s| s.cursor)
20508            .min();
20509        if let Some(cursor) = floor {
20510            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
20511            let _ = transport.realtime_send(&ack);
20512        }
20513    }
20514}
20515
20516#[cfg(test)]
20517mod previous_version_wiring_tests {
20518    use super::*;
20519    use serde_json::{json, Value};
20520
20521    fn previous_version_schema(version: i32) -> Value {
20522        json!({
20523            "version": version,
20524            "tables": [{
20525                "name": "tasks",
20526                "primaryKey": "id",
20527                "columns": [
20528                    { "name": "id", "type": "string", "nullable": false },
20529                    { "name": "note", "type": "string", "nullable": true }
20530                ],
20531                "scopes": []
20532            }]
20533        })
20534    }
20535
20536    fn previous_version_limits() -> crate::api::ClientLimits {
20537        crate::api::ClientLimits {
20538            previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
20539                enabled: true,
20540                ..Default::default()
20541            }),
20542            ..Default::default()
20543        }
20544    }
20545
20546    /// The configuration the live client carries, for a direct capture call.
20547    fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
20548        previous_version_limits()
20549            .previous_version_context
20550            .expect("configured")
20551    }
20552
20553    /// Capture the container the way a bump would, so a read test starts from a
20554    /// container this client's own state will then decide about.
20555    fn recapture_previous_version(client: &SyncClient) {
20556        let replica_path = client
20557            .previous_version_replica_path()
20558            .expect("file-backed replica");
20559        let outcome = crate::previous_version::capture_previous_version_from_replica(
20560            &client.conn,
20561            &replica_path,
20562            2,
20563            2,
20564            &previous_version_config(),
20565            1,
20566        )
20567        .expect("capture");
20568        assert!(matches!(
20569            outcome,
20570            crate::previous_version::CaptureOutcome::Captured(_)
20571        ));
20572    }
20573
20574    fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
20575        Subscription {
20576            id: "wiring-sub".to_owned(),
20577            table: "tasks".to_owned(),
20578            requested: Vec::new(),
20579            params: None,
20580            cursor,
20581            bootstrap_state: bootstrap_state.map(str::to_owned),
20582            state,
20583            reason_code: None,
20584            effective: None,
20585            synced_once: false,
20586        }
20587    }
20588
20589    #[test]
20590    fn schema_downgrade_refuses_all_opens_and_recreate_without_mutation() {
20591        let path =
20592            std::env::temp_dir().join(format!("syncular-downgrade-{}.db", uuid::Uuid::new_v4()));
20593        let path_str = path.to_str().expect("temp path");
20594        let schema = previous_version_schema(3);
20595        let mut older = previous_version_schema(2);
20596        older["tables"][0]["columns"] =
20597            json!([{ "name": "id", "type": "string", "nullable": false }]);
20598        let mut client = SyncClient::open_path(
20599            "downgrade".into(),
20600            &previous_version_schema(1),
20601            previous_version_limits(),
20602            path_str,
20603        )
20604        .expect("fresh");
20605        client
20606            .mutate(vec![Mutation::Upsert {
20607                table: "tasks".into(),
20608                values: Map::from_iter([
20609                    ("id".into(), json!("accepted")),
20610                    ("note".into(), json!("old")),
20611                ]),
20612                base_version: None,
20613            }])
20614            .expect("v1 write");
20615        client
20616            .recreate_with_schema(&schema)
20617            .expect("upgrade captures context");
20618        let commit = client
20619            .mutate(vec![Mutation::Upsert {
20620                table: "tasks".into(),
20621                values: Map::from_iter([
20622                    ("id".into(), json!("queued")),
20623                    ("note".into(), json!("v3-only")),
20624                ]),
20625                base_version: None,
20626            }])
20627            .expect("queued v3 write");
20628        let changes = client.conn.total_changes();
20629        let outbox = client.pending_commit_ids();
20630        assert!(outbox.contains(&commit));
20631        let descriptor = client.get_meta("localSchemaDescriptor");
20632        let container = format!(
20633            "{path_str}{}",
20634            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
20635        );
20636        let context = std::fs::read(&container).expect("captured context");
20637        assert!(client
20638            .recreate_with_schema(&older)
20639            .expect_err("refuse recreate")
20640            .starts_with("client.schema_downgrade:"));
20641        assert_eq!(client.schema.version, 3);
20642        assert_eq!(client.conn.total_changes(), changes);
20643        assert_eq!(client.pending_commit_ids(), outbox);
20644        assert_eq!(client.get_meta("localSchemaDescriptor"), descriptor);
20645        assert_eq!(
20646            client
20647                .conn
20648                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
20649                    row.get::<_, String>(0)
20650                })
20651                .expect("queued row"),
20652            "v3-only"
20653        );
20654        assert_eq!(std::fs::read(&container).expect("context remains"), context);
20655        drop(client);
20656        let before = std::fs::read(&path).expect("replica bytes");
20657        // open_path uses DELETE journaling. The identity open must refuse
20658        // before its normal WAL configuration changes this database.
20659        for entry in 0..3 {
20660            let error = match entry {
20661                0 => SyncClient::open_path(
20662                    "downgrade".into(),
20663                    &older,
20664                    previous_version_limits(),
20665                    path_str,
20666                ),
20667                1 => SyncClient::open_path_with_identity(
20668                    None,
20669                    &older,
20670                    previous_version_limits(),
20671                    path_str,
20672                ),
20673                _ => SyncClient::with_connection(
20674                    "downgrade".into(),
20675                    &older,
20676                    previous_version_limits(),
20677                    Connection::open(&path).expect("connection"),
20678                ),
20679            }
20680            .err()
20681            .expect("refuse open");
20682            assert!(error.starts_with("client.schema_downgrade:"));
20683            let conn = Connection::open(&path).expect("inspect");
20684            assert_eq!(
20685                conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
20686                    .expect("journal mode"),
20687                "delete"
20688            );
20689            drop(conn);
20690            assert_eq!(std::fs::read(&path).expect("replica unchanged"), before);
20691            assert_eq!(
20692                std::fs::read(&container).expect("context unchanged"),
20693                context
20694            );
20695        }
20696        let reopened = SyncClient::open_path(
20697            "downgrade".into(),
20698            &schema,
20699            previous_version_limits(),
20700            path_str,
20701        )
20702        .expect("compatible reopen");
20703        assert_eq!(reopened.pending_commit_ids(), outbox);
20704        assert_eq!(
20705            reopened
20706                .conn
20707                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
20708                    row.get::<_, String>(0)
20709                })
20710                .expect("queued row remains"),
20711            "v3-only"
20712        );
20713        reopened.delete_meta(LOCAL_SCHEMA_VERSION_KEY);
20714        reopened.delete_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
20715        drop(reopened);
20716        let legacy = SyncClient::open_path(
20717            "downgrade".into(),
20718            &schema,
20719            previous_version_limits(),
20720            path_str,
20721        )
20722        .expect("legacy no-marker reopen");
20723        assert_eq!(legacy.pending_commit_ids(), outbox);
20724        assert!(!legacy.upgrading());
20725        assert_eq!(
20726            legacy
20727                .conn
20728                .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
20729                    row.get::<_, String>(0)
20730                })
20731                .expect("legacy row remains"),
20732            "v3-only"
20733        );
20734        drop(legacy);
20735        std::fs::remove_file(path).expect("remove replica");
20736        let _ = std::fs::remove_file(container);
20737    }
20738
20739    #[test]
20740    fn schema_marker_errors_refuse_open_and_recreate_without_mutation() {
20741        for marker in [
20742            "''",
20743            "'bad'",
20744            "'0'",
20745            "'-1'",
20746            "'2.5'",
20747            "'03'",
20748            "'3\n'",
20749            "' 3'",
20750            "'+3'",
20751            "'3e0'",
20752            "'2147483648'",
20753            "NULL",
20754            "X'33'",
20755        ] {
20756            let conn = Connection::open_in_memory().expect("connection");
20757            conn.execute_batch(&format!("CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value); INSERT INTO _syncular_meta VALUES ('localSchemaVersion', {marker});")).expect("invalid marker fixture");
20758            let error = SyncClient::with_connection(
20759                "marker".into(),
20760                &previous_version_schema(3),
20761                ClientLimits::default(),
20762                conn,
20763            )
20764            .err()
20765            .expect("refuse marker");
20766            assert!(
20767                error.starts_with("sync.local_corrupt:"),
20768                "{marker}: {error}"
20769            );
20770        }
20771        for fault in [
20772            "UPDATE _syncular_meta SET value = 'bad' WHERE key = 'localSchemaVersion'",
20773            "ALTER TABLE _syncular_meta RENAME COLUMN value TO unreadable_value",
20774        ] {
20775            let mut client = SyncClient::new(
20776                "marker".into(),
20777                &previous_version_schema(3),
20778                ClientLimits::default(),
20779            )
20780            .expect("fresh");
20781            let commit = client
20782                .mutate(vec![Mutation::Upsert {
20783                    table: "tasks".into(),
20784                    values: Map::from_iter([("id".into(), json!("queued"))]),
20785                    base_version: None,
20786                }])
20787                .expect("queued write");
20788            client.conn.execute_batch(fault).expect("marker fault");
20789            let changes = client.conn.total_changes();
20790            let error = client
20791                .recreate_with_schema(&previous_version_schema(4))
20792                .expect_err("refuse corrupt marker");
20793            assert!(error.starts_with("sync.local_corrupt:"));
20794            assert_eq!(client.conn.total_changes(), changes);
20795            assert_eq!(client.schema.version, 3);
20796            assert_eq!(client.pending_commit_ids(), vec![commit]);
20797        }
20798        for setup in [
20799            "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY)",
20800            "CREATE VIEW _syncular_meta AS SELECT 'localSchemaVersion' AS key, '3' AS value",
20801        ] {
20802            let path =
20803                std::env::temp_dir().join(format!("syncular-marker-{}.db", uuid::Uuid::new_v4()));
20804            let conn = Connection::open(&path).expect("connection");
20805            conn.execute_batch(setup)
20806                .expect("unreadable metadata fixture");
20807            drop(conn);
20808            let before = std::fs::read(&path).expect("before");
20809            let error = SyncClient::open_path_with_identity(
20810                None,
20811                &previous_version_schema(3),
20812                ClientLimits::default(),
20813                path.to_str().expect("path"),
20814            )
20815            .err()
20816            .expect("refuse unreadable marker");
20817            assert!(error.starts_with("sync.local_corrupt:"));
20818            assert_eq!(std::fs::read(&path).expect("after"), before);
20819            std::fs::remove_file(path).expect("remove fixture");
20820        }
20821    }
20822
20823    std::thread_local! {
20824        /// The pending concurrent upgrade a busy handler commits, so a lock
20825        /// collision deterministically becomes the marker race under test.
20826        static CONCURRENT_UPGRADE: RefCell<Option<Connection>> =
20827            const { RefCell::new(None) };
20828    }
20829
20830    fn commit_concurrent_upgrade(attempts: i32) -> bool {
20831        CONCURRENT_UPGRADE.with(|slot| {
20832            if let Some(banner) = slot.borrow_mut().take() {
20833                banner
20834                    .execute_batch(
20835                        "UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion'; COMMIT",
20836                    )
20837                    .expect("commit the concurrent upgrade");
20838            }
20839        });
20840        attempts < 10
20841    }
20842
20843    fn journal_mode(conn: &Connection) -> String {
20844        conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
20845            .expect("journal mode")
20846    }
20847
20848    fn temp_replica(label: &str) -> std::path::PathBuf {
20849        std::env::temp_dir().join(format!("syncular-{label}-{}.db", uuid::Uuid::new_v4()))
20850    }
20851
20852    #[test]
20853    fn file_identity_constructor_requires_the_returned_wal_mode() {
20854        for path in [":memory:", ""] {
20855            let error = SyncClient::open_path_with_identity(
20856                None,
20857                &previous_version_schema(1),
20858                ClientLimits::default(),
20859                path,
20860            )
20861            .err()
20862            .expect("temporary SQLite databases cannot enter WAL mode");
20863            assert_eq!(
20864                error,
20865                "sync.invalid_request: file-backed client requires WAL journal mode"
20866            );
20867        }
20868        let path = temp_replica("confirmed-wal");
20869        let client = SyncClient::open_path_with_identity(
20870            None,
20871            &previous_version_schema(1),
20872            ClientLimits::default(),
20873            path.to_str().unwrap(),
20874        )
20875        .unwrap();
20876        assert_eq!(journal_mode(&client.conn), "wal");
20877        drop(client);
20878        std::fs::remove_file(path).unwrap();
20879    }
20880
20881    #[test]
20882    fn non_positive_generated_schema_versions_are_refused_before_storage() {
20883        for version in [json!(0), json!(-1), json!(i32::MIN), json!(2147483648i64)] {
20884            let mut schema = previous_version_schema(1);
20885            schema["version"] = version.clone();
20886            let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
20887                .err()
20888                .expect("refuse an out-of-range generated version");
20889            assert_eq!(
20890                error,
20891                "sync.invalid_request: generated schema version must be an integer in 1..=2147483647"
20892            );
20893            // The file-backed seam refuses before the replica gains a table.
20894            let path = temp_replica("bad-version");
20895            let path_str = path.to_str().expect("path");
20896            for identity in [false, true] {
20897                let error = if identity {
20898                    SyncClient::open_path_with_identity(
20899                        None,
20900                        &schema,
20901                        ClientLimits::default(),
20902                        path_str,
20903                    )
20904                } else {
20905                    SyncClient::open_path(
20906                        "bad-version".into(),
20907                        &schema,
20908                        ClientLimits::default(),
20909                        path_str,
20910                    )
20911                }
20912                .err()
20913                .expect("refuse before storage");
20914                assert!(
20915                    error.starts_with("sync.invalid_request:"),
20916                    "{version}: {error}"
20917                );
20918                assert!(
20919                    !path.exists(),
20920                    "{version}: a refused constructor creates no replica file"
20921                );
20922            }
20923        }
20924        // A missing version is the same request error, not a parse failure.
20925        let mut schema = previous_version_schema(1);
20926        schema
20927            .as_object_mut()
20928            .expect("schema object")
20929            .remove("version");
20930        let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
20931            .err()
20932            .expect("refuse a missing version");
20933        assert!(error.starts_with("sync.invalid_request:"), "{error}");
20934    }
20935
20936    #[test]
20937    fn a_duplicated_schema_marker_is_refused_without_a_reset() {
20938        let path = temp_replica("duplicate-marker");
20939        let path_str = path.to_str().expect("path");
20940        {
20941            // A hand-built/legacy metadata table without its primary key, with
20942            // the older marker first: `query_row` would pick it and reset.
20943            let conn = Connection::open(path_str).expect("fixture");
20944            conn.execute_batch(
20945                "CREATE TABLE _syncular_meta(key TEXT, value);
20946                 INSERT INTO _syncular_meta VALUES
20947                   ('localSchemaVersion', '1'),
20948                   ('localSchemaVersion', '2'),
20949                   ('clientId', 'duplicate');",
20950            )
20951            .expect("duplicate marker fixture");
20952        }
20953        let error = SyncClient::open_path_with_identity(
20954            None,
20955            &previous_version_schema(3),
20956            ClientLimits::default(),
20957            path_str,
20958        )
20959        .err()
20960        .expect("refuse a duplicated marker");
20961        assert!(error.starts_with("sync.local_corrupt:"), "{error}");
20962        let probe = Connection::open(path_str).expect("probe");
20963        assert_eq!(
20964            probe
20965                .query_row(
20966                    "SELECT COUNT(*) FROM _syncular_meta WHERE key = 'localSchemaVersion'",
20967                    [],
20968                    |row| row.get::<_, i64>(0),
20969                )
20970                .expect("marker rows"),
20971            2,
20972            "the duplicated rows are left for the operator, not collapsed"
20973        );
20974        assert_eq!(
20975            probe
20976                .query_row(
20977                    "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'",
20978                    [],
20979                    |row| row.get::<_, i64>(0),
20980                )
20981                .expect("synced tables"),
20982            0,
20983            "a duplicated marker never drives the reset that recreates v3 tables"
20984        );
20985        assert_eq!(
20986            journal_mode(&probe),
20987            "delete",
20988            "the refusal leaves the journal mode alone"
20989        );
20990        drop(probe);
20991        std::fs::remove_file(path).expect("remove replica");
20992    }
20993
20994    #[test]
20995    fn a_refused_open_does_not_persist_wal_or_mutate_the_replica() {
20996        let path = temp_replica("refused-wal");
20997        let path_str = path.to_str().expect("path");
20998        // `open_path` leaves the default journal mode, so a refused open is
20999        // the only thing that could switch this replica to WAL.
21000        drop(
21001            SyncClient::open_path(
21002                "owner".into(),
21003                &previous_version_schema(2),
21004                ClientLimits::default(),
21005                path_str,
21006            )
21007            .expect("v2 replica"),
21008        );
21009        let before = std::fs::read(&path).expect("before");
21010        let error = SyncClient::open_path_with_identity(
21011            Some("intruder".into()),
21012            &previous_version_schema(2),
21013            ClientLimits::default(),
21014            path_str,
21015        )
21016        .err()
21017        .expect("refuse the identity mismatch");
21018        assert!(error.starts_with("client.identity_mismatch:"), "{error}");
21019        let probe = Connection::open(path_str).expect("probe");
21020        assert_eq!(
21021            journal_mode(&probe),
21022            "delete",
21023            "a refused open must not persist WAL"
21024        );
21025        assert_eq!(std::fs::read(&path).expect("after"), before);
21026        assert_eq!(
21027            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
21028            Some("2")
21029        );
21030        drop(probe);
21031        std::fs::remove_file(path).expect("remove replica");
21032    }
21033
21034    #[test]
21035    fn a_concurrent_marker_upgrade_refuses_a_stale_open() {
21036        let path = temp_replica("marker-race");
21037        let path_str = path.to_str().expect("path");
21038        drop(
21039            SyncClient::open_path_with_identity(
21040                Some("stale".into()),
21041                &previous_version_schema(1),
21042                ClientLimits::default(),
21043                path_str,
21044            )
21045            .expect("v1 replica"),
21046        );
21047        // Another process holds the write lock with an uncommitted upgrade; a
21048        // stale open reads the v1 marker first and only then meets the lock.
21049        // The first busy therefore lands after this replica became v2.
21050        let banner = Connection::open(path_str).expect("banner");
21051        banner
21052            .execute_batch(
21053                "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
21054            )
21055            .expect("pending upgrade");
21056        CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
21057        let conn = Connection::open(path_str).expect("stale connection");
21058        conn.busy_handler(Some(commit_concurrent_upgrade))
21059            .expect("busy handler");
21060        let error = SyncClient::with_connection(
21061            "stale".into(),
21062            &previous_version_schema(1),
21063            ClientLimits::default(),
21064            conn,
21065        )
21066        .err()
21067        .expect("a concurrent upgrade must refuse the stale open");
21068        assert!(error.starts_with("client.schema_downgrade:"), "{error}");
21069        let probe = Connection::open(path_str).expect("probe");
21070        assert_eq!(
21071            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
21072            Some("2"),
21073            "the concurrent upgrade survives"
21074        );
21075        drop(probe);
21076        std::fs::remove_file(path).expect("remove replica");
21077    }
21078
21079    #[test]
21080    fn a_concurrent_marker_upgrade_refuses_a_stale_recreate() {
21081        let path = temp_replica("recreate-race");
21082        let path_str = path.to_str().expect("path");
21083        let mut client = SyncClient::open_path_with_identity(
21084            Some("stale".into()),
21085            &previous_version_schema(1),
21086            ClientLimits::default(),
21087            path_str,
21088        )
21089        .expect("v1 replica");
21090        client
21091            .mutate(vec![Mutation::Upsert {
21092                table: "tasks".into(),
21093                values: Map::from_iter([("id".into(), json!("queued"))]),
21094                base_version: None,
21095            }])
21096            .expect("queued write");
21097        let commit = client.pending_commit_ids();
21098        let banner = Connection::open(path_str).expect("banner");
21099        banner
21100            .execute_batch(
21101                "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
21102            )
21103            .expect("pending upgrade");
21104        CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
21105        client
21106            .conn
21107            .busy_handler(Some(commit_concurrent_upgrade))
21108            .expect("busy handler");
21109        let error = client
21110            .recreate_with_schema(&previous_version_schema(1))
21111            .expect_err("a concurrent upgrade must refuse the stale recreate");
21112        assert!(error.starts_with("client.schema_downgrade:"), "{error}");
21113        assert_eq!(client.schema.version, 1, "the compiled schema is restored");
21114        assert_eq!(
21115            client.pending_commit_ids(),
21116            commit,
21117            "the outbox is preserved"
21118        );
21119        let probe = Connection::open(path_str).expect("probe");
21120        assert_eq!(
21121            meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
21122            Some("2")
21123        );
21124        drop(probe);
21125        drop(client);
21126        std::fs::remove_file(path).expect("remove replica");
21127    }
21128
21129    #[test]
21130    fn failed_schema_record_writes_roll_back_recreation() {
21131        for key in [
21132            LOCAL_SCHEMA_VERSION_KEY,
21133            crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY,
21134        ] {
21135            let mut client = SyncClient::new(
21136                "schema-record".into(),
21137                &previous_version_schema(1),
21138                ClientLimits::default(),
21139            )
21140            .expect("v1");
21141            let marker = client.get_meta(LOCAL_SCHEMA_VERSION_KEY);
21142            let descriptor = client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
21143            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");
21144            client
21145                .recreate_with_schema(&previous_version_schema(2))
21146                .expect_err("both record writes must propagate failure");
21147            assert_eq!(client.schema.version, 1);
21148            assert_eq!(client.get_meta(LOCAL_SCHEMA_VERSION_KEY), marker);
21149            assert_eq!(
21150                client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY),
21151                descriptor
21152            );
21153            client
21154                .conn
21155                .execute_batch("DROP TRIGGER refuse_schema_record")
21156                .expect("remove fault");
21157            client
21158                .recreate_with_schema(&previous_version_schema(2))
21159                .expect("retry");
21160            assert_eq!(client.schema.version, 2);
21161        }
21162    }
21163
21164    #[test]
21165    fn a_failed_outer_commit_restores_every_reset_side_effect() {
21166        let path = temp_replica("outer-commit");
21167        let path_str = path.to_str().expect("path");
21168        // A rollback-journal replica (no WAL), so a second read connection can
21169        // hold SHARED across the recreated transaction's COMMIT.
21170        let conn = Connection::open(path_str).expect("replica connection");
21171        let mut client = SyncClient::with_connection(
21172            "outer-commit".into(),
21173            &previous_version_schema(1),
21174            previous_version_limits(),
21175            conn,
21176        )
21177        .expect("v1 replica");
21178        assert_eq!(journal_mode(&client.conn), "delete");
21179        let commit = client
21180            .mutate(vec![Mutation::Upsert {
21181                table: "tasks".into(),
21182                values: Map::from_iter([
21183                    ("id".into(), json!("queued")),
21184                    ("note".into(), json!("dropped-by-v2")),
21185                ]),
21186                base_version: None,
21187            }])
21188            .expect("queued write");
21189        client.drain_change_batches();
21190        // v2 drops `note`, so the queued upsert cannot re-encode.
21191        let mut dropped = previous_version_schema(2);
21192        dropped["tables"][0]["columns"] =
21193            json!([{ "name": "id", "type": "string", "nullable": false }]);
21194        // Prior database and memory state: the refused COMMIT must restore all
21195        // of it.
21196        let marker = meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY);
21197        let revision = client.local_revision();
21198        let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
21199        let columns: i64 = client
21200            .conn
21201            .query_row(
21202                "SELECT count(*) FROM pragma_table_info('tasks')",
21203                [],
21204                |row| row.get(0),
21205            )
21206            .expect("column count");
21207        let rebuilds = client.overlay_rebuild_count.get();
21208        let prior_subs = client.subs.len();
21209        let prior_changes = client.change_queue.len();
21210        let prior_intents = client.sync_intent_queue.len();
21211        let prior_last_change = client.last_change.clone();
21212        let prior_overlay_dirty = client.overlay_dirty.get();
21213        let prior_sync_needed = client.sync_needed;
21214        let prior_upgrading = client.upgrading;
21215        let prior_stopped = client.stopped;
21216        let prior_schema_floor = client.schema_floor.clone();
21217        let prior_active_round = client.active_round;
21218        // The reader holds SHARED for the whole attempt: the writer takes
21219        // RESERVED, runs the reset, and fails when COMMIT needs EXCLUSIVE.
21220        let reader = Connection::open(path_str).expect("reader");
21221        reader
21222            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
21223            .expect("reader snapshot");
21224        client
21225            .conn
21226            .busy_timeout(std::time::Duration::ZERO)
21227            .expect("busy timeout");
21228        let error = client
21229            .recreate_with_schema(&dropped)
21230            .expect_err("a blocked COMMIT must refuse the recreation");
21231        assert!(error.starts_with("client.storage_busy:"), "{error}");
21232        assert!(
21233            client.overlay_rebuild_count.get() > rebuilds,
21234            "the reset ran to its overlay rebuild inside the transaction"
21235        );
21236        assert!(client.conn.is_autocommit(), "the transaction is released");
21237        // Database: the rolled-back reset left nothing behind.
21238        assert_eq!(meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY), marker);
21239        assert_eq!(client.local_revision(), revision);
21240        assert_eq!(
21241            client.query("SELECT * FROM tasks", &[]).expect("rows"),
21242            rows
21243        );
21244        assert_eq!(
21245            client
21246                .conn
21247                .query_row(
21248                    "SELECT count(*) FROM pragma_table_info('tasks')",
21249                    [],
21250                    |row| { row.get::<_, i64>(0) }
21251                )
21252                .expect("column count"),
21253            columns,
21254            "the previous table layout survives the rollback"
21255        );
21256        assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
21257        // Memory: every side effect of the successful reset is restored with
21258        // the rolled-back database.
21259        assert_eq!(client.schema.version, 1, "compiled schema");
21260        assert_eq!(client.subs.len(), prior_subs);
21261        assert_eq!(client.change_queue.len(), prior_changes);
21262        assert_eq!(client.sync_intent_queue.len(), prior_intents);
21263        assert_eq!(client.last_change, prior_last_change);
21264        assert_eq!(client.overlay_dirty.get(), prior_overlay_dirty);
21265        assert_eq!(client.sync_needed, prior_sync_needed);
21266        assert_eq!(client.upgrading, prior_upgrading);
21267        assert_eq!(client.stopped, prior_stopped);
21268        assert_eq!(client.schema_floor, prior_schema_floor);
21269        assert_eq!(client.active_round, prior_active_round);
21270        assert!(
21271            client.rejections.is_empty(),
21272            "the dropped-column rejection is rolled back too"
21273        );
21274        // The capture committed in its separate file before the failed replica
21275        // COMMIT. It belongs to v2 and cannot be exposed by the restored v1 core.
21276        let container = crate::previous_version::previous_version_container_path(path_str);
21277        assert!(std::path::Path::new(&container).exists());
21278        // The reader no longer needs its snapshot, so discard can clean the
21279        // replica's audit records along with the stale sibling file.
21280        drop(reader);
21281        let snapshot = client
21282            .previous_version_snapshot(&PreviousVersionReadSpec {
21283                table: "tasks".into(),
21284                ..Default::default()
21285            })
21286            .expect("refuse stale capture");
21287        assert!(!snapshot.available);
21288        assert_eq!(snapshot.current_version, 1);
21289        assert!(!std::path::Path::new(&container).exists());
21290        // The client remains usable: releasing the reader lets the identical
21291        // recreation run to completion, and the reset preserves the
21292        // incompatible intent until the send-time prepass classifies it
21293        // (§7.4.3/§7.4.4: the reset never touches the outbox).
21294        client
21295            .conn
21296            .busy_timeout(std::time::Duration::from_secs(1))
21297            .expect("busy timeout");
21298        client
21299            .recreate_with_schema(&dropped)
21300            .expect("the recreation succeeds once the reader releases");
21301        assert_eq!(client.schema.version, 2);
21302        assert_eq!(
21303            client.pending_commit_ids(),
21304            vec![commit.clone()],
21305            "the reset kept the incompatible commit pending"
21306        );
21307        assert!(
21308            client.rejections.is_empty(),
21309            "no rejection exists before the send-time prepass"
21310        );
21311        // The send boundary classifies and drops it.
21312        client
21313            .prepare_sync_round(false)
21314            .expect("the send prepass runs during round preparation");
21315        assert!(client.pending_commit_ids().is_empty());
21316        assert!(
21317            client
21318                .rejections
21319                .iter()
21320                .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE),
21321            "the dropped-column commit surfaced as a rejection at send time"
21322        );
21323        client.previous_version_discard();
21324        drop(client);
21325        std::fs::remove_file(path).expect("remove replica");
21326    }
21327
21328    #[test]
21329    fn the_send_time_incompatible_drop_is_atomic_and_publishes_once() {
21330        let v1 = previous_version_schema(1);
21331        let mut v2 = previous_version_schema(2);
21332        // v2 drops `note`, so the queued `dropped` upsert cannot re-encode.
21333        v2["tables"][0]["columns"] = json!([{ "name": "id", "type": "string", "nullable": false }]);
21334        let mut client = SyncClient::new("drop".to_string(), &v1, ClientLimits::default()).unwrap();
21335        let dropped = client
21336            .mutate(vec![Mutation::Upsert {
21337                table: "tasks".into(),
21338                values: Map::from_iter([
21339                    ("id".into(), json!("dropped")),
21340                    ("note".into(), json!("gone")),
21341                ]),
21342                base_version: None,
21343            }])
21344            .unwrap();
21345        // A full-row v1 upsert always carries the v1 columns, so the surviving
21346        // commit is a value-free delete (it encodes under any schema that still
21347        // has the table).
21348        let survivor = client
21349            .mutate(vec![Mutation::Delete {
21350                table: "tasks".into(),
21351                row_id: "gone".into(),
21352                base_version: None,
21353            }])
21354            .unwrap();
21355        client
21356            .recreate_with_schema(&v2)
21357            .expect("the reset preserves the outbox");
21358        assert_eq!(
21359            client.pending_commit_ids(),
21360            vec![dropped.clone(), survivor.clone()],
21361            "the reset preserves both commits"
21362        );
21363        assert!(client.rejections.is_empty(), "the reset classifies nothing");
21364        client.drain_change_batches();
21365        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
21366        assert_eq!(rows.len(), 1, "only the dropped upsert has a visible row");
21367
21368        // A fault anywhere in the atomic drop leaves the replica, the outbox,
21369        // the durable outcomes, the visible projection, the revision, and the
21370        // publish surface untouched. Clearing `syncNeeded` first keeps the
21371        // round's own status flip out of the assertion, so any published change
21372        // can only be the drop's.
21373        for fault in [
21374            "outcome-insert",
21375            "outbox-delete",
21376            "overlay-rebuild",
21377            "publish-revision",
21378        ] {
21379            client.set_sync_needed(false, false);
21380            client.drain_change_batches();
21381            let revision = client.local_revision();
21382            let trigger = match fault {
21383                "outcome-insert" => {
21384                    "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(ABORT,'drop fault'); END"
21385                }
21386                "outbox-delete" => {
21387                    "CREATE TRIGGER fail_drop BEFORE DELETE ON _syncular_outbox BEGIN SELECT RAISE(ABORT,'drop fault'); END"
21388                }
21389                // The optimized replay removes only the dropped projection, so
21390                // the replay fault belongs on the DELETE; a publish fault lands
21391                // on the revision write that `finish_observation` performs after
21392                // every durable effect already succeeded.
21393                "publish-revision" => {
21394                    "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT,'drop fault'); END"
21395                }
21396                _ => {
21397                    "CREATE TRIGGER fail_drop BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT,'drop fault'); END"
21398                }
21399            };
21400            client.conn.execute_batch(trigger).unwrap();
21401            let outcome = client
21402                .prepare_sync_round(false)
21403                .err()
21404                .unwrap_or_else(|| panic!("{fault}: a fault in the drop must fail the round"));
21405            assert!(
21406                matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "storage.failed"),
21407                "{fault}: {outcome:?}"
21408            );
21409            client.conn.execute_batch("DROP TRIGGER fail_drop").unwrap();
21410            assert!(client.conn.is_autocommit(), "{fault}: no leaked savepoint");
21411            assert_eq!(
21412                client.pending_commit_ids(),
21413                vec![dropped.clone(), survivor.clone()],
21414                "{fault}: the outbox is restored"
21415            );
21416            assert!(
21417                client.rejections.is_empty(),
21418                "{fault}: no in-memory rejection survives"
21419            );
21420            assert!(
21421                client.commit_outcome(&dropped).unwrap().is_none(),
21422                "{fault}: the durable outcome rolled back"
21423            );
21424            assert_eq!(client.local_revision(), revision, "{fault}: revision");
21425            assert_eq!(
21426                client.query("SELECT * FROM tasks", &[]).unwrap(),
21427                rows,
21428                "{fault}: the visible projection is unchanged"
21429            );
21430            assert!(
21431                client.drain_change_batches().is_empty(),
21432                "{fault}: nothing is published"
21433            );
21434        }
21435
21436        // A nested failure leaves the caller's transaction and earlier writes
21437        // intact; only the drop's savepoint is rolled back.
21438        client
21439            .conn
21440            .execute_batch(
21441                "BEGIN; INSERT INTO _syncular_meta(key,value) VALUES ('outerProbe','kept');
21442             CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes
21443             BEGIN SELECT RAISE(ABORT,'nested drop fault'); END;",
21444            )
21445            .unwrap();
21446        assert!(client.drop_incompatible_outbox().is_err());
21447        assert!(!client.conn.is_autocommit());
21448        assert_eq!(client.get_meta("outerProbe").as_deref(), Some("kept"));
21449        assert_eq!(
21450            client.pending_commit_ids(),
21451            vec![dropped.clone(), survivor.clone()]
21452        );
21453        assert!(client.commit_outcome(&dropped).unwrap().is_none());
21454        client
21455            .conn
21456            .execute_batch("DROP TRIGGER fail_drop; COMMIT")
21457            .unwrap();
21458
21459        // The successful drop publishes exactly once and leaves the surviving
21460        // intent queued for the same round. A log epoch is required before a
21461        // round builds push frames at all, so the classification provably runs
21462        // before any operation of the dropped commit is sent.
21463        client.set_meta(LOG_EPOCH_KEY, "epoch");
21464        client.set_sync_needed(false, false);
21465        client.drain_change_batches();
21466        let revision = client.local_revision();
21467        let prepared = client.prepare_sync_round(false).expect("the drop succeeds");
21468        assert_eq!(client.pending_commit_ids(), vec![survivor.clone()]);
21469        assert_eq!(
21470            prepared.meta.pushed_ids,
21471            vec![survivor.clone()],
21472            "the incompatible commit is classified before any operation is sent"
21473        );
21474        let rejection = client
21475            .rejections
21476            .iter()
21477            .find(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE)
21478            .expect("a sync.outbox_incompatible rejection");
21479        assert_eq!(rejection.client_commit_id, dropped);
21480        assert!(
21481            !rejection.retryable,
21482            "a schema-incompatible commit is final"
21483        );
21484        assert_eq!(
21485            client
21486                .commit_outcome(&dropped)
21487                .unwrap()
21488                .expect("a durable rejected outcome")
21489                .status,
21490            CommitOutcomeStatus::Rejected
21491        );
21492        assert_eq!(client.local_revision(), revision + 1, "one revision");
21493        let batches = client.drain_change_batches();
21494        assert_eq!(batches.len(), 1, "one change batch");
21495        assert!(
21496            batches[0].rejections_changed
21497                && batches[0].outcomes_changed
21498                && batches[0].status.is_some(),
21499            "{:?}",
21500            batches[0]
21501        );
21502        assert!(
21503            batches[0].tables.iter().any(|entry| entry.table == "tasks"),
21504            "{:?}",
21505            batches[0]
21506        );
21507        let visible = client.query("SELECT * FROM tasks", &[]).unwrap();
21508        assert!(
21509            visible.is_empty(),
21510            "the dropped projection is undone: {visible:?}"
21511        );
21512
21513        // The observation's outer RELEASE (transaction COMMIT) is guarded too:
21514        // a rollback-journal reader holding SHARED makes it fail with the typed
21515        // busy classification, and releasing the reader lets the retry succeed.
21516        let path = temp_replica("outbox-drop-commit");
21517        let path_str = path.to_str().expect("path");
21518        let conn = Connection::open(path_str).expect("replica connection");
21519        let mut client =
21520            SyncClient::with_connection("drop-commit".into(), &v1, ClientLimits::default(), conn)
21521                .expect("v1 replica");
21522        let dropped = client
21523            .mutate(vec![Mutation::Upsert {
21524                table: "tasks".into(),
21525                values: Map::from_iter([
21526                    ("id".into(), json!("dropped")),
21527                    ("note".into(), json!("gone")),
21528                ]),
21529                base_version: None,
21530            }])
21531            .unwrap();
21532        client
21533            .recreate_with_schema(&v2)
21534            .expect("the reset preserves the outbox");
21535        assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
21536        let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
21537        assert_eq!(rows.len(), 1, "the optimistic row is visible");
21538        client.set_sync_needed(false, false);
21539        client.drain_change_batches();
21540        let revision = client.local_revision();
21541        let reader = Connection::open(path_str).expect("reader");
21542        reader
21543            .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
21544            .expect("reader snapshot");
21545        client
21546            .conn
21547            .busy_timeout(std::time::Duration::ZERO)
21548            .expect("busy timeout");
21549        let outcome = client
21550            .prepare_sync_round(false)
21551            .err()
21552            .expect("a blocked COMMIT must fail the round");
21553        assert!(
21554            matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "client.storage_busy"),
21555            "{outcome:?}"
21556        );
21557        assert!(client.conn.is_autocommit(), "the savepoint is released");
21558        assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
21559        assert!(client.rejections.is_empty());
21560        assert!(client.commit_outcome(&dropped).unwrap().is_none());
21561        assert_eq!(client.local_revision(), revision);
21562        assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
21563        assert!(client.drain_change_batches().is_empty());
21564        // The same round succeeds once the reader releases.
21565        drop(reader);
21566        client
21567            .conn
21568            .busy_timeout(std::time::Duration::from_secs(1))
21569            .expect("busy timeout");
21570        client
21571            .prepare_sync_round(false)
21572            .expect("the drop succeeds after the reader releases");
21573        assert!(client.pending_commit_ids().is_empty());
21574        assert!(client
21575            .rejections
21576            .iter()
21577            .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE));
21578        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
21579        drop(client);
21580        std::fs::remove_file(path).expect("remove replica");
21581    }
21582
21583    #[test]
21584    fn previous_version_read_reasons_come_from_live_client_state() {
21585        use crate::previous_version::{
21586            PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
21587        };
21588        let path = std::env::temp_dir()
21589            .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
21590            .to_string_lossy()
21591            .into_owned();
21592        let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
21593        {
21594            let mut setup = SyncClient::open_path(
21595                "wiring".to_owned(),
21596                &previous_version_schema(1),
21597                previous_version_limits(),
21598                &path,
21599            )
21600            .expect("v1 install");
21601            setup
21602                .mutate(vec![Mutation::Upsert {
21603                    table: "tasks".to_owned(),
21604                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
21605                    base_version: None,
21606                }])
21607                .expect("seed a row");
21608        }
21609        let mut client = SyncClient::open_path(
21610            "wiring".to_owned(),
21611            &previous_version_schema(2),
21612            previous_version_limits(),
21613            &path,
21614        )
21615        .expect("v2 bump captures");
21616        assert!(std::path::Path::new(&container).exists());
21617        let spec = PreviousVersionReadSpec {
21618            table: "tasks".to_owned(),
21619            ..Default::default()
21620        };
21621
21622        // §7.3.5 lease error ⇒ lease-inactive, and the read discards.
21623        recapture_previous_version(&client);
21624        client.lease_state = Some(LeaseState {
21625            lease_id: None,
21626            expires_at_ms: None,
21627            error_code: Some("lease.expired".to_owned()),
21628        });
21629        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
21630        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
21631        assert!(!snapshot.available);
21632        assert!(!std::path::Path::new(&container).exists());
21633
21634        // An expired lease uses the client clock (injected, no sleeps).
21635        recapture_previous_version(&client);
21636        client.set_now_ms(1_000);
21637        client.lease_state = Some(LeaseState {
21638            lease_id: Some("lease".to_owned()),
21639            expires_at_ms: Some(1_000),
21640            error_code: None,
21641        });
21642        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
21643        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
21644        assert!(!std::path::Path::new(&container).exists());
21645        client.lease_state = None;
21646
21647        // A revoked subscription ⇒ scope-revoked.
21648        recapture_previous_version(&client);
21649        client.subs.push(subscription(SubState::Revoked, 0, None));
21650        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
21651        assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
21652        assert!(!std::path::Path::new(&container).exists());
21653        client.subs.clear();
21654
21655        // Coverage completion: every ACTIVE subscription has a cursor and no
21656        // resume token — a still-resuming subscription is not completion.
21657        recapture_previous_version(&client);
21658        client
21659            .subs
21660            .push(subscription(SubState::Active, 0, Some("resume")));
21661        assert!(!client.previous_version_coverage_complete());
21662        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
21663        assert!(snapshot.available);
21664        assert_eq!(snapshot.previous_version, Some(2));
21665        assert_eq!(snapshot.rows.len(), 1);
21666
21667        recapture_previous_version(&client);
21668        client.subs[0].bootstrap_state = None;
21669        assert!(client.previous_version_coverage_complete());
21670        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
21671        assert_eq!(
21672            snapshot.reason,
21673            Some(PreviousVersionReason::CoverageComplete)
21674        );
21675        assert!(!std::path::Path::new(&container).exists());
21676        client.subs.clear();
21677
21678        // With no active subscription at all, coverage is NOT complete.
21679        assert!(!client.previous_version_coverage_complete());
21680
21681        drop(client);
21682        let _ = std::fs::remove_file(&path);
21683    }
21684
21685    /// RFC 0005 D7 lifetime drops. The read surface already proves the reasons
21686    /// come from live state; this proves the OTHER caller —
21687    /// `previous_version_lifetime_check`, the once-per-sync-round trigger —
21688    /// actually removes the container for lease-inactivity, coverage
21689    /// completion and scope revocation, and leaves it alone while the
21690    /// replacement bootstrap is still resuming.
21691    #[test]
21692    fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
21693        let path = std::env::temp_dir()
21694            .join(format!(
21695                "syncular-prev-lifetime-{}.db",
21696                uuid::Uuid::new_v4()
21697            ))
21698            .to_string_lossy()
21699            .into_owned();
21700        let container = format!(
21701            "{path}{}",
21702            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
21703        );
21704        let exists = || std::path::Path::new(&container).exists();
21705        {
21706            let mut setup = SyncClient::open_path(
21707                "lifetime".to_owned(),
21708                &previous_version_schema(1),
21709                previous_version_limits(),
21710                &path,
21711            )
21712            .expect("v1 install");
21713            setup
21714                .mutate(vec![Mutation::Upsert {
21715                    table: "tasks".to_owned(),
21716                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
21717                    base_version: None,
21718                }])
21719                .expect("seed a row");
21720        }
21721        let mut client = SyncClient::open_path(
21722            "lifetime".to_owned(),
21723            &previous_version_schema(2),
21724            previous_version_limits(),
21725            &path,
21726        )
21727        .expect("v2 bump captures");
21728        // Pin the clock so the 24h TTL never fires in this test (the TTL has
21729        // its own coverage); the capture record's createdAtMs is 1.
21730        client.set_now_ms(1_000);
21731        assert!(exists());
21732
21733        // §7.3.5 lease error ⇒ the lifetime check drops the container.
21734        client.lease_state = Some(LeaseState {
21735            lease_id: None,
21736            expires_at_ms: None,
21737            error_code: Some("lease.expired".to_owned()),
21738        });
21739        client.previous_version_lifetime_check();
21740        assert!(!exists());
21741        client.lease_state = None;
21742
21743        // §7.4.5 coverage completion (every active sub has a cursor and no
21744        // resume token) ⇒ drop.
21745        recapture_previous_version(&client);
21746        client.subs.push(subscription(SubState::Active, 0, None));
21747        client.previous_version_lifetime_check();
21748        assert!(!exists());
21749        client.subs.clear();
21750
21751        // §3.3 scope revocation ⇒ drop.
21752        recapture_previous_version(&client);
21753        client.subs.push(subscription(SubState::Revoked, 0, None));
21754        client.previous_version_lifetime_check();
21755        assert!(!exists());
21756        client.subs.clear();
21757
21758        // Negative control: a still-resuming active subscription is NOT
21759        // coverage completion, so the container survives the same call.
21760        recapture_previous_version(&client);
21761        client
21762            .subs
21763            .push(subscription(SubState::Active, 0, Some("resume")));
21764        client.previous_version_lifetime_check();
21765        assert!(exists());
21766        client.subs.clear();
21767
21768        drop(client);
21769        let _ = std::fs::remove_file(&path);
21770        let _ = std::fs::remove_file(&container);
21771        let _ = std::fs::remove_file(format!("{container}-wal"));
21772        let _ = std::fs::remove_file(format!("{container}-shm"));
21773    }
21774}