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 std::cell::{Cell, RefCell};
12use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
13
14use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
15use rusqlite::{Connection, OpenFlags, OptionalExtension};
16use serde::{Deserialize, Serialize};
17use serde_json::{Map, Value};
18use sha2::{Digest, Sha256};
19use ssp2::decode::WIRE_VERSION;
20use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
21use ssp2::primitives::RawJson;
22use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
23use ssp2::{
24    decode_message, encode_message, encode_presence_publish, parse_control, ControlMessage,
25    PresenceKind,
26};
27
28use crate::api::{
29    ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
30    ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
31    ClientDiagnosticsStorage, ClientLimits, CommandEffects, CommitOperation,
32    CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery, CommitOutcomeResolution,
33    CommitOutcomeStatus, ConflictRecord, CoverageSnapshot, DiagnosticLastChange,
34    DiagnosticLastRound, DiagnosticQueryFailure, DiagnosticRoundCounters, DiagnosticSubscription,
35    FetchedBlob, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, LocalDataPurgeTarget,
36    LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation, PresencePeer,
37    PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot, QueryValue,
38    RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RowState, SchemaFloor,
39    SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport, SyncStatusSnapshot, TableChange,
40    WindowBase, WindowChange, WindowCoverage, WindowState, WindowUnitRef,
41    CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
42    MAX_DIAGNOSTIC_QUERY_FAILURES,
43};
44use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
45#[cfg(feature = "bench-internals")]
46use crate::bench::{Phase, Recorder};
47use crate::previous_version::{
48    capture_previous_version_from_replica, first_incompatibility,
49    reconcile_previous_version_at_boot, set_local_schema_descriptor,
50    sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
51    PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
52};
53use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
54use crate::transport::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
55use crate::values::{
56    bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
57    encode_sparse_row_json, full_row_values, json_to_column_value, json_to_scope_map,
58    normalize_values_casing, render_row_id_json, scope_map_to_json, sort_scope_map,
59};
60
61/// §4.2 default: rows and SQLite images, both applied in committed chunks.
62/// Bit 3 (signed URLs, §5.4) is
63/// added per transport capability at request-build time.
64const DEFAULT_ACCEPT: u8 = 0b0111;
65const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
66const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
67const ACCEPT_SQLITE: u8 = 1 << 2;
68const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
69const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
70
71/// §7.4.1 persisted local schema-version marker (`_syncular_meta` key).
72pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
73const LOCAL_REVISION_KEY: &str = "localRevision";
74const CLIENT_ID_KEY: &str = "clientId";
75const LEASE_STATE_KEY: &str = "leaseState";
76const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
77const LOG_EPOCH_KEY: &str = "logEpoch";
78/// Persisted fail-closed quarantine marker: present while a security preflight
79/// is pending and `activateSecurity` has yet to run. Storing it in the replica
80/// keeps the gate with the data it protects, so a rebuilt host handle reopening
81/// the same file returns in preflight even though its in-memory state is fresh.
82const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
83const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
84const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
85/// §7.4.4 client-local code: a pending outbox commit cannot re-encode under
86/// the new schema after a bump. Never a wire code (§10.3).
87const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
88/// §5.11 client-local code: an encode at the push seam resolved no usable key
89/// id or named an unknown key. Never a wire code (§10.3).
90const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
91
92#[derive(Debug, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase", deny_unknown_fields)]
94struct PersistedLocalDataRebootstrapReceipt {
95    version: u8,
96    retained_commits: u64,
97    reset_subscriptions: u64,
98}
99
100fn invalid_local_rebootstrap_receipt() -> String {
101    "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
102}
103
104fn encode_local_rebootstrap_receipt(
105    retained_commits: usize,
106    reset_subscriptions: usize,
107) -> Result<String, String> {
108    let retained_commits =
109        u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
110    let reset_subscriptions =
111        u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
112    if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
113        return Err(invalid_local_rebootstrap_receipt());
114    }
115    serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
116        version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
117        retained_commits,
118        reset_subscriptions,
119    })
120    .map_err(|_| invalid_local_rebootstrap_receipt())
121}
122
123fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
124    // Pre-0.15.36 markers proved application but did not retain the receipt.
125    if value == "v1" {
126        return Ok((0, 0));
127    }
128    let receipt: PersistedLocalDataRebootstrapReceipt =
129        serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
130    if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
131        || receipt.retained_commits > MAX_JS_SAFE_INTEGER
132        || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
133    {
134        return Err(invalid_local_rebootstrap_receipt());
135    }
136    Ok((
137        usize::try_from(receipt.retained_commits)
138            .map_err(|_| invalid_local_rebootstrap_receipt())?,
139        usize::try_from(receipt.reset_subscriptions)
140            .map_err(|_| invalid_local_rebootstrap_receipt())?,
141    ))
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145enum SubState {
146    Active,
147    Revoked,
148    Failed,
149}
150
151#[cfg(test)]
152mod observation_tests {
153    use super::*;
154    use crate::native_transport::HostTransport;
155    use crate::values::encode_row_json;
156    use serde_json::json;
157
158    fn client() -> SyncClient {
159        SyncClient::new(
160            "retry-test".to_owned(),
161            &json!({
162                "version": 1,
163                "tables": [{
164                    "name": "tasks",
165                    "primaryKey": "id",
166                    "columns": [
167                        { "name": "id", "type": "string", "nullable": false },
168                        { "name": "project_id", "type": "string", "nullable": false }
169                    ],
170                    "scopes": [{ "pattern": "project:{project_id}" }]
171                }]
172            }),
173            ClientLimits::default(),
174        )
175        .expect("test client")
176    }
177
178    #[cfg(feature = "e2ee")]
179    #[test]
180    fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
181        let schema = json!({
182            "version": 1,
183            "tables": [{
184                "name": "secrets",
185                "primaryKey": "id",
186                "columns": [
187                    { "name": "id", "type": "string", "nullable": false },
188                    { "name": "encryption_key_id", "type": "string", "nullable": true },
189                    { "name": "note", "type": "bytes", "nullable": true,
190                      "encrypted": true, "declaredType": "string" }
191                ],
192                "scopes": []
193            }]
194        });
195        let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
196            .expect("test client");
197        let mut config = crate::values::EncryptionConfig::default();
198        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
199        config
200            .key_id_columns
201            .insert("secrets".to_owned(), "encryption_key_id".to_owned());
202        client.set_encryption(config);
203        // A patch on a locally absent row presents an encrypted column with no
204        // stored key id to fall back to. Authoring succeeds...
205        let commit_id = client
206            .patch(
207                "secrets",
208                "ghost",
209                Map::from_iter([("note".to_owned(), json!("x"))]),
210                None,
211            )
212            .expect("patch records without an author-time encrypt failure");
213        assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
214        // ...and the push seam raises a durable local rejection, not a throw.
215        assert!(client.drop_unencodable_outbox().unwrap());
216        assert!(client.pending_commit_ids().is_empty());
217        let rejections = client.rejections();
218        assert_eq!(rejections.len(), 1);
219        assert_eq!(rejections[0].code, "client.encrypt_failed");
220        assert_eq!(rejections[0].client_commit_id, commit_id);
221        assert_eq!(
222            client
223                .commit_outcome(&commit_id)
224                .unwrap()
225                .map(|outcome| outcome.status),
226            Some(CommitOutcomeStatus::Rejected)
227        );
228    }
229
230    #[test]
231    fn patch_with_a_scope_column_matches_the_stored_local_row() {
232        let mut client = client();
233        client.create_synced_tables().unwrap();
234        client
235            .conn
236            .execute(
237                "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
238                [],
239            )
240            .unwrap();
241
242        // §3.4 rule 5: a value equal to the stored local row is a no-op, so
243        // the scope column leaves the presence set.
244        client
245            .patch(
246                "tasks",
247                "t1",
248                Map::from_iter([
249                    ("project_id".to_owned(), json!("p1")),
250                    ("id".to_owned(), json!("t1")),
251                ]),
252                None,
253            )
254            .expect("an equal scope column is a no-op");
255        let values = client.outbox[0].ops[0]
256            .values
257            .clone()
258            .expect("the patch records values");
259        assert_eq!(values.get("id"), Some(&json!("t1")));
260        assert!(!values.contains_key("project_id"));
261
262        // A differing value stays rejected.
263        let differing = client.patch(
264            "tasks",
265            "t1",
266            Map::from_iter([("project_id".to_owned(), json!("p2"))]),
267            None,
268        );
269        assert!(differing
270            .expect_err("a differing scope column is rejected")
271            .contains("patch cannot write scope column"));
272
273        // An absent local row leaves nothing to prove equality against, so
274        // the patch fails closed.
275        let absent = client.patch(
276            "tasks",
277            "ghost",
278            Map::from_iter([("project_id".to_owned(), json!("p1"))]),
279            None,
280        );
281        assert!(absent
282            .expect_err("an absent local row is rejected")
283            .contains("patch cannot write scope column"));
284        assert_eq!(client.pending_commit_ids().len(), 1);
285    }
286
287    #[test]
288    fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
289        let schema = json!({
290            "version": 1,
291            "tables": [
292                {
293                    "name": "tenants",
294                    "primaryKey": "tenant_id",
295                    "columns": [
296                        { "name": "tenant_id", "type": "string", "nullable": false },
297                        { "name": "body", "type": "string", "nullable": false }
298                    ],
299                    "scopes": [{ "pattern": "tenant:{tenant_id}" }]
300                },
301                {
302                    "name": "buckets",
303                    "primaryKey": "id",
304                    "columns": [
305                        { "name": "id", "type": "string", "nullable": false },
306                        { "name": "bucket", "type": "float", "nullable": false },
307                        { "name": "body", "type": "string", "nullable": false }
308                    ],
309                    "scopes": [{ "pattern": "bucket:{bucket}" }]
310                }
311            ]
312        });
313        let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
314            .expect("test client");
315        client.create_synced_tables().unwrap();
316
317        // A primary key that is also a scope column with no local row: the
318        // sparse payload's key is the row id being patched, so its value is
319        // proven equal without a stored-row read.
320        client
321            .patch(
322                "tenants",
323                "t1",
324                Map::from_iter([
325                    ("tenant_id".to_owned(), json!("t1")),
326                    ("body".to_owned(), json!("created")),
327                ]),
328                None,
329            )
330            .expect("the primary-key scope column is proven equal by construction");
331
332        // A stored REAL scope value coerces from a supplied integer.
333        client
334            .conn
335            .execute(
336                "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
337                [],
338            )
339            .unwrap();
340        client
341            .patch(
342                "buckets",
343                "b1",
344                Map::from_iter([
345                    ("bucket".to_owned(), json!(2)),
346                    ("body".to_owned(), json!("coerced")),
347                ]),
348                None,
349            )
350            .expect("the stored REAL value coerces from a supplied integer");
351        let values = client.outbox[1].ops[0]
352            .values
353            .clone()
354            .expect("the patch records values");
355        assert!(!values.contains_key("bucket"));
356
357        // A differing value stays rejected.
358        let differing = client.patch(
359            "buckets",
360            "b1",
361            Map::from_iter([("bucket".to_owned(), json!(3))]),
362            None,
363        );
364        assert!(differing
365            .expect_err("a differing scope value is rejected")
366            .contains("patch cannot write scope column"));
367    }
368
369    #[cfg(feature = "e2ee")]
370    #[test]
371    fn stored_key_fallback_resolves_an_integer_primary_key() {
372        let schema = json!({
373            "version": 1,
374            "tables": [{
375                "name": "patients",
376                "primaryKey": "id",
377                "columns": [
378                    { "name": "id", "type": "integer", "nullable": false },
379                    { "name": "encryption_key_id", "type": "string", "nullable": true },
380                    { "name": "note", "type": "bytes", "nullable": true,
381                      "encrypted": true, "declaredType": "string" }
382                ],
383                "scopes": []
384            }]
385        });
386        let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
387            .expect("test client");
388        client.create_synced_tables().unwrap();
389        client
390            .conn
391            .execute(
392                "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
393                 VALUES (5, 'k1', NULL, 1)",
394                [],
395            )
396            .unwrap();
397        let mut config = crate::values::EncryptionConfig::default();
398        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
399        config
400            .key_id_columns
401            .insert("patients".to_owned(), "encryption_key_id".to_owned());
402        client.set_encryption(config);
403        let table = client.schema.table("patients").unwrap();
404        let fallback = client
405            .stored_key_fallback(table, "5")
406            .expect("the stored integer key resolves the fallback");
407        assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
408    }
409
410    #[test]
411    fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
412        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
413            {"name":"id","type":"string","nullable":false},
414            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
415        for state in ["absent", "cached", "pinned"] {
416            let path = std::env::temp_dir().join(format!(
417                "syncular-blob-stage-{}.sqlite",
418                uuid::Uuid::new_v4()
419            ));
420            let mut client = SyncClient::open_path(
421                "stage-test".into(),
422                &schema,
423                Default::default(),
424                path.to_str().unwrap(),
425            )
426            .unwrap();
427            let bytes = b"atomic stage";
428            if state != "absent" {
429                client
430                    .upload_blob(bytes, Some("text/plain".into()), None)
431                    .unwrap();
432                if state == "cached" {
433                    client
434                        .conn
435                        .execute("DELETE FROM _syncular_blob_uploads", [])
436                        .unwrap();
437                }
438            }
439            let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
440            client
441                .conn
442                .execute_batch(
443                    "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
444                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
445                 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
446                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
447                )
448                .unwrap();
449            let error = client
450                .upload_blob(bytes, Some("application/octet-stream".into()), None)
451                .unwrap_err();
452            assert!(error.contains("injected stage failure"), "{state}: {error}");
453            assert!(client.conn.is_autocommit());
454            let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
455            drop(client);
456            let mut reopened = SyncClient::open_path(
457                "stage-test".into(),
458                &schema,
459                Default::default(),
460                path.to_str().unwrap(),
461            )
462            .unwrap();
463            let durable = reopened
464                .query("SELECT * FROM _syncular_blobs", &[])
465                .unwrap();
466            reopened
467                .conn
468                .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
469                .unwrap();
470            let retried = reopened.upload_blob(bytes, None, None).unwrap();
471            assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
472            assert_eq!(
473                reopened
474                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
475                    .unwrap(),
476                vec![Map::from_iter([(
477                    "blob_id".into(),
478                    Value::from(blob_id_for(bytes)),
479                )])]
480            );
481            drop(reopened);
482            std::fs::remove_file(&path).unwrap();
483            assert_eq!(durable, bodies, "{state}: reopened body state");
484            assert_eq!(immediate, bodies, "{state}: immediate body state");
485        }
486    }
487
488    #[test]
489    fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
490        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
491            {"name":"id","type":"string","nullable":false},
492            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
493        for fault in [
494            "missing",
495            "body-type",
496            "length",
497            "hash",
498            "pin-metadata",
499            "read",
500            "pin-delete",
501        ] {
502            let path = std::env::temp_dir().join(format!(
503                "syncular-blob-upload-{}.sqlite",
504                uuid::Uuid::new_v4()
505            ));
506            let mut client = SyncClient::open_path(
507                "upload-test".into(),
508                &schema,
509                Default::default(),
510                path.to_str().unwrap(),
511            )
512            .unwrap();
513            let bytes = b"durable upload";
514            let ref_value = client
515                .upload_blob(bytes, Some("text/plain".into()), None)
516                .unwrap();
517            let commit = client
518                .mutate(vec![Mutation::Upsert {
519                    table: "attachments".into(),
520                    values: Map::from_iter([
521                        ("id".into(), Value::from("pending")),
522                        (
523                            "file".into(),
524                            Value::from(format!(
525                                "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
526                                blob_id_for(bytes),
527                                bytes.len()
528                            )),
529                        ),
530                    ]),
531                    base_version: None,
532                }])
533                .unwrap();
534            client
535                .conn
536                .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
537                .unwrap();
538            client.conn.execute_batch(match fault {
539                "missing" => "DELETE FROM _syncular_blobs",
540                "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
541                "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
542                "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
543                "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
544                "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
545                _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
546            }).unwrap();
547            let state = if fault == "read" {
548                Vec::new()
549            } else {
550                client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
551            };
552            let mut transport = CountingRealtimeTransport::default();
553            let error = client.flush_blob_uploads(&mut transport).unwrap_err();
554            assert_eq!(
555                error.code,
556                if matches!(fault, "read" | "pin-delete") {
557                    "client.failed"
558                } else {
559                    "sync.local_corrupt"
560                },
561                "{fault}"
562            );
563            assert_eq!(
564                transport.blob_uploads.len(),
565                usize::from(fault == "pin-delete"),
566                "{fault}: corrupt bytes must never be uploaded"
567            );
568            assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
569            if fault != "read" {
570                assert_eq!(
571                    client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
572                    state
573                );
574            } else {
575                client
576                    .conn
577                    .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
578                    .unwrap();
579            }
580            drop(client);
581            let mut reopened = SyncClient::open_path(
582                "upload-test".into(),
583                &schema,
584                Default::default(),
585                path.to_str().unwrap(),
586            )
587            .unwrap();
588            assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
589            if fault != "read" {
590                assert_eq!(
591                    reopened
592                        .query("SELECT * FROM _syncular_blobs", &[])
593                        .unwrap(),
594                    state
595                );
596            }
597            if fault == "pin-delete" {
598                reopened
599                    .conn
600                    .execute_batch("DROP TRIGGER fail_pin_delete")
601                    .unwrap();
602            }
603            if fault == "pin-metadata" {
604                reopened
605                    .conn
606                    .execute(
607                        "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
608                        [],
609                    )
610                    .unwrap();
611            }
612            reopened
613                .conn
614                .execute_batch(
615                    "DELETE FROM _syncular_blobs;
616                     INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
617                )
618                .unwrap();
619            transport.blob_uploads.clear();
620            reopened.flush_blob_uploads(&mut transport).unwrap();
621            assert_eq!(
622                transport.blob_uploads,
623                [(blob_id_for(bytes), bytes.to_vec())]
624            );
625            assert_eq!(
626                reopened
627                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
628                    .unwrap(),
629                Vec::<Map<String, Value>>::new()
630            );
631            assert_eq!(reopened.pending_commit_ids(), [commit]);
632            assert_eq!(
633                reopened
634                    .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
635                    .unwrap()
636                    .unwrap()
637                    .bytes,
638                bytes
639            );
640            drop(reopened);
641            std::fs::remove_file(path).unwrap();
642        }
643    }
644
645    #[test]
646    fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
647        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
648            {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
649            "scopes":[{"pattern":"project:{project_id}"}]}]});
650        for realtime in [false, true] {
651            for fault in [
652                "none",
653                "row",
654                "revision",
655                "commit",
656                "cursor",
657                "error",
658                "missing-end",
659            ] {
660                if realtime && fault == "missing-end" {
661                    continue;
662                }
663                let path = std::env::temp_dir()
664                    .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
665                let mut client = SyncClient::open_path(
666                    "frame-test".into(),
667                    &schema,
668                    Default::default(),
669                    path.to_str().unwrap(),
670                )
671                .unwrap();
672                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
673                client
674                    .subscribe(
675                        "tasks".into(),
676                        "tasks".into(),
677                        vec![("project_id".into(), vec!["p1".into()])],
678                        None,
679                    )
680                    .unwrap();
681                client.subs[0].cursor = 0;
682                client.subs[0].synced_once = true;
683                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
684                client.persist_sub(&client.subs[0]).unwrap();
685                client.realtime_state = RealtimeState::Connected;
686                client.realtime_reason_code = None;
687                client.drain_change_batches();
688                let mut transport = CountingRealtimeTransport::default();
689                let (_, meta) = client.build_request(false);
690                let mut response = Message {
691                    wire_version: WIRE_VERSION,
692                    msg_kind: MsgKind::Response,
693                    frames: vec![
694                        Frame::RespHeader {
695                            required_schema_version: None,
696                            latest_schema_version: None,
697                            log_epoch: Some("epoch-1".into()),
698                            reset_required: Some(false),
699                        },
700                        Frame::SubStart {
701                            id: "tasks".into(),
702                            status: SubStatus::Active,
703                            reason_code: "".into(),
704                            effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
705                            bootstrap: false,
706                        },
707                    ],
708                };
709                for (index, id) in ["first", "second"].iter().enumerate() {
710                    let values = Map::from_iter([
711                        ("id".into(), Value::from(*id)),
712                        ("project_id".into(), Value::from("p1")),
713                    ]);
714                    response.frames.push(Frame::Commit {
715                        commit_seq: index as i64 + 1,
716                        actor_id: "writer".into(),
717                        created_at_ms: 1,
718                        tables: vec!["tasks".into()],
719                        changes: vec![ssp2::model::Change {
720                            table_index: 0,
721                            row_id: (*id).into(),
722                            op: Op::Upsert,
723                            row_version: Some(index as i64 + 1),
724                            scopes: vec![("project_id".into(), "p1".into())],
725                            row: Some(
726                                encode_row_json(
727                                    client.schema.table("tasks").unwrap(),
728                                    id,
729                                    &values,
730                                    &client.encryption,
731                                )
732                                .unwrap(),
733                            ),
734                        }],
735                    });
736                }
737                response.frames.push(Frame::SubEnd {
738                    next_cursor: 2,
739                    bootstrap_state: None,
740                });
741                let valid = response.clone();
742                match fault {
743                    "row" => {
744                        let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
745                        let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
746                    },
747                    "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(),
748                    "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(),
749                    "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
750                    "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});},
751                    "missing-end" => {response.frames.truncate(3);},
752                    _ => {}
753                }
754                if realtime {
755                    client.on_realtime_binary(&mut transport, &encode_message(&response));
756                } else {
757                    assert_eq!(
758                        matches!(
759                            client.process_response(&mut transport, response, &meta),
760                            SyncOutcome::Ok(_)
761                        ),
762                        fault == "none",
763                        "{fault}"
764                    );
765                }
766                assert!(
767                    client.conn.is_autocommit(),
768                    "no open transaction after {realtime}/{fault}"
769                );
770                let count = if matches!(fault, "none" | "cursor") {
771                    2
772                } else {
773                    1
774                };
775                let expected_revision = count + u64::from(realtime && fault != "none");
776                assert_eq!(
777                    client.local_revision(),
778                    expected_revision,
779                    "{realtime}/{fault}"
780                );
781                let batches = client.drain_change_batches();
782                assert_eq!(
783                    batches.len(),
784                    expected_revision as usize,
785                    "{realtime}/{fault}"
786                );
787                let row_batches: Vec<_> = batches
788                    .iter()
789                    .filter(|batch| !batch.tables.is_empty())
790                    .collect();
791                assert_eq!(row_batches.len(), count as usize);
792                assert_eq!(
793                    row_batches
794                        .iter()
795                        .map(|b| b.revision.clone())
796                        .collect::<Vec<_>>(),
797                    (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
798                );
799                for table in ["tasks", "_syncular_base_tasks"] {
800                    assert_eq!(
801                        client
802                            .conn
803                            .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
804                                .get::<_, u64>(0))
805                            .unwrap(),
806                        count,
807                        "{realtime}/{fault}/{table}"
808                    );
809                }
810                assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
811                assert_eq!(
812                    transport
813                        .messages
814                        .iter()
815                        .filter(|m| m.contains("\"ack\""))
816                        .count(),
817                    usize::from(fault == "none")
818                );
819                if matches!(fault, "revision" | "commit" | "cursor") {
820                    client
821                        .conn
822                        .execute_batch("DROP TRIGGER fail_frame")
823                        .unwrap();
824                }
825                drop(client);
826                let mut reopened = SyncClient::open_path(
827                    "frame-test".into(),
828                    &schema,
829                    Default::default(),
830                    path.to_str().unwrap(),
831                )
832                .unwrap();
833                assert_eq!(reopened.local_revision(), expected_revision);
834                assert_eq!(
835                    reopened
836                        .query("SELECT id FROM tasks ORDER BY id", &[])
837                        .unwrap()
838                        .len(),
839                    count as usize
840                );
841                assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
842                let (_, meta) = reopened.build_request(false);
843                assert!(matches!(
844                    reopened.process_response(&mut transport, valid, &meta),
845                    SyncOutcome::Ok(_)
846                ));
847                assert_eq!(
848                    reopened
849                        .query("SELECT id FROM tasks ORDER BY id", &[])
850                        .unwrap()
851                        .len(),
852                    2
853                );
854                assert_eq!(reopened.subs[0].cursor, 2);
855                drop(reopened);
856                std::fs::remove_file(path).unwrap();
857            }
858        }
859    }
860
861    #[test]
862    fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
863        for status in [SubStatus::Reset, SubStatus::Revoked] {
864            for fault in ["subscription", "revision"] {
865                let mut client = client();
866                client
867                    .subscribe(
868                        "tasks".into(),
869                        "tasks".into(),
870                        vec![("project_id".into(), vec!["p1".into()])],
871                        None,
872                    )
873                    .unwrap();
874                client.subs[0].cursor = 41;
875                client.subs[0].synced_once = true;
876                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
877                client.persist_sub(&client.subs[0]).unwrap();
878                client
879                    .mutate(vec![Mutation::Upsert {
880                        table: "tasks".into(),
881                        values: Map::from_iter([
882                            ("id".into(), Value::from("pending")),
883                            ("project_id".into(), Value::from("p1")),
884                        ]),
885                        base_version: None,
886                    }])
887                    .unwrap();
888                let ids = client.pending_commit_ids();
889                let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
890                let revision = client.local_revision();
891                client.drain_change_batches();
892                let (_, meta) = client.build_request(false);
893                client.conn.execute_batch(if fault == "subscription" {
894                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
895                } else {
896                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
897                }).unwrap();
898                let mut transport = CountingRealtimeTransport::default();
899                let mut report = SyncReport::default();
900                // Reset changes window completeness and therefore needs a revision.
901                if status == SubStatus::Reset {
902                    client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
903                }
904                let result = client.process_section(
905                    &mut transport,
906                    "tasks",
907                    status,
908                    "sync.scope_revoked",
909                    vec![],
910                    vec![],
911                    Some((42, None)),
912                    &meta,
913                    &mut report,
914                );
915                assert!(result.is_err(), "{status:?}/{fault}");
916                assert!(client.conn.is_autocommit());
917                assert_eq!(client.subs[0].cursor, 41);
918                assert_eq!(client.subs[0].state, SubState::Active);
919                assert_eq!(client.pending_commit_ids(), ids);
920                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
921                assert!(client.rejections.is_empty());
922                assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
923                assert_eq!(client.local_revision(), revision);
924                assert!(client.drain_change_batches().is_empty());
925                assert!(report.revoked.is_empty() && report.resets.is_empty());
926            }
927        }
928    }
929
930    #[test]
931    fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
932        let mut client = client();
933        client
934            .subscribe(
935                "tasks".into(),
936                "tasks".into(),
937                vec![("project_id".into(), vec!["p1".into()])],
938                None,
939            )
940            .unwrap();
941        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
942        client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
943            CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
944        let row = |id: &str| ssp2::segment::SegmentRow {
945            server_version: 1,
946            values: vec![
947                Some(ColumnValue::String(id.into())),
948                Some(ColumnValue::String("p1".into())),
949            ],
950        };
951        let segment = RowsSegment {
952            table: "tasks".into(),
953            schema_version: 1,
954            columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
955            blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
956        };
957        assert!(client.apply_segment(0, &segment, true).is_err());
958        assert!(client.conn.is_autocommit());
959        assert_eq!(
960            client
961                .query("SELECT id FROM tasks ORDER BY id", &[])
962                .unwrap(),
963            vec![Map::from_iter([("id".into(), Value::from("first"))])]
964        );
965        assert_eq!(client.local_revision(), 1);
966        assert_eq!(client.drain_change_batches().len(), 1);
967        assert_eq!(client.subs[0].cursor, -1);
968        client
969            .conn
970            .execute_batch("DROP TRIGGER fail_block")
971            .unwrap();
972        assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
973        assert_eq!(client.local_revision(), 3);
974        assert_eq!(client.drain_change_batches().len(), 2);
975    }
976
977    #[test]
978    fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
979        assert_eq!(
980            client().diagnostics_storage().blob_cache_bytes_approx,
981            Some(0)
982        );
983        let client = SyncClient::new(
984            "diagnostic-storage".to_owned(),
985            &json!({
986                "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
987                    "columns": [{"name": "id", "type": "string", "nullable": false},
988                        {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
989            }),
990            ClientLimits {
991                blob_cache_max_bytes: Some(5),
992                ..Default::default()
993            },
994        )
995        .unwrap();
996        let empty = client.diagnostics_storage();
997        assert_eq!(empty.status, "healthy");
998        assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
999        assert_eq!(empty.retained_outcome_entries, Some(0));
1000        assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1001        assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1002        client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1003            INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1004                VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1005            INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1006                VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1007        let written = client.diagnostics_storage();
1008        assert_eq!(written.status, "pressure");
1009        assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1010        assert_eq!(written.retained_outcome_entries, Some(2));
1011        assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1012        assert_eq!(written.blob_cache_bytes_approx, Some(8));
1013        assert_eq!(
1014            written.pressure_reason_code.as_deref(),
1015            Some("client.blob_cache_over_limit")
1016        );
1017        client
1018            .conn
1019            .execute_batch(
1020                "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1021            DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1022            )
1023            .unwrap();
1024        let staged = client.diagnostics_storage();
1025        assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1026        assert_eq!(staged.retained_outcome_entries, Some(0));
1027        assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1028        assert_eq!(staged.status, "healthy");
1029        client.conn.execute_batch("ROLLBACK").unwrap();
1030        assert_eq!(client.diagnostics_storage(), written);
1031        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1032        let unreadable = client.diagnostics_storage();
1033        assert_eq!(unreadable.status, "unreadable");
1034        assert!(unreadable.database_bytes_approx.is_none());
1035        assert!(unreadable.pending_outbox_bytes_approx.is_none());
1036        assert!(unreadable.retained_outcome_entries.is_none());
1037        assert!(unreadable.retained_outcome_bytes_approx.is_none());
1038        assert!(unreadable.blob_cache_bytes_approx.is_none());
1039        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1040        let restored = client.diagnostics_storage();
1041        assert_eq!(restored.status, written.status);
1042        assert_eq!(
1043            restored.retained_outcome_bytes_approx,
1044            written.retained_outcome_bytes_approx
1045        );
1046        client.conn.set_prepared_statement_cache_capacity(0);
1047        assert_eq!(client.diagnostics_storage(), restored);
1048    }
1049
1050    #[test]
1051    fn background_retry_deadlines_back_off_and_reset() {
1052        let mut client = client();
1053        client.schedule_background_retry();
1054        assert!(matches!(
1055            client.drain_sync_intents().as_slice(),
1056            [SyncIntent::Background { delay_ms: 250 }]
1057        ));
1058        client.schedule_background_retry();
1059        assert!(matches!(
1060            client.drain_sync_intents().as_slice(),
1061            [SyncIntent::Background { delay_ms: 500 }]
1062        ));
1063        client.reset_background_retry();
1064        client.schedule_background_retry();
1065        assert!(matches!(
1066            client.drain_sync_intents().as_slice(),
1067            [SyncIntent::Background { delay_ms: 250 }]
1068        ));
1069    }
1070
1071    #[test]
1072    fn log_epoch_reset_requests_an_immediate_follow_up_round() {
1073        let mut client = client();
1074        client
1075            .subscribe(
1076                "epoch-tasks".to_owned(),
1077                "tasks".to_owned(),
1078                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1079                None,
1080            )
1081            .expect("subscribe");
1082        client.drain_sync_intents();
1083
1084        client
1085            .run_log_epoch_reset("epoch-2")
1086            .expect("reset partition log epoch");
1087
1088        assert!(matches!(
1089            client.drain_sync_intents().as_slice(),
1090            [SyncIntent::Interactive]
1091        ));
1092    }
1093
1094    #[derive(Default)]
1095    struct CountingRealtimeTransport {
1096        connects: usize,
1097        closes: usize,
1098        messages: Vec<String>,
1099        blob_uploads: Vec<(String, Vec<u8>)>,
1100        blob_download: Option<Vec<u8>>,
1101        sync_calls: usize,
1102        realtime_sync_calls: usize,
1103        refuse_connect: bool,
1104    }
1105
1106    impl Transport for CountingRealtimeTransport {
1107        fn blob_upload(
1108            &mut self,
1109            blob_id: &str,
1110            bytes: &[u8],
1111            _media_type: Option<&str>,
1112        ) -> Result<(), TransportError> {
1113            self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1114            Ok(())
1115        }
1116
1117        fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1118            self.blob_download
1119                .clone()
1120                .map(BlobDownload::Bytes)
1121                .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1122        }
1123
1124        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1125            self.sync_calls += 1;
1126            Err(TransportError::new("sync.transport_failed", "offline"))
1127        }
1128
1129        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1130            self.realtime_sync_calls += 1;
1131            Err(TransportError::new("sync.transport_failed", "offline"))
1132        }
1133
1134        fn download_segment(
1135            &mut self,
1136            _request: &SegmentRequest,
1137            _on_progress: &mut dyn FnMut(u64),
1138        ) -> Result<Vec<u8>, TransportError> {
1139            Err(TransportError::new("sync.transport_failed", "offline"))
1140        }
1141
1142        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1143            self.connects += 1;
1144            if self.refuse_connect {
1145                return Err(TransportError::new("transport.failed", "refused"));
1146            }
1147            Ok(())
1148        }
1149
1150        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1151            self.messages.push(text.to_owned());
1152            Ok(())
1153        }
1154
1155        fn realtime_close(&mut self) -> Result<(), TransportError> {
1156            self.closes += 1;
1157            Ok(())
1158        }
1159    }
1160
1161    #[test]
1162    fn blob_results_own_downloaded_bytes() {
1163        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1164            {"name":"id","type":"string","nullable":false},
1165            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1166        let bytes = b"owned typed result".to_vec();
1167        let blob_id = blob_id_for(&bytes);
1168        let mut transport = CountingRealtimeTransport {
1169            blob_download: Some(bytes.clone()),
1170            ..Default::default()
1171        };
1172        let result = {
1173            let mut client =
1174                SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1175                    .unwrap();
1176            let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1177            assert_eq!(result.blob_id, blob_id);
1178            assert_eq!(result.byte_length, bytes.len() as i64);
1179            assert_eq!(result.bytes, bytes);
1180            assert_eq!(result.media_type, None);
1181            assert_eq!(
1182                client
1183                    .conn
1184                    .query_row(
1185                        "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1186                        rusqlite::params![blob_id],
1187                        |row| row.get::<_, i64>(0),
1188                    )
1189                    .unwrap(),
1190                0
1191            );
1192            result
1193        };
1194        assert_eq!(result.bytes, bytes);
1195
1196        let mut client =
1197            SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1198        assert_eq!(
1199            client
1200                .fetch_blob_bytes(&mut transport, "not json")
1201                .unwrap_err(),
1202            (
1203                "client.failed".to_owned(),
1204                "blob ref is not JSON".to_owned()
1205            )
1206        );
1207    }
1208
1209    #[test]
1210    fn visible_blob_references_survive_cache_trimming() {
1211        let schema = json!({"version":1,"tables":[
1212            {"name":"attachments","primaryKey":"id","columns":[
1213                {"name":"id","type":"string","nullable":false},
1214                {"name":"body","type":"blob_ref","nullable":true},
1215                {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
1216            {"name":"avatars","primaryKey":"id","columns":[
1217                {"name":"id","type":"string","nullable":false},
1218                {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
1219        ]});
1220        let bytes = b"referenced above cap".to_vec();
1221        let blob_id = blob_id_for(&bytes);
1222        let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
1223        let mut client = SyncClient::new(
1224            "visible-blob-refs".to_owned(),
1225            &schema,
1226            ClientLimits {
1227                blob_cache_max_bytes: Some(1),
1228                ..Default::default()
1229            },
1230        )
1231        .unwrap();
1232        client.conn.execute(
1233            "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
1234            rusqlite::params![reference, reference, reference, reference],
1235        ).unwrap();
1236        client
1237            .conn
1238            .execute(
1239                "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
1240                rusqlite::params![reference],
1241            )
1242            .unwrap();
1243        client.conn.execute(
1244            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
1245            [],
1246        ).unwrap();
1247        let mut transport = CountingRealtimeTransport {
1248            blob_download: Some(bytes.clone()),
1249            ..Default::default()
1250        };
1251
1252        assert_eq!(
1253            client
1254                .fetch_blob_bytes(&mut transport, &blob_id)
1255                .unwrap()
1256                .bytes,
1257            bytes
1258        );
1259        let rows = client
1260            .conn
1261            .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1262            .unwrap()
1263            .query_map([], |row| row.get::<_, String>(0))
1264            .unwrap()
1265            .collect::<Result<Vec<_>, _>>()
1266            .unwrap();
1267        assert_eq!(rows, vec![blob_id]);
1268    }
1269
1270    #[test]
1271    fn realtime_connection_ownership_is_idempotent() {
1272        let mut client = client();
1273        let mut transport = CountingRealtimeTransport {
1274            connects: 0,
1275            closes: 0,
1276            ..Default::default()
1277        };
1278        client
1279            .connect_realtime(&mut transport)
1280            .expect("first connect");
1281        client
1282            .connect_realtime(&mut transport)
1283            .expect("idempotent connect");
1284        assert_eq!(transport.connects, 1);
1285        client.disconnect_realtime(&mut transport);
1286        client.disconnect_realtime(&mut transport);
1287        assert_eq!(transport.closes, 1);
1288        client
1289            .connect_realtime(&mut transport)
1290            .expect("deliberate reconnect");
1291        assert_eq!(transport.connects, 2);
1292    }
1293
1294    #[test]
1295    fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
1296        let mut client = client();
1297        client
1298            .set_realtime_policy(RealtimePolicy::Required)
1299            .expect("set policy");
1300        let mut transport = CountingRealtimeTransport::default();
1301        match client.sync(&mut transport) {
1302            SyncOutcome::RealtimeUnavailable {
1303                state,
1304                reason_code,
1305                retry_delay_ms,
1306            } => {
1307                assert_eq!(state, RealtimeState::Disconnected);
1308                assert_eq!(reason_code, None);
1309                assert_eq!(retry_delay_ms, 250);
1310            }
1311            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1312        }
1313        assert_eq!(transport.sync_calls, 0);
1314        assert!(matches!(
1315            client.drain_sync_intents().as_slice(),
1316            [SyncIntent::Background { delay_ms: 250 }]
1317        ));
1318        let snapshot = client
1319            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1320            .expect("diagnostics");
1321        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1322        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
1323        assert_eq!(snapshot.host.realtime_reason_code, None);
1324        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1325        assert_eq!(
1326            snapshot
1327                .last_round
1328                .as_ref()
1329                .and_then(|round| round.error_code.clone()),
1330            Some(REALTIME_UNAVAILABLE_CODE.to_owned())
1331        );
1332    }
1333
1334    #[test]
1335    fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
1336        let mut client = client();
1337        client
1338            .set_realtime_policy(RealtimePolicy::Required)
1339            .expect("set policy");
1340        let mut transport = CountingRealtimeTransport {
1341            refuse_connect: true,
1342            ..Default::default()
1343        };
1344        assert!(client.connect_realtime(&mut transport).is_err());
1345        assert_eq!(client.realtime_state(), RealtimeState::Refused);
1346        let snapshot = client
1347            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1348            .expect("diagnostics");
1349        assert_eq!(
1350            snapshot.host.realtime_reason_code.as_deref(),
1351            Some("transport.failed")
1352        );
1353        match client.sync(&mut transport) {
1354            SyncOutcome::RealtimeUnavailable {
1355                state, reason_code, ..
1356            } => {
1357                assert_eq!(state, RealtimeState::Refused);
1358                assert_eq!(reason_code.as_deref(), Some("transport.failed"));
1359            }
1360            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1361        }
1362        assert_eq!(transport.sync_calls, 0);
1363        assert_eq!(transport.realtime_sync_calls, 0);
1364    }
1365
1366    #[test]
1367    fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
1368        let mut client = client();
1369        client
1370            .set_realtime_policy(RealtimePolicy::Required)
1371            .expect("set policy");
1372        let mut transport = CountingRealtimeTransport::default();
1373        client.connect_realtime(&mut transport).expect("connect");
1374        assert_eq!(client.realtime_state(), RealtimeState::Connected);
1375        assert!(
1376            client.set_realtime_policy(RealtimePolicy::Off).is_err(),
1377            "a policy change cannot silently re-route a connected socket"
1378        );
1379        assert!(matches!(
1380            client.sync(&mut transport),
1381            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
1382        ));
1383        assert_eq!(client.realtime_state(), RealtimeState::Lost);
1384        assert_eq!(transport.realtime_sync_calls, 1);
1385        assert_eq!(transport.sync_calls, 0);
1386        let snapshot = client
1387            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1388            .expect("diagnostics");
1389        assert_eq!(
1390            snapshot.host.realtime_reason_code.as_deref(),
1391            Some("sync.transport_failed")
1392        );
1393        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1394        match client.sync(&mut transport) {
1395            SyncOutcome::RealtimeUnavailable {
1396                state, reason_code, ..
1397            } => {
1398                assert_eq!(state, RealtimeState::Lost);
1399                assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
1400            }
1401            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1402        }
1403        assert_eq!(transport.sync_calls, 0);
1404        client.disconnect_realtime(&mut transport);
1405        assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
1406        assert_eq!(transport.closes, 1);
1407        client.connect_realtime(&mut transport).expect("reconnect");
1408        assert_eq!(client.realtime_state(), RealtimeState::Connected);
1409        assert_eq!(transport.connects, 2);
1410    }
1411
1412    #[test]
1413    fn off_policy_uses_http_and_refuses_an_explicit_connect() {
1414        let mut client = client();
1415        client
1416            .set_realtime_policy(RealtimePolicy::Off)
1417            .expect("set policy");
1418        let mut transport = CountingRealtimeTransport::default();
1419        assert!(client.connect_realtime(&mut transport).is_err());
1420        assert_eq!(client.realtime_state(), RealtimeState::Disabled);
1421        assert!(matches!(
1422            client.sync(&mut transport),
1423            SyncOutcome::Failed { .. }
1424        ));
1425        assert_eq!(transport.sync_calls, 1);
1426        assert_eq!(transport.realtime_sync_calls, 0);
1427        let snapshot = client
1428            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1429            .expect("diagnostics");
1430        assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
1431        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
1432    }
1433
1434    #[test]
1435    fn optional_policy_keeps_the_http_round() {
1436        let mut client = client();
1437        let mut transport = CountingRealtimeTransport::default();
1438        assert!(matches!(
1439            client.sync(&mut transport),
1440            SyncOutcome::Failed { .. }
1441        ));
1442        assert_eq!(transport.sync_calls, 1);
1443        assert_eq!(transport.realtime_sync_calls, 0);
1444        let snapshot = client
1445            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1446            .expect("diagnostics");
1447        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1448        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
1449    }
1450
1451    #[test]
1452    fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1453        let mut client = client();
1454        client
1455            .subscribe(
1456                "repair-tasks".to_owned(),
1457                "tasks".to_owned(),
1458                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1459                None,
1460            )
1461            .expect("subscribe");
1462        {
1463            let sub = client
1464                .subs
1465                .iter_mut()
1466                .find(|sub| sub.id == "repair-tasks")
1467                .expect("subscription");
1468            sub.cursor = 42;
1469            sub.synced_once = true;
1470            let persisted = sub.clone();
1471            client.persist_sub(&persisted).unwrap();
1472        }
1473        for table in ["_syncular_base_tasks", "tasks"] {
1474            client
1475                .conn
1476                .execute(
1477                    &format!(
1478                        "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1479                    ),
1480                    rusqlite::params!["server-row", "p1"],
1481                )
1482                .expect("seed server row");
1483        }
1484        let pending = client
1485            .mutate(vec![Mutation::Upsert {
1486                table: "tasks".to_owned(),
1487                values: Map::from_iter([
1488                    ("id".to_owned(), Value::from("offline-row")),
1489                    ("project_id".to_owned(), Value::from("p1")),
1490                ]),
1491                base_version: None,
1492            }])
1493            .expect("queue offline work");
1494        client.drain_change_batches();
1495        client.drain_sync_intents();
1496
1497        assert_eq!(
1498            client
1499                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1500                    rebootstrap_id: "support-case-001".to_owned(),
1501                })
1502                .expect("rebootstrap"),
1503            LocalDataRebootstrapResult {
1504                already_applied: false,
1505                retained_commits: 1,
1506                reset_subscriptions: 1,
1507            }
1508        );
1509        let visible_ids = client
1510            .conn
1511            .prepare("SELECT id FROM tasks ORDER BY id")
1512            .expect("prepare visible ids")
1513            .query_map([], |row| row.get::<_, String>(0))
1514            .expect("query visible ids")
1515            .collect::<Result<Vec<_>, _>>()
1516            .expect("collect visible ids");
1517        assert_eq!(visible_ids, vec!["offline-row"]);
1518        assert_eq!(client.pending_commit_ids(), vec![pending]);
1519        assert_eq!(
1520            client
1521                .subscription_state("repair-tasks")
1522                .expect("subscription")
1523                .cursor,
1524            -1
1525        );
1526        assert!(client.upgrading());
1527        assert!(client.sync_needed());
1528        assert!(matches!(
1529            client.drain_sync_intents().as_slice(),
1530            [SyncIntent::Interactive]
1531        ));
1532        assert_eq!(client.drain_change_batches().len(), 1);
1533
1534        assert_eq!(
1535            client
1536                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1537                    rebootstrap_id: "support-case-001".to_owned(),
1538                })
1539                .expect("idempotent retry"),
1540            LocalDataRebootstrapResult {
1541                already_applied: true,
1542                retained_commits: 1,
1543                reset_subscriptions: 1,
1544            }
1545        );
1546        assert!(client.drain_change_batches().is_empty());
1547    }
1548
1549    #[test]
1550    fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1551        let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1552        assert_eq!(
1553            decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1554            (3, 4)
1555        );
1556        assert_eq!(
1557            decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1558            (0, 0)
1559        );
1560        for malformed in [
1561            "",
1562            "{}",
1563            r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1564            r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1565            r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1566        ] {
1567            assert_eq!(
1568                decode_local_rebootstrap_receipt(malformed)
1569                    .expect_err("malformed receipt must fail"),
1570                "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1571            );
1572        }
1573    }
1574
1575    #[test]
1576    fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1577        let path = std::env::temp_dir().join(format!(
1578            "syncular-rebootstrap-receipt-{}.db",
1579            uuid::Uuid::new_v4()
1580        ));
1581        let schema = json!({
1582            "version": 1,
1583            "tables": [{
1584                "name": "tasks",
1585                "primaryKey": "id",
1586                "columns": [
1587                    { "name": "id", "type": "string", "nullable": false },
1588                    { "name": "project_id", "type": "string", "nullable": false }
1589                ],
1590                "scopes": [{ "pattern": "project:{project_id}" }]
1591            }]
1592        });
1593        let path_string = path.to_str().expect("UTF-8 temp path");
1594
1595        {
1596            let mut first = SyncClient::open_path(
1597                "repair-restart-client".to_owned(),
1598                &schema,
1599                ClientLimits::default(),
1600                path_string,
1601            )
1602            .expect("first open");
1603            first
1604                .subscribe(
1605                    "repair-tasks".to_owned(),
1606                    "tasks".to_owned(),
1607                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1608                    None,
1609                )
1610                .expect("subscribe");
1611            first
1612                .mutate(vec![Mutation::Upsert {
1613                    table: "tasks".to_owned(),
1614                    values: Map::from_iter([
1615                        ("id".to_owned(), Value::from("offline-row")),
1616                        ("project_id".to_owned(), Value::from("p1")),
1617                    ]),
1618                    base_version: None,
1619                }])
1620                .expect("queue offline work");
1621            assert_eq!(
1622                first
1623                    .rebootstrap_local_data(&LocalDataRebootstrapInput {
1624                        rebootstrap_id: "restart-receipt".to_owned(),
1625                    })
1626                    .expect("first rebootstrap"),
1627                LocalDataRebootstrapResult {
1628                    already_applied: false,
1629                    retained_commits: 1,
1630                    reset_subscriptions: 1,
1631                }
1632            );
1633        }
1634
1635        let mut reopened = SyncClient::open_path(
1636            "repair-restart-client".to_owned(),
1637            &schema,
1638            ClientLimits::default(),
1639            path_string,
1640        )
1641        .expect("reopen");
1642        reopened.drain_change_batches();
1643        reopened.drain_sync_intents();
1644        assert_eq!(
1645            reopened
1646                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1647                    rebootstrap_id: "restart-receipt".to_owned(),
1648                })
1649                .expect("receipt replay"),
1650            LocalDataRebootstrapResult {
1651                already_applied: true,
1652                retained_commits: 1,
1653                reset_subscriptions: 1,
1654            }
1655        );
1656        assert!(reopened.drain_change_batches().is_empty());
1657        assert!(reopened.drain_sync_intents().is_empty());
1658        drop(reopened);
1659        std::fs::remove_file(path).expect("remove temp database");
1660    }
1661
1662    #[test]
1663    fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1664        let mut malformed = client();
1665        malformed
1666            .subscribe(
1667                "repair-tasks".to_owned(),
1668                "tasks".to_owned(),
1669                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1670                None,
1671            )
1672            .expect("subscribe");
1673        malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1674        malformed.drain_change_batches();
1675        malformed.drain_sync_intents();
1676        let malformed_error = malformed
1677            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1678                rebootstrap_id: "malformed".to_owned(),
1679            })
1680            .expect_err("malformed receipt must fail closed");
1681        assert_eq!(
1682            malformed_error,
1683            "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1684        );
1685        assert!(!malformed.upgrading());
1686        assert_eq!(
1687            malformed
1688                .subscription_state("repair-tasks")
1689                .expect("unchanged subscription")
1690                .cursor,
1691            -1
1692        );
1693        assert!(malformed.drain_change_batches().is_empty());
1694        assert!(malformed.drain_sync_intents().is_empty());
1695
1696        let mut unreadable = client();
1697        unreadable
1698            .conn
1699            .execute(
1700                "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1701                rusqlite::params!["server-row", "p1"],
1702            )
1703            .expect("seed visible row");
1704        unreadable
1705            .conn
1706            .execute("DROP TABLE _syncular_meta", [])
1707            .expect("break marker storage");
1708        let unreadable_error = unreadable
1709            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1710                rebootstrap_id: "unreadable".to_owned(),
1711            })
1712            .expect_err("unreadable marker storage must fail closed");
1713        assert_eq!(
1714            unreadable_error,
1715            "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1716        );
1717        let visible_rows = unreadable
1718            .conn
1719            .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1720            .expect("visible projection remains");
1721        assert_eq!(visible_rows, 1);
1722        assert!(!unreadable.upgrading());
1723        assert!(unreadable.drain_change_batches().is_empty());
1724        assert!(unreadable.drain_sync_intents().is_empty());
1725    }
1726
1727    #[test]
1728    fn local_rebootstrap_cannot_bypass_schema_floor() {
1729        let mut client = client();
1730        client.set_schema_floor(Some(SchemaFloor {
1731            required_schema_version: Some(2),
1732            latest_schema_version: Some(2),
1733        }));
1734        let error = client
1735            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1736                rebootstrap_id: "blocked-floor".to_owned(),
1737            })
1738            .expect_err("schema floor must block repair");
1739        assert!(error.contains("cannot bypass an active schema-floor stop"));
1740    }
1741
1742    #[test]
1743    fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
1744        let mut client = client();
1745        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1746        const COMMIT_COUNT: usize = 32;
1747
1748        for index in 0..COMMIT_COUNT {
1749            client
1750                .mutate(vec![Mutation::Upsert {
1751                    table: "tasks".to_owned(),
1752                    values: Map::from_iter([
1753                        ("id".to_owned(), Value::from(format!("task-{index}"))),
1754                        ("project_id".to_owned(), Value::from("project-1")),
1755                    ]),
1756                    base_version: None,
1757                }])
1758                .expect("queue commit");
1759        }
1760
1761        let (_, request_meta) = client.build_request(false);
1762        assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
1763        let mut frames = vec![Frame::RespHeader {
1764            required_schema_version: None,
1765            latest_schema_version: None,
1766            log_epoch: Some("epoch-1".to_owned()),
1767            reset_required: Some(false),
1768        }];
1769        frames.extend(request_meta.pushed_ids.iter().enumerate().map(
1770            |(index, client_commit_id)| Frame::PushResult {
1771                client_commit_id: client_commit_id.clone(),
1772                status: PushStatus::Applied,
1773                commit_seq: Some(index as i64 + 1),
1774                results: vec![OpResult::Applied { op_index: 0 }],
1775            },
1776        ));
1777        let response = Message {
1778            wire_version: WIRE_VERSION,
1779            msg_kind: MsgKind::Response,
1780            frames,
1781        };
1782        let mut transport =
1783            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
1784
1785        client.overlay_rebuild_count.set(0);
1786        client.outcome_prune_count.set(0);
1787        let outcome = client.process_response(&mut transport, response, &request_meta);
1788        assert!(matches!(outcome, SyncOutcome::Ok(_)));
1789        assert!(client.pending_commit_ids().is_empty());
1790        assert_eq!(
1791            client.overlay_rebuild_count.get(),
1792            1,
1793            "one response must reconcile its acknowledged commits with one overlay rebuild"
1794        );
1795        assert_eq!(
1796            client.outcome_prune_count.get(),
1797            1,
1798            "one response must enforce outcome retention once"
1799        );
1800    }
1801
1802    #[test]
1803    fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
1804        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
1805            "columns": [
1806                {"name":"id", "type":"string", "nullable":false},
1807                {"name":"project_id", "type":"string", "nullable":false},
1808                {"name":"title", "type":"string", "nullable":false}
1809            ], "scopes": [{"pattern":"project:{project_id}"}],
1810            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1811            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
1812        }] });
1813        let mut incremental =
1814            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1815        let mut reference =
1816            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1817        let tables = vec!["tasks".to_owned()];
1818        for index in 0..32 {
1819            let id = format!("task-{}", index % 8);
1820            let values = Map::from_iter([
1821                ("id".to_owned(), Value::from(id.clone())),
1822                (
1823                    "project_id".to_owned(),
1824                    Value::from(format!("p{}", index % 2)),
1825                ),
1826                ("title".to_owned(), Value::from(format!("needle{index}"))),
1827            ]);
1828            let payload = encode_row_json(
1829                incremental.schema.table("tasks").unwrap(),
1830                &id,
1831                &values,
1832                &incremental.encryption,
1833            )
1834            .unwrap();
1835            let change = ssp2::model::Change {
1836                table_index: 0,
1837                row_id: id,
1838                op: if index % 5 == 0 {
1839                    Op::Delete
1840                } else {
1841                    Op::Upsert
1842                },
1843                row_version: Some(index + 1),
1844                scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
1845                row: Some(payload),
1846            };
1847            reference.overlay_dirty.set(true);
1848            for instance in [&mut incremental, &mut reference] {
1849                instance.begin_observation("mirror_test").unwrap();
1850                let mut batch = ChangeAccumulator::default();
1851                instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
1852                instance
1853                    .apply_commit_changes(&tables, std::slice::from_ref(&change))
1854                    .unwrap();
1855                instance.rebuild_overlay_if_dirty();
1856                instance.finish_observation("mirror_test", batch).unwrap();
1857            }
1858            for sql in [
1859                "SELECT * FROM tasks ORDER BY id",
1860                "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
1861            ] {
1862                assert_eq!(
1863                    incremental.query(sql, &[]).unwrap(),
1864                    reference.query(sql, &[]).unwrap()
1865                );
1866            }
1867            assert_eq!(incremental.local_revision(), reference.local_revision());
1868            assert_eq!(
1869                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1870                serde_json::to_value(reference.drain_change_batches()).unwrap()
1871            );
1872        }
1873        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
1874        assert_eq!(reference.overlay_rebuild_count.get(), 32);
1875    }
1876
1877    #[test]
1878    fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
1879        let cases = [
1880            (
1881                "string",
1882                vec![
1883                    json!("1"),
1884                    json!("01"),
1885                    json!(""),
1886                    json!("nul\u{0}id"),
1887                    json!("é"),
1888                ],
1889            ),
1890            (
1891                "integer",
1892                vec![
1893                    json!(i64::MIN),
1894                    json!(-1),
1895                    json!(0),
1896                    json!(1),
1897                    json!(i64::MAX),
1898                ],
1899            ),
1900            ("boolean", vec![json!(false), json!(true)]),
1901            ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
1902        ];
1903        for (kind, ids) in cases {
1904            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1905                "columns":[{"name":"id","type":kind,"nullable":false},
1906                    {"name":"project_id","type":"string","nullable":false}],
1907                "scopes":[{"pattern":"project:{project_id}"}]}]});
1908            let mut client =
1909                SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
1910            for (index, id) in ids.iter().enumerate() {
1911                let table = client.schema.table("tasks").unwrap();
1912                client
1913                    .write_base_row(
1914                        "tasks",
1915                        &vec![
1916                            json_to_column_value(&table.columns[0], Some(id)).unwrap(),
1917                            Some(ColumnValue::String(format!("p{index}"))),
1918                        ],
1919                        1,
1920                    )
1921                    .unwrap();
1922            }
1923            client.rebuild_overlay_if_dirty();
1924            for base in [true, false] {
1925                let full = if base {
1926                    base_table("tasks")
1927                } else {
1928                    visible_table("tasks")
1929                };
1930                let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
1931                for row_id in [
1932                    "1",
1933                    "01",
1934                    "1.0",
1935                    "1.0000000000000002",
1936                    "+1",
1937                    "1e0",
1938                    " 1",
1939                    "-1",
1940                    "0",
1941                    "9223372036854775807",
1942                    "-9223372036854775808",
1943                    "true",
1944                    "false",
1945                    "",
1946                    "nul\u{0}id",
1947                    "é",
1948                    "null",
1949                    "{\"a\":1}",
1950                ] {
1951                    let old_sql =
1952                        format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
1953                    let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
1954                    let old = client
1955                        .conn
1956                        .prepare(&old_sql)
1957                        .unwrap()
1958                        .query_map([row_id], |row| row.get::<_, String>(0))
1959                        .unwrap()
1960                        .collect::<Result<Vec<_>, _>>()
1961                        .unwrap();
1962                    let new = client
1963                        .conn
1964                        .prepare_cached(&new_sql)
1965                        .unwrap()
1966                        .query_map([row_id], |row| row.get::<_, String>(0))
1967                        .unwrap()
1968                        .collect::<Result<Vec<_>, _>>()
1969                        .unwrap();
1970                    assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
1971                    let mut batch = ChangeAccumulator::default();
1972                    assert_eq!(
1973                        client.record_row_scopes(&mut batch, "tasks", row_id, base),
1974                        !old.is_empty()
1975                    );
1976                    let expected = old.first().map(|scope| {
1977                        BTreeMap::from([(
1978                            "tasks".to_owned(),
1979                            Some(BTreeSet::from([format!("project:{scope}")])),
1980                        )])
1981                    });
1982                    assert_eq!(batch.tables, expected.unwrap_or_default());
1983                    client
1984                        .conn
1985                        .execute_batch("SAVEPOINT lookup_delete")
1986                        .unwrap();
1987                    if base {
1988                        client.delete_base_row("tasks", row_id).unwrap();
1989                    } else {
1990                        client.apply_outbox_ops(&[OutboxOp {
1991                            upsert: false,
1992                            table: "tasks".into(),
1993                            row_id: row_id.into(),
1994                            base_version: None,
1995                            values: None,
1996                        }]);
1997                    }
1998                    let remaining: i64 = client
1999                        .conn
2000                        .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
2001                            row.get(0)
2002                        })
2003                        .unwrap();
2004                    assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
2005                    client
2006                        .conn
2007                        .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
2008                        .unwrap();
2009                    client.overlay_dirty.set(false);
2010                }
2011                for (predicate, expected) in [
2012                    ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
2013                    (predicate, "SEARCH"),
2014                ] {
2015                    let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
2016                    let details = client
2017                        .conn
2018                        .prepare(&sql)
2019                        .unwrap()
2020                        .query_map(["1"], |row| row.get::<_, String>(3))
2021                        .unwrap()
2022                        .collect::<Result<Vec<_>, _>>()
2023                        .unwrap();
2024                    assert!(
2025                        details.iter().any(|detail| detail.contains(expected)),
2026                        "{kind}: {details:?}"
2027                    );
2028                }
2029            }
2030        }
2031    }
2032
2033    #[test]
2034    fn primary_keys_reject_every_ineligible_column_type() {
2035        // §2.4: a `rowId` is a string, so a primary key type whose string form
2036        // differs per renderer, or cannot be reproduced by local storage
2037        // comparison, is a schema error rather than a silent lookup miss.
2038        for ty in ["float", "bytes", "crdt", "blob_ref"] {
2039            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2040                "columns":[{"name":"id","type":ty,"nullable":false},
2041                    {"name":"project_id","type":"string","nullable":false}],
2042                "scopes":[{"pattern":"project:{project_id}"}]}]});
2043            let error =
2044                match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
2045                    Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
2046                    Err(error) => error,
2047                };
2048            assert!(
2049                error.contains("has an ineligible column type (§2.4)"),
2050                "{ty}: {error}"
2051            );
2052        }
2053        for ty in ["string", "integer", "boolean", "json"] {
2054            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2055                "columns":[{"name":"id","type":ty,"nullable":false},
2056                    {"name":"project_id","type":"string","nullable":false}],
2057                "scopes":[{"pattern":"project:{project_id}"}]}]});
2058            SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
2059                .unwrap_or_else(|error| panic!("{ty}: {error}"));
2060        }
2061    }
2062
2063    #[test]
2064    fn pending_row_reconciliation_matches_full_fifo_replay() {
2065        for unique in [false, true] {
2066            let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
2067                "name": name, "primaryKey": "id", "columns": [
2068                    {"name":"id", "type":"string", "nullable":false},
2069                    {"name":"project_id", "type":"string", "nullable":false},
2070                    {"name":"title", "type":"string", "nullable":false}],
2071                "scopes": [{"pattern":"project:{project_id}"}],
2072                "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
2073                "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
2074            })))});
2075            let mut incremental =
2076                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2077            let mut reference =
2078                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2079            let tables = vec!["tasks".to_owned(), "notes".to_owned()];
2080            for instance in [&mut incremental, &mut reference] {
2081                for table in &tables {
2082                    for index in 0..12 {
2083                        instance
2084                            .write_base_row(
2085                                table,
2086                                &vec![
2087                                    Some(ColumnValue::String(format!("row{index}"))),
2088                                    Some(ColumnValue::String("p0".into())),
2089                                    Some(ColumnValue::String(format!("base{index}"))),
2090                                ],
2091                                1,
2092                            )
2093                            .unwrap();
2094                    }
2095                }
2096                instance.rebuild_overlay_if_dirty();
2097            }
2098            for index in 0..80 {
2099                let mutations = (0..3)
2100                    .map(|offset| {
2101                        let table = tables[(index + offset) % 2].clone();
2102                        let id = format!("row{}", (index + offset) % 16);
2103                        if (index + offset) % 7 == 0 {
2104                            Mutation::Delete {
2105                                table,
2106                                row_id: id,
2107                                base_version: None,
2108                            }
2109                        } else {
2110                            Mutation::Upsert {
2111                                table,
2112                                values: Map::from_iter([
2113                                    ("id".into(), Value::from(id)),
2114                                    ("project_id".into(), Value::from(format!("p{}", index % 3))),
2115                                    (
2116                                        "title".into(),
2117                                        Value::from(format!("needle{}", (index + offset) % 16)),
2118                                    ),
2119                                ]),
2120                                base_version: None,
2121                            }
2122                        }
2123                    })
2124                    .collect::<Vec<_>>();
2125                for instance in [&mut incremental, &mut reference] {
2126                    instance.mutate(mutations.clone()).unwrap();
2127                }
2128                // Most frames touch only the table without unique constraints.
2129                // Mixed frames must retain full replay for both tables.
2130                let changes = (0..3)
2131                    .map(|offset| {
2132                        let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
2133                        let id = format!("row{}", (index + offset) % 16);
2134                        let values = Map::from_iter([
2135                            ("id".into(), Value::from(id.clone())),
2136                            (
2137                                "project_id".into(),
2138                                Value::from(format!("p{}", (index + 1) % 3)),
2139                            ),
2140                            (
2141                                "title".into(),
2142                                Value::from(format!("remote{index}_{offset}")),
2143                            ),
2144                        ]);
2145                        ssp2::model::Change {
2146                            table_index: table_index as u16,
2147                            row_id: id.clone(),
2148                            op: if (index + offset) % 5 == 0 {
2149                                Op::Delete
2150                            } else {
2151                                Op::Upsert
2152                            },
2153                            row_version: Some(index as i64 + 2),
2154                            scopes: vec![],
2155                            row: Some(
2156                                encode_row_json(
2157                                    incremental.schema.table(&tables[table_index]).unwrap(),
2158                                    &id,
2159                                    &values,
2160                                    &incremental.encryption,
2161                                )
2162                                .unwrap(),
2163                            ),
2164                        }
2165                    })
2166                    .collect::<Vec<_>>();
2167                reference.overlay_dirty.set(true);
2168                incremental.apply_commit_frame(&tables, &changes).unwrap();
2169                reference.apply_commit_frame(&tables, &changes).unwrap();
2170                for table in &tables {
2171                    for sql in [format!("SELECT * FROM {table} ORDER BY id"),
2172                        format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
2173                        format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
2174                        format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
2175                        assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
2176                    }
2177                }
2178                assert_eq!(incremental.local_revision(), reference.local_revision());
2179                assert_eq!(
2180                    serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2181                    serde_json::to_value(reference.drain_change_batches()).unwrap()
2182                );
2183            }
2184            assert_eq!(
2185                incremental.overlay_rebuild_count.get(),
2186                if unique { 81 } else { 21 }
2187            );
2188            assert_eq!(reference.overlay_rebuild_count.get(), 81);
2189        }
2190    }
2191
2192    #[test]
2193    fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
2194        let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
2195            "columns":[{"name":"id", "type":"string", "nullable":false},
2196                {"name":"title", "type":"string", "nullable":false}],
2197            "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
2198        let mut client =
2199            SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
2200        for (id, title) in [("a", "original"), ("b", "occupied")] {
2201            client
2202                .write_base_row(
2203                    "tasks",
2204                    &vec![
2205                        Some(ColumnValue::String(id.into())),
2206                        Some(ColumnValue::String(title.into())),
2207                    ],
2208                    1,
2209                )
2210                .unwrap();
2211        }
2212        client.rebuild_overlay_if_dirty();
2213        let pending = client
2214            .mutate(vec![Mutation::Upsert {
2215                table: "tasks".into(),
2216                values: Map::from_iter([
2217                    ("id".into(), Value::from("a")),
2218                    ("title".into(), Value::from("available")),
2219                ]),
2220                base_version: None,
2221            }])
2222            .unwrap();
2223        assert_eq!(
2224            client
2225                .conn
2226                .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2227                    .get::<_, String>(
2228                    0
2229                ))
2230                .unwrap(),
2231            "available"
2232        );
2233        for (title, expected) in [("available", "original"), ("occupied", "available")] {
2234            let change = ssp2::model::Change {
2235                table_index: 0,
2236                row_id: "b".into(),
2237                op: Op::Upsert,
2238                row_version: Some(2),
2239                scopes: vec![],
2240                row: Some(
2241                    encode_row_json(
2242                        client.schema.table("tasks").unwrap(),
2243                        "b",
2244                        &Map::from_iter([
2245                            ("id".into(), Value::from("b")),
2246                            ("title".into(), Value::from(title)),
2247                        ]),
2248                        &client.encryption,
2249                    )
2250                    .unwrap(),
2251                ),
2252            };
2253            client
2254                .apply_commit_frame(&["tasks".into()], &[change])
2255                .unwrap();
2256            assert_eq!(
2257                client
2258                    .conn
2259                    .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2260                        .get::<_, String>(
2261                        0
2262                    ))
2263                    .unwrap(),
2264                expected
2265            );
2266            assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
2267        }
2268        assert_eq!(client.overlay_rebuild_count.get(), 3);
2269    }
2270
2271    #[test]
2272    fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
2273        for failure in ["visible", "revision", "commit"] {
2274            let mut client = client();
2275            let id = client
2276                .mutate(vec![Mutation::Upsert {
2277                    table: "tasks".into(),
2278                    values: Map::from_iter([
2279                        ("id".into(), Value::from("t1")),
2280                        ("project_id".into(), Value::from("pending")),
2281                    ]),
2282                    base_version: None,
2283                }])
2284                .unwrap();
2285            client.drain_change_batches();
2286            let revision = client.local_revision();
2287            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
2288            let trigger = match failure {
2289                "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
2290                "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"),
2291                _ => "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(),
2292            };
2293            client.conn.execute_batch(&trigger).unwrap();
2294            let change = ssp2::model::Change {
2295                table_index: 0,
2296                row_id: "t1".into(),
2297                op: Op::Upsert,
2298                row_version: Some(2),
2299                scopes: vec![],
2300                row: Some(
2301                    encode_row_json(
2302                        client.schema.table("tasks").unwrap(),
2303                        "t1",
2304                        &Map::from_iter([
2305                            ("id".into(), Value::from("t1")),
2306                            ("project_id".into(), Value::from("remote")),
2307                        ]),
2308                        &client.encryption,
2309                    )
2310                    .unwrap(),
2311                ),
2312            };
2313            assert!(client
2314                .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change))
2315                .is_err());
2316            assert!(client.conn.is_autocommit());
2317            assert!(!client.overlay_dirty.get());
2318            assert_eq!(client.local_revision(), revision);
2319            assert!(client.drain_change_batches().is_empty());
2320            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
2321            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
2322            assert!(client
2323                .query("SELECT * FROM _syncular_base_tasks", &[])
2324                .unwrap()
2325                .is_empty());
2326            client
2327                .conn
2328                .execute_batch("DROP TRIGGER fail_pending")
2329                .unwrap();
2330            client
2331                .apply_commit_frame(&["tasks".into()], &[change])
2332                .unwrap();
2333            assert_eq!(client.local_revision(), revision + 1);
2334            assert_eq!(client.pending_commit_ids(), vec![id]);
2335            // The overlay keeps the pending write visible; the row now carries
2336            // the server row's version it is layered on (§7.1).
2337            let mut expected = before[0].clone();
2338            expected.insert("_syncular_version".to_owned(), Value::from(2));
2339            assert_eq!(
2340                client.query("SELECT * FROM tasks", &[]).unwrap(),
2341                vec![expected]
2342            );
2343            assert_eq!(
2344                client
2345                    .conn
2346                    .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
2347                        .get::<_, String>(0))
2348                    .unwrap(),
2349                "remote"
2350            );
2351        }
2352    }
2353
2354    #[test]
2355    fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
2356        let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
2357            "columns": [
2358                {"name":"id", "type":"string", "nullable":false},
2359                {"name":"project_id", "type":"string", "nullable":false},
2360                {"name":"title", "type":"string", "nullable":false}],
2361            "scopes": [{"pattern":"project:{project_id}"}],
2362            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2363            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
2364        let mut incremental =
2365            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2366        let mut reference =
2367            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2368        for instance in [&mut incremental, &mut reference] {
2369            for index in 0..16 {
2370                instance
2371                    .write_base_row(
2372                        "tasks",
2373                        &vec![
2374                            Some(ColumnValue::String(format!("task{index}"))),
2375                            Some(ColumnValue::String("p0".to_owned())),
2376                            Some(ColumnValue::String(format!("base{index}"))),
2377                        ],
2378                        1,
2379                    )
2380                    .unwrap();
2381            }
2382            instance.rebuild_overlay_if_dirty();
2383            instance.overlay_rebuild_count.set(0);
2384        }
2385        for index in 0..100 {
2386            let mutations = (0..1 + index % 4)
2387                .map(|offset| {
2388                    let id = format!("task{}", (index + offset) % 24);
2389                    if (index + offset) % 7 == 0 {
2390                        Mutation::Delete {
2391                            table: "tasks".to_owned(),
2392                            row_id: id,
2393                            base_version: None,
2394                        }
2395                    } else {
2396                        Mutation::Upsert {
2397                            table: "tasks".to_owned(),
2398                            values: Map::from_iter([
2399                                ("id".to_owned(), Value::from(id)),
2400                                (
2401                                    "project_id".to_owned(),
2402                                    Value::from(format!("p{}", index % 2)),
2403                                ),
2404                                (
2405                                    "title".to_owned(),
2406                                    Value::from(format!("needle{}", (index + offset) % 24)),
2407                                ),
2408                            ]),
2409                            base_version: None,
2410                        }
2411                    }
2412                })
2413                .collect::<Vec<_>>();
2414            reference.overlay_dirty.set(true);
2415            incremental.mutate(mutations.clone()).unwrap();
2416            reference.mutate(mutations).unwrap();
2417            for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2418                "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
2419                assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
2420            }
2421            assert_eq!(incremental.local_revision(), reference.local_revision());
2422            assert_eq!(
2423                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2424                serde_json::to_value(reference.drain_change_batches()).unwrap()
2425            );
2426        }
2427        assert_eq!(incremental.outbox.len(), 100);
2428        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2429        assert_eq!(reference.overlay_rebuild_count.get(), 100);
2430    }
2431
2432    #[test]
2433    fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
2434        for fail_at in ["outbox", "revision"] {
2435            let mut client = client();
2436            let values = Map::from_iter([
2437                ("id".to_owned(), Value::from("t1")),
2438                ("project_id".to_owned(), Value::from("p1")),
2439            ]);
2440            let mutation = Mutation::Upsert {
2441                table: "tasks".to_owned(),
2442                values,
2443                base_version: None,
2444            };
2445            let trigger = if fail_at == "outbox" {
2446                "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
2447            } else {
2448                format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
2449            };
2450            client.conn.execute_batch(&trigger).unwrap();
2451            let revision = client.local_revision();
2452            assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
2453            assert_eq!(client.local_revision(), revision);
2454            assert!(client.outbox.is_empty());
2455            assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2456            assert_eq!(
2457                client
2458                    .conn
2459                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
2460                        .get::<_, i64>(0))
2461                    .unwrap(),
2462                0
2463            );
2464            assert!(client.drain_change_batches().is_empty());
2465            assert!(!client.overlay_dirty.get());
2466            client
2467                .conn
2468                .execute_batch("DROP TRIGGER fail_append")
2469                .unwrap();
2470            client.mutate(vec![mutation]).unwrap();
2471            assert_eq!(client.outbox.len(), 1);
2472            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
2473        }
2474    }
2475
2476    #[test]
2477    fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
2478        let mut client = client();
2479        let tables = vec!["tasks".to_owned(), "missing".to_owned()];
2480        let values = Map::from_iter([
2481            ("id".to_owned(), Value::from("t1")),
2482            ("project_id".to_owned(), Value::from("p1")),
2483        ]);
2484        let payload = encode_row_json(
2485            client.schema.table("tasks").unwrap(),
2486            "t1",
2487            &values,
2488            &client.encryption,
2489        )
2490        .unwrap();
2491        let first = ssp2::model::Change {
2492            table_index: 0,
2493            row_id: "t1".to_owned(),
2494            op: Op::Upsert,
2495            row_version: Some(1),
2496            scopes: vec![("project_id".to_owned(), "p1".to_owned())],
2497            row: Some(payload),
2498        };
2499        let mut invalid = first.clone();
2500        invalid.table_index = 1;
2501        let revision = client.local_revision();
2502        client.begin_observation("failed_mirror").unwrap();
2503        assert!(client
2504            .apply_commit_changes(&tables, &[first, invalid])
2505            .is_err());
2506        client.rollback_observation("failed_mirror");
2507        client.rebuild_overlay_if_dirty();
2508        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2509        assert_eq!(
2510            client
2511                .conn
2512                .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
2513                    .get::<_, i64>(
2514                    0
2515                ))
2516                .unwrap(),
2517            0
2518        );
2519        assert_eq!(client.local_revision(), revision);
2520        assert!(client.drain_change_batches().is_empty());
2521    }
2522
2523    #[test]
2524    fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
2525        for failure in ["journal", "revision", "commit", "later-frame"] {
2526            let mut client = client();
2527            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2528            for id in ["first", "second"] {
2529                client
2530                    .mutate(vec![Mutation::Upsert {
2531                        table: "tasks".to_owned(),
2532                        values: Map::from_iter([
2533                            ("id".to_owned(), Value::from(id)),
2534                            ("project_id".to_owned(), Value::from("p1")),
2535                        ]),
2536                        base_version: None,
2537                    }])
2538                    .unwrap();
2539            }
2540            let (_, meta) = client.build_request(false);
2541            // A malicious or stale response must not drain an unsent local commit.
2542            client
2543                .mutate(vec![Mutation::Upsert {
2544                    table: "tasks".to_owned(),
2545                    values: Map::from_iter([
2546                        ("id".to_owned(), Value::from("later")),
2547                        ("project_id".to_owned(), Value::from("p1")),
2548                    ]),
2549                    base_version: None,
2550                }])
2551                .unwrap();
2552            let ids = client.pending_commit_ids();
2553            let rows = client
2554                .query("SELECT * FROM tasks ORDER BY id", &[])
2555                .unwrap();
2556            let revision = client.local_revision();
2557            client.drain_change_batches();
2558            if failure != "later-frame" {
2559                client.conn.execute_batch(match failure {
2560                    "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",
2561                    "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",
2562                    _ => "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",
2563                }).unwrap();
2564            }
2565            let mut response = Message {
2566                wire_version: WIRE_VERSION,
2567                msg_kind: MsgKind::Response,
2568                frames: vec![Frame::RespHeader {
2569                    required_schema_version: None,
2570                    latest_schema_version: None,
2571                    log_epoch: Some("epoch-1".to_owned()),
2572                    reset_required: Some(false),
2573                }],
2574            };
2575            for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
2576                response.frames.push(Frame::PushResult {
2577                    client_commit_id: id.to_owned(),
2578                    status: PushStatus::Applied,
2579                    commit_seq: Some(1),
2580                    results: vec![OpResult::Applied { op_index: 0 }],
2581                });
2582            }
2583            if failure == "later-frame" {
2584                response.frames.push(Frame::Error {
2585                    code: "sync.invalid_request".to_owned(),
2586                    message: "later error".to_owned(),
2587                    category: "protocol".to_owned(),
2588                    retryable: false,
2589                    recommended_action: "retry".to_owned(),
2590                    details: None,
2591                });
2592            }
2593            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2594            let outcome = client.process_response(&mut transport, response.clone(), &meta);
2595            assert!(
2596                matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
2597            );
2598            if failure == "later-frame" {
2599                assert_eq!(client.pending_commit_ids(), ids[2..]);
2600                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2601                assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
2602                let batches = client.drain_change_batches();
2603                assert_eq!(batches.len(), 1);
2604                assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
2605                assert_eq!(client.local_revision(), revision + 1);
2606                response.frames.pop();
2607            } else {
2608                assert_eq!(client.pending_commit_ids(), ids);
2609                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2610                assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
2611                assert_eq!(client.local_revision(), revision);
2612                assert!(client.drain_change_batches().is_empty());
2613                assert_eq!(
2614                    client
2615                        .query("SELECT * FROM tasks ORDER BY id", &[])
2616                        .unwrap(),
2617                    rows
2618                );
2619                client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
2620            }
2621            for frame in &mut response.frames {
2622                if let Frame::PushResult { status, .. } = frame {
2623                    *status = PushStatus::Cached;
2624                }
2625            }
2626            let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
2627            else {
2628                panic!("retry failed");
2629            };
2630            assert_eq!(
2631                report.applied,
2632                if failure == "later-frame" {
2633                    vec![]
2634                } else {
2635                    ids[..2].to_vec()
2636                }
2637            );
2638            assert_eq!(client.pending_commit_ids(), ids[2..]);
2639            let batches = client.drain_change_batches();
2640            assert_eq!(batches.len(), usize::from(failure != "later-frame"));
2641            if let Some(batch) = batches.first() {
2642                assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
2643            }
2644            assert_eq!(client.local_revision(), revision + 1);
2645        }
2646    }
2647
2648    #[test]
2649    fn failed_push_result_preserves_memory_disk_and_retry_identity() {
2650        for failure in ["revision", "journal", "commit"] {
2651            for status in ["applied", "cached", "rejected", "conflict"] {
2652                let mut client = client();
2653                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2654                for id in ["first", "later"] {
2655                    client
2656                        .mutate(vec![Mutation::Upsert {
2657                            table: "tasks".to_owned(),
2658                            values: Map::from_iter([
2659                                ("id".to_owned(), Value::from(id)),
2660                                ("project_id".to_owned(), Value::from("p1")),
2661                            ]),
2662                            base_version: None,
2663                        }])
2664                        .unwrap();
2665                }
2666                let (_, meta) = client.build_request(false);
2667                let ids = client.pending_commit_ids();
2668                let rows = client
2669                    .query("SELECT * FROM tasks ORDER BY id", &[])
2670                    .unwrap();
2671                let revision = client.local_revision();
2672                client.drain_change_batches();
2673                let (push_status, results) = match status {
2674                    "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
2675                    "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
2676                    "conflict" => (
2677                        PushStatus::Rejected,
2678                        vec![OpResult::Conflict {
2679                            op_index: 0,
2680                            code: "sync.version_conflict".to_owned(),
2681                            message: "conflict".to_owned(),
2682                            server_version: 1,
2683                            server_row: encode_row_json(
2684                                client.schema.table("tasks").unwrap(),
2685                                "first",
2686                                &Map::from_iter([
2687                                    ("id".to_owned(), Value::from("first")),
2688                                    ("project_id".to_owned(), Value::from("p1")),
2689                                ]),
2690                                &client.encryption,
2691                            )
2692                            .unwrap(),
2693                            conflict_columns: Vec::new(),
2694                        }],
2695                    ),
2696                    _ => (
2697                        PushStatus::Rejected,
2698                        vec![OpResult::Error {
2699                            op_index: 0,
2700                            code: "sync.validation_failed".to_owned(),
2701                            message: "rejected".to_owned(),
2702                            retryable: false,
2703                        }],
2704                    ),
2705                };
2706                let response = Message {
2707                    wire_version: WIRE_VERSION,
2708                    msg_kind: MsgKind::Response,
2709                    frames: vec![
2710                        Frame::RespHeader {
2711                            required_schema_version: None,
2712                            latest_schema_version: None,
2713                            log_epoch: Some("epoch-1".to_owned()),
2714                            reset_required: Some(false),
2715                        },
2716                        Frame::PushResult {
2717                            client_commit_id: ids[0].clone(),
2718                            status: push_status,
2719                            commit_seq: Some(1),
2720                            results,
2721                        },
2722                    ],
2723                };
2724                client.conn.execute_batch(match failure {
2725                    "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
2726                    "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
2727                    _ => "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",
2728                }).unwrap();
2729                let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2730                let outcome = client.process_response(&mut transport, response.clone(), &meta);
2731                assert_eq!(
2732                    client.pending_commit_ids(),
2733                    ids,
2734                    "{failure}/{status}: pending identity"
2735                );
2736                assert_eq!(
2737                    client.local_revision(),
2738                    revision,
2739                    "{failure}/{status}: revision"
2740                );
2741                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2742                assert!(client.conflicts().is_empty());
2743                assert!(client.rejections().is_empty());
2744                assert!(client.drain_change_batches().is_empty());
2745                assert_eq!(
2746                    client
2747                        .query("SELECT * FROM tasks ORDER BY id", &[])
2748                        .unwrap(),
2749                    rows
2750                );
2751                assert!(
2752                    matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
2753                    "{failure}/{status}: failed round"
2754                );
2755                client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
2756                assert!(matches!(
2757                    client.process_response(&mut transport, response, &meta),
2758                    SyncOutcome::Ok(_)
2759                ));
2760                assert_eq!(client.pending_commit_ids(), ids[1..]);
2761                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2762                assert_eq!(client.drain_change_batches().len(), 1);
2763            }
2764        }
2765    }
2766
2767    #[test]
2768    fn mixed_push_results_reconcile_and_prune_once_per_response() {
2769        let mut client = client();
2770        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2771        for index in 0..4 {
2772            client
2773                .mutate(vec![Mutation::Upsert {
2774                    table: "tasks".to_owned(),
2775                    values: Map::from_iter([
2776                        ("id".to_owned(), Value::from(format!("task-{index}"))),
2777                        ("project_id".to_owned(), Value::from("project-1")),
2778                    ]),
2779                    base_version: None,
2780                }])
2781                .expect("queue commit");
2782        }
2783
2784        let (_, request_meta) = client.build_request(false);
2785        let ids = &request_meta.pushed_ids;
2786        assert_eq!(ids.len(), 4);
2787        let response = Message {
2788            wire_version: WIRE_VERSION,
2789            msg_kind: MsgKind::Response,
2790            frames: vec![
2791                Frame::RespHeader {
2792                    required_schema_version: None,
2793                    latest_schema_version: None,
2794                    log_epoch: Some("epoch-1".to_owned()),
2795                    reset_required: Some(false),
2796                },
2797                Frame::PushResult {
2798                    client_commit_id: ids[0].clone(),
2799                    status: PushStatus::Applied,
2800                    commit_seq: Some(1),
2801                    results: vec![OpResult::Applied { op_index: 0 }],
2802                },
2803                Frame::PushResult {
2804                    client_commit_id: ids[1].clone(),
2805                    status: PushStatus::Cached,
2806                    commit_seq: Some(2),
2807                    results: vec![OpResult::Applied { op_index: 0 }],
2808                },
2809                Frame::PushResult {
2810                    client_commit_id: ids[2].clone(),
2811                    status: PushStatus::Rejected,
2812                    commit_seq: None,
2813                    results: vec![OpResult::Error {
2814                        op_index: 0,
2815                        code: "sync.validation_failed".to_owned(),
2816                        message: "rejected".to_owned(),
2817                        retryable: false,
2818                    }],
2819                },
2820                Frame::PushResult {
2821                    client_commit_id: ids[3].clone(),
2822                    status: PushStatus::Rejected,
2823                    commit_seq: None,
2824                    results: vec![OpResult::Error {
2825                        op_index: 0,
2826                        code: "sync.idempotency_cache_miss".to_owned(),
2827                        message: "retry".to_owned(),
2828                        retryable: true,
2829                    }],
2830                },
2831            ],
2832        };
2833        let mut transport =
2834            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
2835
2836        client.overlay_rebuild_count.set(0);
2837        client.outcome_prune_count.set(0);
2838        let outcome = client.process_response(&mut transport, response, &request_meta);
2839        let SyncOutcome::Ok(report) = outcome else {
2840            panic!("mixed push-result response failed");
2841        };
2842
2843        assert_eq!(report.applied, ids[..2]);
2844        assert_eq!(report.rejected, ids[2..3]);
2845        assert_eq!(report.retryable, ids[3..4]);
2846        assert_eq!(client.pending_commit_ids(), ids[3..4]);
2847        assert_eq!(
2848            client
2849                .query("SELECT id FROM tasks ORDER BY id", &[])
2850                .expect("query visible overlay"),
2851            vec![Map::from_iter([("id".to_owned(), Value::from("task-3"))])]
2852        );
2853        assert_eq!(client.overlay_rebuild_count.get(), 1);
2854        assert_eq!(client.outcome_prune_count.get(), 1);
2855    }
2856
2857    #[test]
2858    fn secondary_unique_collision_preserves_existing_synced_row() {
2859        let client = SyncClient::new(
2860            "unique-upsert-test".to_owned(),
2861            &json!({
2862                "version": 1,
2863                "tables": [{
2864                    "name": "tasks",
2865                    "primaryKey": "id",
2866                    "columns": [
2867                        { "name": "id", "type": "string", "nullable": false },
2868                        { "name": "project_id", "type": "string", "nullable": false },
2869                        { "name": "title", "type": "string", "nullable": false }
2870                    ],
2871                    "scopes": [{ "pattern": "project:{project_id}" }],
2872                    "indexes": [{
2873                        "name": "tasks_by_project_title",
2874                        "columns": ["project_id", "title"],
2875                        "unique": true
2876                    }]
2877                }]
2878            }),
2879            ClientLimits::default(),
2880        )
2881        .expect("test client");
2882        let table = client.schema.table("tasks").expect("tasks table");
2883        let sql = client.insert_row_sql(&base_table("tasks"), table);
2884
2885        client
2886            .conn
2887            .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
2888            .expect("insert first row");
2889        client
2890            .conn
2891            .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
2892            .expect("update same primary key");
2893        client
2894            .conn
2895            .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
2896            .expect("insert second row");
2897        assert!(client
2898            .conn
2899            .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
2900            .is_err());
2901
2902        let rows = client
2903            .conn
2904            .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
2905            .expect("prepare rows")
2906            .query_map([], |row| {
2907                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2908            })
2909            .expect("query rows")
2910            .collect::<Result<Vec<_>, _>>()
2911            .expect("collect rows");
2912        assert_eq!(
2913            rows,
2914            vec![
2915                ("t1".to_owned(), "updated".to_owned()),
2916                ("t2".to_owned(), "original".to_owned())
2917            ]
2918        );
2919    }
2920
2921    #[test]
2922    fn reopening_active_subscriptions_emits_a_catch_up_intent() {
2923        let path = std::env::temp_dir().join(format!(
2924            "syncular-startup-intent-{}.db",
2925            uuid::Uuid::new_v4()
2926        ));
2927        let schema = json!({
2928            "version": 1,
2929            "tables": [{
2930                "name": "tasks",
2931                "primaryKey": "id",
2932                "columns": [
2933                    { "name": "id", "type": "string", "nullable": false },
2934                    { "name": "project_id", "type": "string", "nullable": false }
2935                ],
2936                "scopes": [{ "pattern": "project:{project_id}" }]
2937            }]
2938        });
2939
2940        {
2941            let mut first = SyncClient::open_path_with_identity(
2942                None,
2943                &schema,
2944                ClientLimits::default(),
2945                path.to_str().expect("UTF-8 temp path"),
2946            )
2947            .expect("first open");
2948            first
2949                .set_window(
2950                    &WindowBase {
2951                        table: "tasks".to_owned(),
2952                        variable: "project_id".to_owned(),
2953                        fixed_scopes: Vec::new(),
2954                        params: None,
2955                    },
2956                    &["persisted".to_owned()],
2957                )
2958                .expect("persist window");
2959        }
2960
2961        let mut reopened = SyncClient::open_path_with_identity(
2962            None,
2963            &schema,
2964            ClientLimits::default(),
2965            path.to_str().expect("UTF-8 temp path"),
2966        )
2967        .expect("reopen");
2968        assert!(reopened.sync_needed());
2969        assert!(matches!(
2970            reopened.drain_sync_intents().as_slice(),
2971            [SyncIntent::Interactive]
2972        ));
2973        drop(reopened);
2974        std::fs::remove_file(path).expect("remove temp database");
2975    }
2976
2977    #[test]
2978    fn reopening_preserves_immutable_subscription_identity_and_progress() {
2979        let path = std::env::temp_dir().join(format!(
2980            "syncular-subscription-identity-{}.db",
2981            uuid::Uuid::new_v4()
2982        ));
2983        let schema = json!({
2984            "version": 1,
2985            "tables": [
2986                {
2987                    "name": "tasks",
2988                    "primaryKey": "id",
2989                    "columns": [
2990                        { "name": "id", "type": "string", "nullable": false },
2991                        { "name": "project_id", "type": "string", "nullable": false }
2992                    ],
2993                    "scopes": [{ "pattern": "project:{project_id}" }]
2994                },
2995                {
2996                    "name": "docs",
2997                    "primaryKey": "id",
2998                    "columns": [
2999                        { "name": "id", "type": "string", "nullable": false },
3000                        { "name": "org_id", "type": "string", "nullable": false },
3001                        { "name": "project_id", "type": "string", "nullable": false }
3002                    ],
3003                    "scopes": [
3004                        { "pattern": "org:{org_id}" },
3005                        { "pattern": "project:{projectId}", "column": "project_id" }
3006                    ]
3007                }
3008            ]
3009        });
3010
3011        {
3012            let mut first = SyncClient::open_path_with_identity(
3013                None,
3014                &schema,
3015                ClientLimits::default(),
3016                path.to_str().expect("UTF-8 temp path"),
3017            )
3018            .expect("first open");
3019            first
3020                .subscribe(
3021                    "stable-subscription".to_owned(),
3022                    "tasks".to_owned(),
3023                    vec![(
3024                        "project_id".to_owned(),
3025                        vec!["p2".to_owned(), "p1".to_owned()],
3026                    )],
3027                    Some(r#"{"view":"v1"}"#.to_owned()),
3028                )
3029                .expect("persist subscription");
3030            let persisted = {
3031                let subscription = first
3032                    .subs
3033                    .iter_mut()
3034                    .find(|subscription| subscription.id == "stable-subscription")
3035                    .expect("subscription");
3036                subscription.cursor = 41;
3037                subscription.bootstrap_state = Some("resume-token".to_owned());
3038                subscription.effective = Some(vec![(
3039                    "project_id".to_owned(),
3040                    vec!["p1".to_owned(), "p2".to_owned()],
3041                )]);
3042                subscription.synced_once = true;
3043                subscription.clone()
3044            };
3045            first.persist_sub(&persisted).unwrap();
3046        }
3047
3048        let mut reopened = SyncClient::open_path_with_identity(
3049            None,
3050            &schema,
3051            ClientLimits::default(),
3052            path.to_str().expect("UTF-8 temp path"),
3053        )
3054        .expect("reopen");
3055        let progress = reopened
3056            .subscription_state("stable-subscription")
3057            .expect("persisted state");
3058        assert_eq!(progress.cursor, 41);
3059        assert!(progress.has_resume_token);
3060
3061        reopened
3062            .subscribe(
3063                "stable-subscription".to_owned(),
3064                "tasks".to_owned(),
3065                vec![(
3066                    "project_id".to_owned(),
3067                    vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
3068                )],
3069                Some(r#"{"view":"v1"}"#.to_owned()),
3070            )
3071            .expect("canonical intent is idempotent");
3072        assert_eq!(
3073            reopened
3074                .subscription_state("stable-subscription")
3075                .expect("unchanged state")
3076                .cursor,
3077            progress.cursor
3078        );
3079
3080        for (table, scopes, params) in [
3081            (
3082                "tasks",
3083                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3084                Some(r#"{"view":"v1"}"#.to_owned()),
3085            ),
3086            (
3087                "tasks",
3088                vec![(
3089                    "project_id".to_owned(),
3090                    vec!["p2".to_owned(), "p1".to_owned()],
3091                )],
3092                Some(r#"{"view":"v2"}"#.to_owned()),
3093            ),
3094            (
3095                "docs",
3096                vec![
3097                    ("org_id".to_owned(), vec!["o1".to_owned()]),
3098                    ("projectId".to_owned(), vec!["p1".to_owned()]),
3099                ],
3100                Some(r#"{"view":"v1"}"#.to_owned()),
3101            ),
3102        ] {
3103            let error = reopened
3104                .subscribe(
3105                    "stable-subscription".to_owned(),
3106                    table.to_owned(),
3107                    scopes,
3108                    params,
3109                )
3110                .expect_err("identity rebind must fail");
3111            assert!(error.starts_with("client.subscription_intent_mismatch:"));
3112            assert_eq!(
3113                reopened
3114                    .subscription_state("stable-subscription")
3115                    .expect("unchanged state")
3116                    .cursor,
3117                progress.cursor
3118            );
3119        }
3120
3121        drop(reopened);
3122        std::fs::remove_file(path).expect("remove temp database");
3123    }
3124
3125    #[test]
3126    fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
3127        let path = std::env::temp_dir().join(format!(
3128            "syncular-satisfied-schema-floor-{}.db",
3129            uuid::Uuid::new_v4()
3130        ));
3131        let schema = json!({
3132            "version": 23,
3133            "tables": [{
3134                "name": "tasks",
3135                "primaryKey": "id",
3136                "columns": [
3137                    { "name": "id", "type": "string", "nullable": false },
3138                    { "name": "project_id", "type": "string", "nullable": false }
3139                ],
3140                "scopes": [{ "pattern": "project:{project_id}" }]
3141            }]
3142        });
3143
3144        {
3145            let mut first = SyncClient::open_path_with_identity(
3146                None,
3147                &schema,
3148                ClientLimits::default(),
3149                path.to_str().expect("UTF-8 temp path"),
3150            )
3151            .expect("first open");
3152            first
3153                .subscribe(
3154                    "tasks".to_owned(),
3155                    "tasks".to_owned(),
3156                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3157                    None,
3158                )
3159                .expect("persist subscription");
3160            first.set_schema_floor(Some(SchemaFloor {
3161                required_schema_version: Some(22),
3162                latest_schema_version: Some(22),
3163            }));
3164        }
3165
3166        let mut reopened = SyncClient::open_path_with_identity(
3167            None,
3168            &schema,
3169            ClientLimits::default(),
3170            path.to_str().expect("UTF-8 temp path"),
3171        )
3172        .expect("reopen");
3173        assert!(reopened.schema_floor().is_none());
3174        assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
3175        assert!(reopened.sync_needed());
3176        assert!(matches!(
3177            reopened.drain_sync_intents().as_slice(),
3178            [SyncIntent::Interactive]
3179        ));
3180        drop(reopened);
3181        std::fs::remove_file(path).expect("remove temp database");
3182    }
3183
3184    #[test]
3185    fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
3186        let path = std::env::temp_dir().join(format!(
3187            "syncular-unsatisfied-schema-floor-{}.db",
3188            uuid::Uuid::new_v4()
3189        ));
3190        let schema = json!({
3191            "version": 1,
3192            "tables": [{
3193                "name": "tasks",
3194                "primaryKey": "id",
3195                "columns": [
3196                    { "name": "id", "type": "string", "nullable": false },
3197                    { "name": "project_id", "type": "string", "nullable": false }
3198                ],
3199                "scopes": [{ "pattern": "project:{project_id}" }]
3200            }]
3201        });
3202
3203        {
3204            let mut first = SyncClient::open_path_with_identity(
3205                None,
3206                &schema,
3207                ClientLimits::default(),
3208                path.to_str().expect("UTF-8 temp path"),
3209            )
3210            .expect("first open");
3211            first
3212                .subscribe(
3213                    "tasks".to_owned(),
3214                    "tasks".to_owned(),
3215                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3216                    None,
3217                )
3218                .expect("persist subscription");
3219            first.set_schema_floor(Some(SchemaFloor {
3220                required_schema_version: Some(2),
3221                latest_schema_version: Some(2),
3222            }));
3223        }
3224
3225        let reopened = SyncClient::open_path_with_identity(
3226            None,
3227            &schema,
3228            ClientLimits::default(),
3229            path.to_str().expect("UTF-8 temp path"),
3230        )
3231        .expect("reopen");
3232        assert_eq!(
3233            reopened.schema_floor(),
3234            Some(&SchemaFloor {
3235                required_schema_version: Some(2),
3236                latest_schema_version: Some(2),
3237            })
3238        );
3239        assert!(!reopened.sync_needed());
3240        drop(reopened);
3241        std::fs::remove_file(path).expect("remove temp database");
3242    }
3243
3244    #[test]
3245    fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
3246        let path = std::env::temp_dir().join(format!(
3247            "syncular-indexed-column-bump-{}.db",
3248            uuid::Uuid::new_v4()
3249        ));
3250        let old_schema = json!({
3251            "version": 1,
3252            "tables": [
3253                {
3254                    "name": "tasks",
3255                    "primaryKey": "id",
3256                    "columns": [
3257                        { "name": "id", "type": "string", "nullable": false },
3258                        { "name": "project_id", "type": "string", "nullable": false }
3259                    ],
3260                    "scopes": [{ "pattern": "project:{project_id}" }]
3261                },
3262                {
3263                    "name": "legacy",
3264                    "primaryKey": "id",
3265                    "columns": [
3266                        { "name": "id", "type": "string", "nullable": false },
3267                        { "name": "project_id", "type": "string", "nullable": false }
3268                    ],
3269                    "scopes": [{ "pattern": "project:{project_id}" }]
3270                }
3271            ]
3272        });
3273        {
3274            let mut first = SyncClient::open_path_with_identity(
3275                None,
3276                &old_schema,
3277                ClientLimits::default(),
3278                path.to_str().expect("UTF-8 temp path"),
3279            )
3280            .expect("open old schema");
3281            first
3282                .subscribe(
3283                    "legacy-sub".to_owned(),
3284                    "legacy".to_owned(),
3285                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3286                    None,
3287                )
3288                .expect("persist legacy subscription");
3289        }
3290
3291        let new_schema = json!({
3292            "version": 2,
3293            "tables": [{
3294                "name": "tasks",
3295                "primaryKey": "id",
3296                "columns": [
3297                    { "name": "id", "type": "string", "nullable": false },
3298                    { "name": "project_id", "type": "string", "nullable": false },
3299                    { "name": "facility_membership_id", "type": "string", "nullable": true }
3300                ],
3301                "scopes": [{ "pattern": "project:{project_id}" }],
3302                "indexes": [{
3303                    "name": "tasks_by_membership",
3304                    "columns": ["project_id", "facility_membership_id"],
3305                    "unique": false
3306                }]
3307            }]
3308        });
3309        let upgraded = SyncClient::open_path_with_identity(
3310            None,
3311            &new_schema,
3312            ClientLimits::default(),
3313            path.to_str().expect("UTF-8 temp path"),
3314        )
3315        .expect("open upgraded schema");
3316        let columns = upgraded
3317            .conn
3318            .prepare("PRAGMA table_info(tasks)")
3319            .expect("prepare columns")
3320            .query_map([], |row| row.get::<_, String>(1))
3321            .expect("query columns")
3322            .collect::<Result<Vec<_>, _>>()
3323            .expect("collect columns");
3324        assert!(columns.contains(&"facility_membership_id".to_owned()));
3325        let index_count: i64 = upgraded
3326            .conn
3327            .query_row(
3328                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
3329                [],
3330                |row| row.get(0),
3331            )
3332            .expect("query index");
3333        assert_eq!(index_count, 1);
3334        assert!(upgraded.subscription_state("legacy-sub").is_none());
3335        drop(upgraded);
3336        std::fs::remove_file(path).expect("remove temp database");
3337    }
3338
3339    #[test]
3340    fn migrates_pre_envelope_outcome_journal_additively() {
3341        let path = std::env::temp_dir().join(format!(
3342            "syncular-outcome-migration-{}.db",
3343            uuid::Uuid::new_v4()
3344        ));
3345        let conn = Connection::open(&path).expect("open legacy database");
3346        conn.execute_batch(
3347            "CREATE TABLE _syncular_commit_outcomes (
3348               seq INTEGER PRIMARY KEY AUTOINCREMENT,
3349               client_commit_id TEXT NOT NULL UNIQUE,
3350               status TEXT NOT NULL,
3351               recorded_at_ms INTEGER NOT NULL,
3352               results_json TEXT NOT NULL,
3353               resolution TEXT NOT NULL DEFAULT 'active',
3354               resolved_at_ms INTEGER,
3355               replacement_client_commit_id TEXT);",
3356        )
3357        .expect("create legacy outcome journal");
3358        drop(conn);
3359        let schema = json!({
3360            "version": 1,
3361            "tables": [{
3362                "name": "tasks",
3363                "primaryKey": "id",
3364                "columns": [
3365                    { "name": "id", "type": "string", "nullable": false },
3366                    { "name": "project_id", "type": "string", "nullable": false }
3367                ],
3368                "scopes": [{ "pattern": "project:{project_id}" }]
3369            }]
3370        });
3371        let client = SyncClient::open_path(
3372            "migration-native".to_owned(),
3373            &schema,
3374            ClientLimits::default(),
3375            path.to_str().expect("UTF-8 temp path"),
3376        )
3377        .expect("migrate database");
3378        let has_operations = client
3379            .conn
3380            .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
3381            .expect("prepare table info")
3382            .query_map([], |row| row.get::<_, String>(1))
3383            .expect("query table info")
3384            .filter_map(Result::ok)
3385            .any(|column| column == "operations_json");
3386        assert!(has_operations);
3387        drop(client);
3388        std::fs::remove_file(path).expect("remove temp database");
3389    }
3390
3391    #[test]
3392    fn durable_conflict_outcome_and_resolution_survive_reopen() {
3393        let path = std::env::temp_dir().join(format!(
3394            "syncular-durable-outcome-{}.db",
3395            uuid::Uuid::new_v4()
3396        ));
3397        let schema = json!({
3398            "version": 1,
3399            "tables": [{
3400                "name": "tasks",
3401                "primaryKey": "id",
3402                "columns": [
3403                    { "name": "id", "type": "string", "nullable": false },
3404                    { "name": "project_id", "type": "string", "nullable": false }
3405                ],
3406                "scopes": [{ "pattern": "project:{project_id}" }]
3407            }]
3408        });
3409        let conflict = ConflictRecord {
3410            client_commit_id: "losing-commit".to_owned(),
3411            op_index: 0,
3412            table: "tasks".to_owned(),
3413            row_id: "t1".to_owned(),
3414            code: "sync.version_conflict".to_owned(),
3415            message: "stale base version".to_owned(),
3416            server_version: 2,
3417            server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
3418            conflict_columns: vec!["project_id".to_owned()],
3419            operation: Some(CommitOperation {
3420                table: "tasks".to_owned(),
3421                row_id: "t1".to_owned(),
3422                op: "upsert".to_owned(),
3423                base_version: Some(1),
3424                values: None,
3425            }),
3426        };
3427        let failed_operations = vec![
3428            OutboxOp {
3429                upsert: true,
3430                table: "tasks".to_owned(),
3431                row_id: "t1".to_owned(),
3432                base_version: Some(1),
3433                values: None,
3434            },
3435            OutboxOp {
3436                upsert: true,
3437                table: "tasks".to_owned(),
3438                row_id: "status-event-1".to_owned(),
3439                base_version: Some(0),
3440                values: None,
3441            },
3442        ];
3443
3444        {
3445            let mut first = SyncClient::open_path(
3446                "durable-native".to_owned(),
3447                &schema,
3448                ClientLimits::default(),
3449                path.to_str().expect("UTF-8 temp path"),
3450            )
3451            .expect("first open");
3452            first
3453                .begin_observation("test_outcome")
3454                .expect("begin outcome");
3455            first
3456                .persist_commit_outcome(
3457                    "losing-commit",
3458                    CommitOutcomeStatus::Conflict,
3459                    &[CommitOperationOutcome::Conflict {
3460                        conflict: conflict.clone(),
3461                    }],
3462                    Some(&failed_operations),
3463                )
3464                .expect("persist outcome");
3465            first.conflicts.push(conflict);
3466            first
3467                .finish_observation(
3468                    "test_outcome",
3469                    ChangeAccumulator {
3470                        conflicts: true,
3471                        outcomes: true,
3472                        ..ChangeAccumulator::default()
3473                    },
3474                )
3475                .expect("commit outcome");
3476        }
3477
3478        {
3479            let mut reopened = SyncClient::open_path(
3480                "durable-native".to_owned(),
3481                &schema,
3482                ClientLimits::default(),
3483                path.to_str().expect("UTF-8 temp path"),
3484            )
3485            .expect("reopen");
3486            assert_eq!(reopened.conflicts().len(), 1);
3487            assert_eq!(
3488                reopened.conflicts()[0].conflict_columns,
3489                vec!["project_id".to_owned()],
3490                "conflictColumns survives the journal round trip"
3491            );
3492            let outcome = reopened
3493                .commit_outcome("losing-commit")
3494                .expect("read outcome")
3495                .expect("outcome");
3496            assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
3497            let operations = outcome.operations.expect("aggregate envelope");
3498            assert_eq!(operations.len(), 2);
3499            assert_eq!(operations[1].row_id, "status-event-1");
3500            let resolved = reopened
3501                .resolve_commit_outcome(ResolveCommitOutcomeInput {
3502                    client_commit_id: "losing-commit".to_owned(),
3503                    resolution: CommitOutcomeResolution::ResolvedKeepServer,
3504                    replacement_client_commit_id: None,
3505                })
3506                .expect("resolve");
3507            assert_eq!(
3508                resolved.resolution,
3509                CommitOutcomeResolution::ResolvedKeepServer
3510            );
3511            assert!(reopened.conflicts().is_empty());
3512        }
3513
3514        let reopened = SyncClient::open_path(
3515            "durable-native".to_owned(),
3516            &schema,
3517            ClientLimits::default(),
3518            path.to_str().expect("UTF-8 temp path"),
3519        )
3520        .expect("second reopen");
3521        assert!(reopened.conflicts().is_empty());
3522        assert_eq!(
3523            reopened
3524                .commit_outcome("losing-commit")
3525                .expect("read outcome")
3526                .expect("outcome")
3527                .resolution,
3528            CommitOutcomeResolution::ResolvedKeepServer
3529        );
3530        drop(reopened);
3531        std::fs::remove_file(path).expect("remove temp database");
3532    }
3533
3534    #[test]
3535    fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
3536        let path =
3537            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
3538        let schema = json!({
3539            "version": 1,
3540            "tables": [{
3541                "name": "tasks",
3542                "primaryKey": "id",
3543                "columns": [
3544                    { "name": "id", "type": "string", "nullable": false },
3545                    { "name": "project_id", "type": "string", "nullable": false }
3546                ],
3547                "scopes": [{ "pattern": "project:{project_id}" }]
3548            }]
3549        });
3550        let mut client = SyncClient::open_path_with_identity(
3551            Some("sidecar-client".to_owned()),
3552            &schema,
3553            ClientLimits::default(),
3554            path.to_str().expect("UTF-8 temp path"),
3555        )
3556        .expect("open owner");
3557        let base = WindowBase {
3558            table: "tasks".to_owned(),
3559            variable: "project_id".to_owned(),
3560            fixed_scopes: Vec::new(),
3561            params: None,
3562        };
3563        client
3564            .set_window(&base, &["one".to_owned()])
3565            .expect("set window");
3566        client
3567            .mutate(vec![Mutation::Upsert {
3568                table: "tasks".to_owned(),
3569                values: Map::from_iter([
3570                    ("id".to_owned(), Value::from("t1")),
3571                    ("project_id".to_owned(), Value::from("one")),
3572                ]),
3573                base_version: None,
3574            }])
3575            .expect("local mutate");
3576
3577        let coverage = [WindowCoverage {
3578            base,
3579            units: vec!["one".to_owned(), "missing".to_owned()],
3580        }];
3581        let owner = client
3582            .query_snapshot(
3583                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3584                &[],
3585                &coverage,
3586                None,
3587            )
3588            .expect("owner snapshot");
3589        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
3590        let sidecar = reader
3591            .query_snapshot(
3592                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3593                &[],
3594                &coverage,
3595            )
3596            .expect("sidecar snapshot");
3597
3598        assert_eq!(sidecar.revision, owner.revision);
3599        assert_eq!(sidecar.rows, owner.rows);
3600        assert_eq!(
3601            serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
3602            serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
3603        );
3604        assert_eq!(sidecar.revision, "2");
3605        assert_eq!(sidecar.rows[0]["id"], "t1");
3606        assert_eq!(sidecar.rows[0]["server_version"], -1);
3607        assert!(!sidecar.coverage.complete);
3608        assert_eq!(sidecar.coverage.pending.len(), 1);
3609        assert_eq!(sidecar.coverage.missing.len(), 1);
3610
3611        drop(reader);
3612        drop(client);
3613        std::fs::remove_file(path).expect("remove temp database");
3614    }
3615
3616    #[test]
3617    fn owned_query_failures_classify_order_bound_and_clear() {
3618        let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
3619            rusqlite::ffi::Error::new(266),
3620            Some("private SQLite prose".to_owned()),
3621        ));
3622        assert_eq!(io.code, Some("client.storage_io"));
3623        assert_eq!(io.sqlite_code, Some(266));
3624        assert_eq!(io.message, "local SQLite storage I/O failed");
3625        let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
3626            rusqlite::ffi::Error::new(26),
3627            None,
3628        ));
3629        assert_eq!(corrupt.code, Some("client.storage_corrupt"));
3630
3631        let mut client = client();
3632        let generic = QueryReadFailure::from("private query prose".to_owned());
3633        client.record_query_read(
3634            QueryOwner {
3635                id: "first",
3636                tables: &["tasks", "tasks"],
3637            },
3638            Some(&generic),
3639        );
3640        client.record_query_read(
3641            QueryOwner {
3642                id: "second",
3643                tables: &["tasks"],
3644            },
3645            Some(&generic),
3646        );
3647        client.record_query_read(
3648            QueryOwner {
3649                id: "first",
3650                tables: &["docs"],
3651            },
3652            Some(&io),
3653        );
3654        let failures = &client
3655            .diagnostics_snapshot(&Default::default())
3656            .expect("diagnostics")
3657            .query_failures;
3658        assert_eq!(
3659            failures
3660                .iter()
3661                .map(|entry| entry.id.as_str())
3662                .collect::<Vec<_>>(),
3663            vec!["second", "first"]
3664        );
3665        assert_eq!(failures[1].tables, vec!["docs"]);
3666        assert_eq!(failures[1].code, "client.storage_io");
3667
3668        for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
3669            client.record_query_read(
3670                QueryOwner {
3671                    id: &format!("query-{index}"),
3672                    tables: &["tasks"],
3673                },
3674                Some(&generic),
3675            );
3676        }
3677        let failures = &client
3678            .diagnostics_snapshot(&Default::default())
3679            .expect("bounded diagnostics")
3680            .query_failures;
3681        assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
3682        assert_eq!(failures.last().expect("last failure").id, "query-256");
3683
3684        client
3685            .query_snapshot(
3686                "SELECT id FROM tasks",
3687                &[],
3688                &[],
3689                Some(QueryOwner {
3690                    id: "query-256",
3691                    tables: &["tasks"],
3692                }),
3693            )
3694            .expect("successful owned read");
3695        assert!(client
3696            .diagnostics_snapshot(&Default::default())
3697            .expect("cleared diagnostics")
3698            .query_failures
3699            .iter()
3700            .all(|entry| entry.id != "query-256"));
3701    }
3702
3703    #[test]
3704    fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
3705        let mut client = client();
3706        let image = Connection::open_in_memory().unwrap();
3707        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
3708            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3709            INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
3710            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3711            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
3712        let table = client.schema.table("tasks").unwrap().clone();
3713        let scopes = vec![("project_id".into(), vec!["p1".into()])];
3714        assert!(client
3715            .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
3716            .is_err());
3717        assert!(client.conn.is_autocommit());
3718        assert_eq!(
3719            client
3720                .query("SELECT count(*) AS n FROM tasks", &[])
3721                .unwrap()[0]["n"],
3722            1024
3723        );
3724        assert_eq!(client.drain_change_batches().len(), 1);
3725        image
3726            .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
3727            .unwrap();
3728        assert!(matches!(
3729            client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
3730            Ok(2050)
3731        ));
3732        assert_eq!(
3733            client
3734                .query("SELECT count(*) AS n FROM tasks", &[])
3735                .unwrap()[0]["n"],
3736            2050
3737        );
3738        assert_eq!(client.drain_change_batches().len(), 3);
3739    }
3740
3741    #[test]
3742    fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
3743        let mut client = client();
3744        let base = WindowBase {
3745            table: "tasks".into(),
3746            variable: "project_id".into(),
3747            fixed_scopes: Vec::new(),
3748            params: None,
3749        };
3750        client
3751            .set_window(&base, &["p1".into(), "p2".into()])
3752            .unwrap();
3753        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3754            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3755            INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
3756            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3757            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3758        client.drain_change_batches();
3759        assert!(client.set_window(&base, &["p2".into()]).is_err());
3760        assert!(client.conn.is_autocommit());
3761        assert_eq!(
3762            client
3763                .query("SELECT count(*) AS n FROM tasks", &[])
3764                .unwrap()[0]["n"],
3765            1027
3766        );
3767        assert_eq!(client.window_state(&base).units, vec!["p2"]);
3768        assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
3769        assert_eq!(client.drain_change_batches().len(), 1);
3770        client
3771            .conn
3772            .execute_batch("DROP TRIGGER interrupt_evict")
3773            .unwrap();
3774        client.drain_pending_evictions().unwrap();
3775        assert!(client.load_pending_evictions().unwrap().is_empty());
3776        assert_eq!(
3777            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3778            "held"
3779        );
3780        assert_eq!(client.drain_change_batches().len(), 2);
3781    }
3782
3783    #[test]
3784    fn eviction_marker_failures_roll_back_window_changes() {
3785        let mut client = client();
3786        let base = WindowBase {
3787            table: "tasks".into(),
3788            variable: "project_id".into(),
3789            fixed_scopes: Vec::new(),
3790            params: None,
3791        };
3792        client.set_window(&base, &["p1".into()]).unwrap();
3793        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3794            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3795            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3796            CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3797        let revision = client.local_revision();
3798        assert!(client.set_window(&base, &[]).is_err());
3799        assert!(client.conn.is_autocommit());
3800        assert_eq!(client.local_revision(), revision);
3801        assert_eq!(
3802            client
3803                .query("SELECT count(*) AS n FROM tasks", &[])
3804                .unwrap()[0]["n"],
3805            2050
3806        );
3807        assert_eq!(client.window_state(&base).units, vec!["p1"]);
3808        client.conn.execute_batch("DROP TRIGGER fail_marker;
3809            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3810        assert!(client.set_window(&base, &[]).is_err());
3811        client.conn.execute_batch("DROP TRIGGER interrupt_evict;
3812            CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3813        assert!(client.drain_pending_evictions().is_err());
3814        assert!(client.conn.is_autocommit());
3815        assert_eq!(
3816            client
3817                .query("SELECT count(*) AS n FROM tasks", &[])
3818                .unwrap()[0]["n"],
3819            2
3820        );
3821        assert!(client.set_window(&base, &["p1".into()]).is_err());
3822        assert!(client.conn.is_autocommit());
3823        assert!(client.window_state(&base).units.is_empty());
3824        assert!(client.subs.is_empty());
3825        assert_eq!(
3826            client
3827                .query(
3828                    "SELECT count(*) AS n FROM _syncular_window_pending_evict",
3829                    &[]
3830                )
3831                .unwrap()[0]["n"],
3832            1
3833        );
3834        client
3835            .conn
3836            .execute_batch("DROP TRIGGER fail_marker")
3837            .unwrap();
3838        client.set_window(&base, &["p1".into()]).unwrap();
3839        assert_eq!(client.window_state(&base).units, vec!["p1"]);
3840    }
3841
3842    #[test]
3843    fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
3844        let mut client = client();
3845        client
3846            .conn
3847            .execute(
3848                "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
3849                [],
3850            )
3851            .unwrap();
3852        assert_eq!(
3853            client.drain_pending_evictions().unwrap_err(),
3854            "sync.local_corrupt: invalid pending eviction scopes"
3855        );
3856        assert!(client.conn.is_autocommit());
3857        assert!(client.enqueue_startup_sync_if_needed().is_err());
3858    }
3859
3860    #[test]
3861    fn image_chunks_reject_null_primary_keys() {
3862        let mut client = client();
3863        let image = Connection::open_in_memory().unwrap();
3864        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3865            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
3866            INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
3867            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3868            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
3869        let table = client.schema.table("tasks").unwrap().clone();
3870        assert!(client
3871            .apply_sqlite_image(
3872                &image,
3873                &table,
3874                true,
3875                &[("project_id".into(), vec!["p1".into()])],
3876                1025,
3877                7,
3878                "digest"
3879            )
3880            .is_err());
3881        assert!(client.conn.is_autocommit());
3882        assert_eq!(
3883            client
3884                .query("SELECT count(*) AS n FROM tasks", &[])
3885                .unwrap()[0]["n"],
3886            0
3887        );
3888    }
3889
3890    #[test]
3891    fn import_chunks_with_unique_constraints_match_full_pending_replay() {
3892        for images in [false, true] {
3893            let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
3894                "name":name,"primaryKey":"id","columns":[
3895                    {"name":"id","type":"string","nullable":false},
3896                    {"name":"project_id","type":"string","nullable":false},
3897                    {"name":"title","type":"string","nullable":false}],
3898                "scopes":[{"pattern":"project:{project_id}"}],
3899                "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
3900                "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
3901            })))});
3902            let mut actual =
3903                SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
3904            let mut reference =
3905                SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
3906            let scopes = vec![("project_id".into(), vec!["p1".into()])];
3907            for client in [&mut actual, &mut reference] {
3908                client
3909                    .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
3910                    .unwrap();
3911                client.subs[0].effective = Some(scopes.clone());
3912                for (table, id, title) in [
3913                    ("tasks", "a", "original"),
3914                    ("tasks", "b", "occupied"),
3915                    ("tasks", "c", "deleted"),
3916                    ("notes", "n", "untouched"),
3917                ] {
3918                    client
3919                        .write_base_row(
3920                            table,
3921                            &vec![
3922                                Some(ColumnValue::String(id.into())),
3923                                Some(ColumnValue::String("p1".into())),
3924                                Some(ColumnValue::String(title.into())),
3925                            ],
3926                            1,
3927                        )
3928                        .unwrap();
3929                }
3930                client.rebuild_overlay_if_dirty();
3931                client
3932                    .mutate(vec![
3933                        Mutation::Upsert {
3934                            table: "tasks".into(),
3935                            values: Map::from_iter([
3936                                ("id".into(), Value::from("a")),
3937                                ("project_id".into(), Value::from("p1")),
3938                                ("title".into(), Value::from("available")),
3939                            ]),
3940                            base_version: None,
3941                        },
3942                        Mutation::Delete {
3943                            table: "tasks".into(),
3944                            row_id: "c".into(),
3945                            base_version: None,
3946                        },
3947                        Mutation::Upsert {
3948                            table: "tasks".into(),
3949                            values: Map::from_iter([
3950                                ("id".into(), Value::from("local")),
3951                                ("project_id".into(), Value::from("p1")),
3952                                ("title".into(), Value::from("local value")),
3953                            ]),
3954                            base_version: None,
3955                        },
3956                        Mutation::Upsert {
3957                            table: "notes".into(),
3958                            values: Map::from_iter([
3959                                ("id".into(), Value::from("n")),
3960                                ("project_id".into(), Value::from("p1")),
3961                                ("title".into(), Value::from("pending note")),
3962                            ]),
3963                            base_version: None,
3964                        },
3965                    ])
3966                    .unwrap();
3967            }
3968            let pending = actual.pending_commit_ids();
3969            actual.overlay_rebuild_count.set(0);
3970            for (id, title) in [
3971                ("b", "available"),
3972                ("b", "occupied"),
3973                ("a", "server original"),
3974                ("d", "local value"),
3975                ("d", "released"),
3976                ("c", "redelivered"),
3977            ] {
3978                let table = actual.schema.table("tasks").unwrap().clone();
3979                let values = vec![
3980                    Some(ColumnValue::String(id.into())),
3981                    Some(ColumnValue::String("p1".into())),
3982                    Some(ColumnValue::String(title.into())),
3983                ];
3984                if images {
3985                    let image = Connection::open_in_memory().unwrap();
3986                    image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3987                        CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3988                        INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
3989                    image
3990                        .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
3991                        .unwrap();
3992                    assert!(matches!(
3993                        actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
3994                        Ok(1)
3995                    ));
3996                } else {
3997                    let segment = RowsSegment {
3998                        table: "tasks".into(),
3999                        schema_version: 1,
4000                        columns: table.wire_columns.clone(),
4001                        blocks: vec![vec![ssp2::segment::SegmentRow {
4002                            server_version: 2,
4003                            values: values.clone(),
4004                        }]],
4005                    };
4006                    assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
4007                }
4008                reference.write_base_row("tasks", &values, 2).unwrap();
4009                reference.rebuild_overlay();
4010                for table in ["tasks", "notes"] {
4011                    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")] {
4012                        assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
4013                    }
4014                }
4015                assert_eq!(actual.pending_commit_ids(), pending);
4016            }
4017            assert_eq!(
4018                actual.overlay_rebuild_count.get(),
4019                0,
4020                "each chunk must leave unrelated tables alone"
4021            );
4022            let before = actual
4023                .query("SELECT * FROM tasks ORDER BY id", &[])
4024                .unwrap();
4025            let fts_before = actual
4026                .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4027                .unwrap();
4028            let revision = actual.local_revision();
4029            actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
4030            let table = actual.schema.table("tasks").unwrap().clone();
4031            let segment = RowsSegment {
4032                table: "tasks".into(),
4033                schema_version: 1,
4034                columns: table.wire_columns.clone(),
4035                blocks: vec![vec![ssp2::segment::SegmentRow {
4036                    server_version: 3,
4037                    values: vec![
4038                        Some(ColumnValue::String("b".into())),
4039                        Some(ColumnValue::String("p1".into())),
4040                        Some(ColumnValue::String("fresh".into())),
4041                    ],
4042                }]],
4043            };
4044            assert!(actual.apply_segment(0, &segment, false).is_err());
4045            assert!(actual.conn.is_autocommit());
4046            assert_eq!(
4047                actual
4048                    .query("SELECT * FROM tasks ORDER BY id", &[])
4049                    .unwrap(),
4050                before
4051            );
4052            assert_eq!(
4053                actual
4054                    .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4055                    .unwrap(),
4056                fts_before
4057            );
4058            assert_eq!(actual.local_revision(), revision);
4059            assert_eq!(actual.pending_commit_ids(), pending);
4060        }
4061    }
4062
4063    #[test]
4064    fn import_reconciliation_preserves_typed_pending_primary_keys() {
4065        for (kind, a, b) in [
4066            ("string", json!("a"), json!("b")),
4067            ("integer", json!(1), json!(2)),
4068            ("boolean", json!(true), json!(false)),
4069            ("json", json!("1"), json!("2")),
4070        ] {
4071            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}]}]});
4072            for images in [false, true] {
4073                let mut client =
4074                    SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
4075                let table = client.schema.table("tasks").unwrap().clone();
4076                client
4077                    .subscribe("tasks".into(), "tasks".into(), vec![], None)
4078                    .unwrap();
4079                for (id, title) in [(&a, "original"), (&b, "occupied")] {
4080                    client
4081                        .write_base_row(
4082                            "tasks",
4083                            &vec![
4084                                json_to_column_value(&table.columns[0], Some(id)).unwrap(),
4085                                Some(ColumnValue::String(title.into())),
4086                            ],
4087                            1,
4088                        )
4089                        .unwrap();
4090                }
4091                client.rebuild_overlay_if_dirty();
4092                client
4093                    .mutate(vec![Mutation::Upsert {
4094                        table: "tasks".into(),
4095                        values: Map::from_iter([
4096                            ("id".into(), a.clone()),
4097                            ("title".into(), json!("available")),
4098                        ]),
4099                        base_version: None,
4100                    }])
4101                    .unwrap();
4102                client.overlay_rebuild_count.set(0);
4103                for (title, expected) in [("available", "original"), ("occupied", "available")] {
4104                    let values = vec![
4105                        json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
4106                        Some(ColumnValue::String(title.into())),
4107                    ];
4108                    if images {
4109                        let image = Connection::open_in_memory().unwrap();
4110                        image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4111                            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4112                            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4113                        image
4114                            .execute(
4115                                "INSERT INTO tasks VALUES (?1,?2,2)",
4116                                rusqlite::params![RowParam::Cell(&values[0]), title],
4117                            )
4118                            .unwrap();
4119                        assert!(
4120                            matches!(
4121                                client.apply_sqlite_image(
4122                                    &image,
4123                                    &table,
4124                                    false,
4125                                    &[],
4126                                    1,
4127                                    7,
4128                                    "digest"
4129                                ),
4130                                Ok(1)
4131                            ),
4132                            "{kind}, images={images}"
4133                        );
4134                    } else {
4135                        let segment = RowsSegment {
4136                            table: "tasks".into(),
4137                            schema_version: 1,
4138                            columns: table.wire_columns.clone(),
4139                            blocks: vec![vec![ssp2::segment::SegmentRow {
4140                                server_version: 2,
4141                                values,
4142                            }]],
4143                        };
4144                        assert!(
4145                            matches!(client.apply_segment(0, &segment, false), Ok(1)),
4146                            "{kind}"
4147                        );
4148                    }
4149                    let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
4150                    let actual: String = client
4151                        .conn
4152                        .query_row(
4153                            "SELECT title FROM tasks WHERE id=?1",
4154                            [RowParam::Cell(&key)],
4155                            |row| row.get(0),
4156                        )
4157                        .unwrap();
4158                    assert_eq!(actual, expected, "{kind}, images={images}");
4159                }
4160                assert_eq!(client.overlay_rebuild_count.get(), 0);
4161            }
4162        }
4163    }
4164
4165    fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
4166        let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4167            "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
4168            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
4169            "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4170        })))});
4171        let mut client =
4172            SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
4173        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();
4174        client.rebuild_overlay();
4175        client
4176            .mutate(vec![Mutation::Upsert {
4177                table: "tasks".into(),
4178                values: Map::from_iter([
4179                    ("id".into(), json!("offline")),
4180                    ("project_id".into(), json!("p1")),
4181                    ("title".into(), json!("pending edit")),
4182                ]),
4183                base_version: None,
4184            }])
4185            .unwrap();
4186        let image = Connection::open_in_memory().unwrap();
4187        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4188            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
4189        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();
4190        image
4191            .execute(
4192                "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
4193                [rows],
4194            )
4195            .unwrap();
4196        client.overlay_rebuild_count.set(0);
4197        let table = client.schema.table("tasks").unwrap().clone();
4198        (client, image, table)
4199    }
4200
4201    #[test]
4202    fn large_unique_import_copies_no_unrelated_tables() {
4203        let (mut client, image, table) = unique_import_fixture(10240);
4204        let pending = client.pending_commit_ids();
4205        client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
4206            CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
4207        assert!(matches!(
4208            client.apply_sqlite_image(
4209                &image,
4210                &table,
4211                true,
4212                &[("project_id".into(), vec!["p1".into()])],
4213                10240,
4214                7,
4215                "digest"
4216            ),
4217            Ok(10240)
4218        ));
4219        assert_eq!(client.overlay_rebuild_count.get(), 0);
4220        assert_eq!(client.pending_commit_ids(), pending);
4221        assert_eq!(
4222            client
4223                .query("SELECT count(*) AS n FROM tasks", &[])
4224                .unwrap()[0]["n"],
4225            10241
4226        );
4227        assert_eq!(
4228            client
4229                .query("SELECT count(*) AS n FROM tasks_fts", &[])
4230                .unwrap()[0]["n"],
4231            10241
4232        );
4233        assert_eq!(
4234            client
4235                .query("SELECT count(*) AS n FROM notes_fts", &[])
4236                .unwrap()[0]["n"],
4237            8192
4238        );
4239    }
4240
4241    #[test]
4242    #[ignore = "manual scaling benchmark; see bench/README.md"]
4243    fn benchmark_unique_image_import() {
4244        let mut samples = Vec::new();
4245        for rows in [10240, 51200, 102400] {
4246            for trial in 0..4 {
4247                let (mut client, image, table) = unique_import_fixture(rows);
4248                let start = std::time::Instant::now();
4249                assert!(
4250                    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)
4251                );
4252                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
4253                assert_eq!(
4254                    client
4255                        .query("SELECT count(*) AS n FROM tasks", &[])
4256                        .unwrap()[0]["n"],
4257                    rows + 1
4258                );
4259                assert_eq!(
4260                    client
4261                        .query("SELECT count(*) AS n FROM tasks_fts", &[])
4262                        .unwrap()[0]["n"],
4263                    rows + 1
4264                );
4265                assert_eq!(
4266                    client
4267                        .query("SELECT count(*) AS n FROM notes_fts", &[])
4268                        .unwrap()[0]["n"],
4269                    8192
4270                );
4271                assert_eq!(client.pending_commit_ids().len(), 1);
4272                if trial > 0 {
4273                    samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
4274                }
4275            }
4276        }
4277        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});
4278        let output = serde_json::to_string_pretty(&artifact).unwrap();
4279        if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
4280            use std::io::Write;
4281            let mut file = std::fs::OpenOptions::new()
4282                .write(true)
4283                .create_new(true)
4284                .open(path)
4285                .unwrap();
4286            file.write_all(output.as_bytes()).unwrap();
4287        } else {
4288            println!("{output}");
4289        }
4290    }
4291
4292    #[test]
4293    fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
4294        let schema = json!({
4295            "version": 1,
4296            "tables": [{
4297                "name": "catalogue_codes",
4298                "primaryKey": "id",
4299                "columns": [
4300                    { "name": "id", "type": "string", "nullable": false },
4301                    { "name": "release_id", "type": "string", "nullable": false },
4302                    { "name": "code", "type": "string", "nullable": false },
4303                    { "name": "title", "type": "string", "nullable": false }
4304                ],
4305                "scopes": [{ "pattern": "release:{release_id}" }],
4306                "ftsIndexes": [{
4307                    "name": "catalogue_codes_fts",
4308                    "columns": ["code", "title"],
4309                    "tokenize": "unicode61 remove_diacritics 2"
4310                }]
4311            }]
4312        });
4313        let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
4314            .expect("FTS5 client");
4315        let insert_trigger: String = client
4316            .conn
4317            .query_row(
4318                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
4319                [],
4320                |row| row.get(0),
4321            )
4322            .expect("insert trigger");
4323        let replace_guard: String = client
4324            .conn
4325            .query_row(
4326                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
4327                [],
4328                |row| row.get(0),
4329            )
4330            .expect("replace guard");
4331        assert!(!insert_trigger.contains("DELETE FROM"));
4332        assert!(replace_guard.contains("BEFORE INSERT"));
4333        assert!(replace_guard.contains("WHEN EXISTS"));
4334        let search = |client: &SyncClient, query: &str| {
4335            client
4336                .query(
4337                    "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",
4338                    &[Value::from(query)],
4339                )
4340                .expect("FTS query")
4341        };
4342
4343        client
4344            .mutate(vec![Mutation::Upsert {
4345                table: "catalogue_codes".to_owned(),
4346                values: Map::from_iter([
4347                    ("id".to_owned(), Value::from("c1")),
4348                    ("release_id".to_owned(), Value::from("r1")),
4349                    ("code".to_owned(), Value::from("A01")),
4350                    ("title".to_owned(), Value::from("Cholera")),
4351                ]),
4352                base_version: None,
4353            }])
4354            .expect("insert code");
4355        assert_eq!(search(&client, "cholera").len(), 1);
4356        let prior = client
4357            .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4358            .unwrap();
4359        client
4360            .conn
4361            .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
4362            .unwrap();
4363        client.create_synced_tables().unwrap();
4364        assert_eq!(
4365            client
4366                .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4367                .unwrap(),
4368            prior
4369        );
4370        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();
4371        let rendered = serde_json::to_string(&plan).unwrap();
4372        assert!(rendered.contains("COVERING INDEX"));
4373        assert!(rendered.contains("INDEX 0:="));
4374        client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
4375        assert_eq!(search(&client, "cholera").len(), 1);
4376
4377        client
4378            .mutate(vec![Mutation::Upsert {
4379                table: "catalogue_codes".to_owned(),
4380                values: Map::from_iter([
4381                    ("id".to_owned(), Value::from("c1")),
4382                    ("release_id".to_owned(), Value::from("r1")),
4383                    ("code".to_owned(), Value::from("A01")),
4384                    ("title".to_owned(), Value::from("Enteric infection")),
4385                ]),
4386                base_version: None,
4387            }])
4388            .expect("update code");
4389        assert!(search(&client, "cholera").is_empty());
4390        assert_eq!(search(&client, "enteric").len(), 1);
4391
4392        client
4393            .mutate(vec![Mutation::Delete {
4394                table: "catalogue_codes".to_owned(),
4395                row_id: "c1".to_owned(),
4396                base_version: None,
4397            }])
4398            .expect("delete code");
4399        assert!(search(&client, "enteric").is_empty());
4400    }
4401
4402    #[test]
4403    fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
4404        let schema = json!({
4405            "version": 1,
4406            "tables": [{
4407                "name": "patient_notes",
4408                "primaryKey": "id",
4409                "columns": [
4410                    { "name": "id", "type": "string", "nullable": false },
4411                    { "name": "practice_id", "type": "string", "nullable": false },
4412                    { "name": "encryption_key_id", "type": "string", "nullable": false },
4413                    { "name": "title", "type": "string", "nullable": false }
4414                ],
4415                "scopes": [{ "pattern": "practice:{practice_id}" }],
4416                "ftsIndexes": [{
4417                    "name": "patient_notes_fts",
4418                    "columns": ["title"],
4419                    "tokenize": "unicode61 remove_diacritics 2"
4420                }]
4421            }]
4422        });
4423        let mut client = SyncClient::new(
4424            "local-purge-test".to_owned(),
4425            &schema,
4426            ClientLimits::default(),
4427        )
4428        .expect("local purge client");
4429        client
4430            .conn
4431            .execute(
4432                "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
4433                 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
4434                 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
4435                [],
4436            )
4437            .expect("seed base rows");
4438        client.overlay_dirty.set(true);
4439        client.rebuild_overlay_if_dirty();
4440
4441        let note = |id: &str, key_id: &str, title: &str| {
4442            Map::from_iter([
4443                ("id".to_owned(), Value::from(id)),
4444                ("practice_id".to_owned(), Value::from("practice-1")),
4445                ("encryption_key_id".to_owned(), Value::from(key_id)),
4446                ("title".to_owned(), Value::from(title)),
4447            ])
4448        };
4449        let doomed = client
4450            .mutate(vec![
4451                Mutation::Upsert {
4452                    table: "patient_notes".to_owned(),
4453                    values: note("target", "key-revoked", "Target changed"),
4454                    base_version: None,
4455                },
4456                Mutation::Upsert {
4457                    table: "patient_notes".to_owned(),
4458                    values: note("unrelated", "key-held", "Unrelated changed"),
4459                    base_version: None,
4460                },
4461            ])
4462            .expect("doomed commit");
4463        let kept = client
4464            .mutate(vec![Mutation::Upsert {
4465                table: "patient_notes".to_owned(),
4466                values: note("kept", "key-held", "Kept optimistic"),
4467                base_version: None,
4468            }])
4469            .expect("kept commit");
4470        client.drain_change_batches();
4471
4472        let input = LocalDataPurgeInput {
4473            purge_id: "purge-001".to_owned(),
4474            targets: vec![LocalDataPurgeTarget {
4475                table: "patient_notes".to_owned(),
4476                selectors: BTreeMap::from([(
4477                    "encryption_key_id".to_owned(),
4478                    vec!["key-revoked".to_owned()],
4479                )]),
4480            }],
4481        };
4482        assert_eq!(
4483            client.purge_local_data(&input).expect("apply purge"),
4484            LocalDataPurgeResult {
4485                already_applied: false,
4486                purged_rows: 1,
4487                dropped_commits: 1,
4488            }
4489        );
4490        let rows = client
4491            .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
4492            .expect("visible rows");
4493        assert_eq!(rows.len(), 2);
4494        assert_eq!(rows[0]["id"], "kept");
4495        assert_eq!(rows[1]["id"], "unrelated");
4496        assert_eq!(rows[1]["title"], "Unrelated original");
4497        let fts = client
4498            .query(
4499                "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'",
4500                &[],
4501            )
4502            .expect("fts query");
4503        assert!(fts.is_empty());
4504        assert_eq!(client.outbox.len(), 1);
4505        assert_eq!(client.outbox[0].client_commit_id, kept);
4506        let outcome = client
4507            .commit_outcome(&doomed)
4508            .expect("read doomed outcome")
4509            .expect("doomed outcome");
4510        assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
4511        match &outcome.results[0] {
4512            CommitOperationOutcome::Error { rejection } => {
4513                assert_eq!(rejection.code, "client.local_data_purged");
4514                assert_eq!(rejection.client_commit_id, doomed);
4515            }
4516            other => panic!("expected local purge rejection, got {other:?}"),
4517        }
4518        assert_eq!(client.drain_change_batches().len(), 1);
4519        assert_eq!(
4520            client.purge_local_data(&input).expect("retry purge"),
4521            LocalDataPurgeResult {
4522                already_applied: true,
4523                purged_rows: 0,
4524                dropped_commits: 0,
4525            }
4526        );
4527        let conflicting = LocalDataPurgeInput {
4528            purge_id: input.purge_id.clone(),
4529            targets: vec![LocalDataPurgeTarget {
4530                table: "patient_notes".to_owned(),
4531                selectors: BTreeMap::from([(
4532                    "encryption_key_id".to_owned(),
4533                    vec!["key-held".to_owned()],
4534                )]),
4535            }],
4536        };
4537        assert!(client
4538            .purge_local_data(&conflicting)
4539            .expect_err("id collision must fail")
4540            .contains("already used with a different plan"));
4541    }
4542}
4543
4544impl SubState {
4545    fn name(self) -> &'static str {
4546        match self {
4547            SubState::Active => "active",
4548            SubState::Revoked => "revoked",
4549            SubState::Failed => "failed",
4550        }
4551    }
4552
4553    fn parse(value: &str) -> Self {
4554        match value {
4555            "revoked" => Self::Revoked,
4556            "failed" => Self::Failed,
4557            _ => Self::Active,
4558        }
4559    }
4560}
4561
4562#[derive(Debug, Clone)]
4563struct Subscription {
4564    id: String,
4565    table: String,
4566    requested: Vec<(String, Vec<String>)>,
4567    params: Option<String>,
4568    cursor: i64,
4569    /// §4.7 resume token, round-tripped opaquely.
4570    bootstrap_state: Option<String>,
4571    state: SubState,
4572    reason_code: Option<String>,
4573    /// Last effective scopes echoed while active (§3.3: persisted for the
4574    /// purge contract; each active echo replaces it).
4575    effective: Option<Vec<(String, Vec<String>)>>,
4576    synced_once: bool,
4577}
4578
4579#[derive(Debug, Clone)]
4580struct OutboxOp {
4581    upsert: bool,
4582    table: String,
4583    row_id: String,
4584    base_version: Option<i64>,
4585    /// Schema-agnostic local form (§0): driver JSON values, encoded with
4586    /// the current codec at send time. A key's presence IS the §6.1 sparse
4587    /// presence set — both the overlay and the wire payload derive from it.
4588    values: Option<Map<String, Value>>,
4589}
4590
4591impl From<&OutboxOp> for CommitOperation {
4592    fn from(operation: &OutboxOp) -> Self {
4593        Self {
4594            table: operation.table.clone(),
4595            row_id: operation.row_id.clone(),
4596            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
4597            base_version: operation.base_version,
4598            values: operation.values.clone(),
4599        }
4600    }
4601}
4602
4603#[derive(Debug, Clone)]
4604struct OutboxCommit {
4605    client_commit_id: String,
4606    ops: Vec<OutboxOp>,
4607}
4608
4609/// One commit's `(table, value keys)` per upsert operation — the classification
4610/// shape the §7.4.4 send-time drop and the RFC 0005 D6 audit share, so the two
4611/// can never disagree about which commits cannot re-encode.
4612fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
4613    commit
4614        .ops
4615        .iter()
4616        .filter(|operation| operation.upsert)
4617        .map(|operation| {
4618            (
4619                operation.table.as_str(),
4620                operation
4621                    .values
4622                    .as_ref()
4623                    .map(|values| values.keys().map(String::as_str).collect())
4624                    .unwrap_or_default(),
4625            )
4626        })
4627        .collect()
4628}
4629
4630#[derive(Debug, Clone)]
4631struct CompiledLocalDataPurgeTarget {
4632    table: String,
4633    selectors: Vec<(String, Vec<String>)>,
4634}
4635
4636struct StoredCommitOutcomeRow {
4637    sequence: i64,
4638    client_commit_id: String,
4639    status: String,
4640    recorded_at_ms: i64,
4641    results_json: String,
4642    operations_json: Option<String>,
4643    resolution: String,
4644    resolved_at_ms: Option<i64>,
4645    replacement_client_commit_id: Option<String>,
4646}
4647
4648/// Section outcome distinguishing the §5.6 subscription-local fail-closed
4649/// path from a round-aborting failure (§1.4 rule 5).
4650enum SectionError {
4651    FailClosed,
4652    Abort(String, String),
4653}
4654
4655struct RequestMeta {
4656    pushed_ids: Vec<String>,
4657    /// Subscription id → the request carried `cursor < 0` and no resume
4658    /// token (§5.6 first-page detection: a *fresh* bootstrap).
4659    fresh: Vec<(String, bool)>,
4660    accept: u8,
4661    /// §6.1 splitBatch: outbox commits held back from THIS request because
4662    /// the running operation count reached the push cap — the next round
4663    /// pushes them (`sync_needed` stays set while any remain).
4664    deferred_commits: usize,
4665}
4666
4667#[derive(Default)]
4668struct ChangeAccumulator {
4669    /// None means table-wide; Some is the exact scoped domain.
4670    tables: BTreeMap<String, Option<BTreeSet<String>>>,
4671    windows: BTreeMap<(String, String), BTreeSet<String>>,
4672    status: bool,
4673    conflicts: bool,
4674    rejections: bool,
4675    outcomes: bool,
4676}
4677
4678impl ChangeAccumulator {
4679    fn table(&mut self, table: &str) {
4680        self.tables.insert(table.to_owned(), None);
4681    }
4682
4683    fn scope(&mut self, table: &str, key: String) {
4684        match self.tables.get_mut(table) {
4685            Some(None) => {}
4686            Some(Some(keys)) => {
4687                keys.insert(key);
4688            }
4689            None => {
4690                self.tables
4691                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
4692            }
4693        }
4694    }
4695
4696    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
4697        self.windows
4698            .entry((base_key.to_owned(), table.to_owned()))
4699            .or_default()
4700            .insert(unit.to_owned());
4701    }
4702
4703    fn touched(&self) -> bool {
4704        !self.tables.is_empty()
4705            || !self.windows.is_empty()
4706            || self.status
4707            || self.conflicts
4708            || self.rejections
4709            || self.outcomes
4710    }
4711}
4712
4713/// §6.1: the server caps total operations per request (reference default
4714/// 500) and rejects the whole batch with `sync.too_many_operations`; the
4715/// client "splits and retries". Splitting happens at build time: commits are
4716/// included IN ORDER until the operation budget is spent, the rest wait for
4717/// the next round.
4718const PUSH_OPS_PER_REQUEST: usize = 500;
4719const MAX_LOCAL_PURGE_TARGETS: usize = 64;
4720const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
4721const MAX_LOCAL_PURGE_VALUES: usize = 128;
4722const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
4723pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
4724
4725pub struct SyncClient {
4726    #[cfg(feature = "bench-internals")]
4727    benchmark_phases: Recorder,
4728    conn: Connection,
4729    schema: ClientSchema,
4730    client_id: String,
4731    limits: ClientLimits,
4732    subs: Vec<Subscription>,
4733    outbox: Vec<OutboxCommit>,
4734    conflicts: Vec<ConflictRecord>,
4735    rejections: Vec<RejectionRecord>,
4736    schema_floor: Option<SchemaFloor>,
4737    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
4738    lease_state: Option<LeaseState>,
4739    /// §1.6: the schema-floor response stops syncing until an upgrade.
4740    stopped: bool,
4741    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
4742    upgrading: bool,
4743    /// §8.4 coalesced sync-needed signal.
4744    sync_needed: bool,
4745    /// §8.8: how sync rounds treat the realtime binding, and its exact
4746    /// availability state with the evidence behind a lost or refused one.
4747    realtime_policy: RealtimePolicy,
4748    realtime_state: RealtimeState,
4749    realtime_reason_code: Option<String>,
4750    realtime_retry_delay_ms: Option<u64>,
4751    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
4752    presence: HashMap<String, HashMap<String, PresencePeer>>,
4753    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
4754    /// host may pin it (conformance runs on a virtual clock).
4755    now_ms: Option<i64>,
4756    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
4757    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
4758    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
4759    encryption: crate::values::EncryptionConfig,
4760    /// Fail-closed host bootstrap gate. While set, command hosts permit only
4761    /// status/lifecycle inspection and an exact authorized local purge.
4762    security_preflight: bool,
4763    /// RFC 0005 D2/D8: the previous-version capture config. `None` (default)
4764    /// means the feature is off; the descriptor write and the container orphan
4765    /// sweep still happen.
4766    previous_version: Option<PreviousVersionContextConfig>,
4767    /// Per-table primary-key upsert SQL, built once per (full table name) —
4768    /// the row write path runs per row during bootstrap (§5.6), so the SQL
4769    /// string (and, via `prepare_cached`, its compiled statement) is reused
4770    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
4771    /// schema reset (the column lists may have changed).
4772    insert_sql: RefCell<HashMap<String, String>>,
4773    /// §7.1 rebuild gate: true whenever the base tables or the outbox have
4774    /// diverged from the visible overlay since the last rebuild. Lets a
4775    /// no-op sync round skip the full base→visible copy.
4776    overlay_dirty: Cell<bool>,
4777    /// Test-only structural performance signal: response processing must not
4778    /// turn a batch of acknowledgements into one full overlay rebuild each.
4779    #[cfg(test)]
4780    overlay_rebuild_count: Cell<usize>,
4781    /// Test-only structural performance signal: outcome retention is enforced
4782    /// once per response rather than once per acknowledgement.
4783    #[cfg(test)]
4784    outcome_prune_count: Cell<usize>,
4785    /// Exact observer-transaction output drained by command/FFI hosts.
4786    progress: ProgressObserver,
4787    change_queue: VecDeque<ClientChangeBatch>,
4788    sync_intent_queue: VecDeque<SyncIntent>,
4789    /// Explicit exponential retry policy for transient transport failures.
4790    retry_delay_ms: u64,
4791    last_round: Option<DiagnosticLastRound>,
4792    last_change: Option<DiagnosticLastChange>,
4793    /// §7.6: latest failed owned snapshot read per owner id, oldest first.
4794    query_failures: Vec<DiagnosticQueryFailure>,
4795}
4796
4797pub(crate) fn quote_ident(name: &str) -> String {
4798    format!("\"{}\"", name.replace('"', "\"\""))
4799}
4800/// §7.4.1 `_syncular_meta` helpers. Free functions so RFC 0005's
4801/// previous-version module reads and writes the SAME records as the client
4802/// instead of growing a parallel meta layer.
4803pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
4804    conn.query_row(
4805        "SELECT value FROM _syncular_meta WHERE key = ?1",
4806        rusqlite::params![key],
4807        |row| row.get::<_, String>(0),
4808    )
4809    .ok()
4810}
4811
4812pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
4813    let _ = conn.execute(
4814        "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
4815        rusqlite::params![key, value],
4816    );
4817}
4818
4819pub(crate) fn meta_delete(conn: &Connection, key: &str) {
4820    let _ = conn.execute(
4821        "DELETE FROM _syncular_meta WHERE key = ?1",
4822        rusqlite::params![key],
4823    );
4824}
4825
4826/// Seek by the existing key while retaining the exact legacy text match.
4827fn row_id_predicate(table: &TableSchema) -> String {
4828    let key = quote_ident(&table.primary_key);
4829    let text_match = format!("CAST({key} AS TEXT) = ?1");
4830    match table.columns[table.pk_index].ty {
4831        ColumnType::String | ColumnType::Json => {
4832            format!("{key} = ?1 AND {text_match}")
4833        }
4834        // Native columns have no declared SQLite affinity. Match the stored
4835        // integer bind type explicitly. Unary + removes expression affinity so
4836        // SQLite can seek the typeless key; the text check preserves exact IDs.
4837        ColumnType::Integer | ColumnType::Boolean => {
4838            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
4839        }
4840        // REAL-to-text conversion can round distinct values to the same text.
4841        // Narrowing those matches by numeric equality would change behavior.
4842        _ => text_match,
4843    }
4844}
4845
4846fn is_local_operation_code_like(value: &str) -> bool {
4847    let bytes = value.as_bytes();
4848    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
4849        && bytes
4850            .iter()
4851            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
4852}
4853
4854fn base_table(name: &str) -> String {
4855    quote_ident(&format!("_syncular_base_{name}"))
4856}
4857
4858/// §4.8: a stable, server-opaque key for a window base — table + variable +
4859/// canonical fixed scopes. Two `set_window` calls with the same base
4860/// address the same registry rows.
4861/// §4.8 deferred eviction record: (sub id, table, effective scope map).
4862type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
4863
4864fn window_base_key(base: &WindowBase) -> String {
4865    format!(
4866        "{}\0{}\0{}",
4867        base.table,
4868        base.variable,
4869        canonical_scope_json(&base.fixed_scopes)
4870    )
4871}
4872
4873/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
4874fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
4875    let mut scopes = base.fixed_scopes.clone();
4876    scopes.retain(|(k, _)| k != &base.variable);
4877    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
4878    scopes
4879}
4880
4881/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
4882/// echoed not interpreted by the server, so the exact hash is client
4883/// convention; SHA-256 matches the SPEC's worked example.
4884fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
4885    let canonical = canonical_scope_json(&unit_scopes(base, unit));
4886    let digest = Sha256::digest(canonical.as_bytes());
4887    let hex = bytes_to_hex(&digest);
4888    format!("w:{}:{}", base.table, &hex[..16])
4889}
4890
4891fn visible_table(name: &str) -> String {
4892    quote_ident(name)
4893}
4894
4895const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
4896
4897/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
4898/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
4899fn is_synced_table_name(name: &str) -> bool {
4900    if name.starts_with("sqlite_") {
4901        return false;
4902    }
4903    if name.starts_with("_syncular_base_") {
4904        return true; // the base half of a synced table pair
4905    }
4906    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
4907    !name.starts_with("_syncular_")
4908}
4909
4910/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
4911fn blob_id_for(bytes: &[u8]) -> String {
4912    let digest = Sha256::digest(bytes);
4913    format!("sha256:{}", bytes_to_hex(&digest))
4914}
4915
4916/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
4917/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
4918/// per row on the §5.6 bootstrap path), scalars bind owned.
4919enum RowParam<'a> {
4920    Cell(&'a Option<ColumnValue>),
4921    Version(i64),
4922}
4923
4924impl rusqlite::ToSql for RowParam<'_> {
4925    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
4926        Ok(match self {
4927            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
4928            RowParam::Cell(cell) => match cell {
4929                None => ToSqlOutput::Owned(SqlValue::Null),
4930                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
4931                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
4932                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
4933                Some(ColumnValue::Boolean(b)) => {
4934                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
4935                }
4936                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
4937                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
4938                }
4939                // §5.10: crdt bytes store as BLOB, like bytes.
4940                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
4941                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
4942                }
4943            },
4944        })
4945    }
4946}
4947
4948// Retain a typed primary key across the current import chunk without copying row payloads.
4949fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
4950    match param.to_sql().map_err(|error| error.to_string())? {
4951        ToSqlOutput::Owned(value) => Ok(value),
4952        ToSqlOutput::Borrowed(value) => {
4953            SqlValue::try_from(value).map_err(|error| error.to_string())
4954        }
4955        _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
4956    }
4957}
4958
4959/// §5.3 image cell → bind parameter, strict per the declared column type
4960/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
4961/// nullable). Mismatches are image-producer violations. Returns the cell
4962/// borrowed when the stored representation already matches what the row
4963/// codec would write, or the normalized scalar (boolean → 0/1, float from
4964/// INTEGER → REAL) otherwise — the local write is byte-identical to the
4965/// old convert-then-insert path without allocating per cell.
4966fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
4967    use ssp2::segment::ColumnType;
4968    let mismatch = || {
4969        Err(format!(
4970            "image column {:?} holds a value of the wrong type",
4971            column.name
4972        ))
4973    };
4974    match value {
4975        ValueRef::Null => {
4976            if !column.nullable {
4977                return Err(format!(
4978                    "image column {:?} is NULL but not nullable",
4979                    column.name
4980                ));
4981            }
4982            Ok(ToSqlOutput::Owned(SqlValue::Null))
4983        }
4984        ValueRef::Integer(i) => match column.ty {
4985            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
4986            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
4987            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
4988            _ => mismatch(),
4989        },
4990        ValueRef::Real(_) => match column.ty {
4991            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
4992            _ => mismatch(),
4993        },
4994        ValueRef::Text(t) => {
4995            std::str::from_utf8(t)
4996                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
4997            match column.ty {
4998                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
4999                    Ok(ToSqlOutput::Borrowed(value))
5000                }
5001                _ => mismatch(),
5002            }
5003        }
5004        ValueRef::Blob(_) => match column.ty {
5005            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
5006            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
5007            _ => mismatch(),
5008        },
5009    }
5010}
5011
5012pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
5013    use rusqlite::types::ValueRef;
5014    match value {
5015        ValueRef::Null => Value::Null,
5016        ValueRef::Integer(i) => match column.ty {
5017            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
5018            ssp2::segment::ColumnType::Float => {
5019                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
5020            }
5021            _ => Value::from(i),
5022        },
5023        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5024        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5025        ValueRef::Blob(b) => {
5026            let mut map = Map::new();
5027            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5028            Value::Object(map)
5029        }
5030    }
5031}
5032
5033/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
5034/// Objects are accepted in lossless `{"$bytes": hex}` and
5035/// `{"$bigint": decimal}` envelope forms.
5036fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
5037    Ok(match value {
5038        Value::Null => SqlValue::Null,
5039        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
5040        Value::Number(n) => {
5041            if let Some(i) = n.as_i64() {
5042                SqlValue::Integer(i)
5043            } else if let Some(f) = n.as_f64() {
5044                SqlValue::Real(f)
5045            } else {
5046                return Err(format!("query param number {n} is out of range"));
5047            }
5048        }
5049        Value::String(s) => SqlValue::Text(s.clone()),
5050        Value::Object(_) => {
5051            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
5052                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
5053            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
5054                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
5055                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
5056                })?)
5057            } else {
5058                return Err(
5059                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
5060                        .to_owned(),
5061                );
5062            }
5063        }
5064        Value::Array(_) => return Err("query array params are not supported".to_owned()),
5065    })
5066}
5067
5068/// Map a rusqlite value with no schema column to consult (arbitrary query
5069/// output): integers/reals/text pass through by stored affinity, blobs ride
5070/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
5071/// schema column type to recover booleans/floats/json.
5072fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
5073    use rusqlite::types::ValueRef;
5074    match value {
5075        ValueRef::Null => Value::Null,
5076        ValueRef::Integer(i) => {
5077            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
5078            // ordinary UI-sized integers ergonomic and envelope only values
5079            // beyond JavaScript's safe range.
5080            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
5081            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
5082                Value::from(i)
5083            } else {
5084                let mut map = Map::new();
5085                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
5086                Value::Object(map)
5087            }
5088        }
5089        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5090        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5091        ValueRef::Blob(b) => {
5092            let mut map = Map::new();
5093            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5094            Value::Object(map)
5095        }
5096    }
5097}
5098
5099/// §7.5: SQLite failures classify by result code into [`QueryReadFailure`].
5100fn query_connection(
5101    conn: &Connection,
5102    sql: &str,
5103    params: &[QueryValue],
5104) -> Result<Vec<QueryRow>, QueryReadFailure> {
5105    crate::query_guard::assert_read_only_query(sql)?;
5106    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
5107    let bound: Vec<SqlValue> = params
5108        .iter()
5109        .map(json_param_to_sql)
5110        .collect::<Result<_, _>>()?;
5111    let mut stmt = conn.prepare(&lowered_sql)?;
5112    let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
5113    let bound_refs: Vec<&dyn rusqlite::ToSql> =
5114        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
5115    let mut sql_rows = stmt.query(bound_refs.as_slice())?;
5116    let mut out = Vec::new();
5117    while let Some(row) = sql_rows.next()? {
5118        let mut record = Map::new();
5119        for (i, name) in column_names.iter().enumerate() {
5120            let value = row.get_ref(i)?;
5121            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
5122        }
5123        out.push(record);
5124    }
5125    Ok(out)
5126}
5127
5128fn persisted_window_state(
5129    conn: &Connection,
5130    base: &WindowBase,
5131) -> Result<WindowState, QueryReadFailure> {
5132    let mut stmt = conn.prepare(
5133        "SELECT windows.unit, subscriptions.state_json
5134           FROM _syncular_windows AS windows
5135           JOIN _syncular_subscriptions AS subscriptions
5136             ON subscriptions.id = windows.sub_id
5137          WHERE windows.base = ?1
5138          ORDER BY windows.unit ASC",
5139    )?;
5140    let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
5141        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5142    })?;
5143    let mut units = Vec::new();
5144    let mut pending = Vec::new();
5145    for row in rows {
5146        let (unit, raw) = row?;
5147        let state: Value = serde_json::from_str(&raw)
5148            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
5149        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
5150            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
5151            || state
5152                .get("bootstrapState")
5153                .is_some_and(|value| !value.is_null());
5154        if is_pending {
5155            pending.push(unit.clone());
5156        }
5157        units.push(unit);
5158    }
5159    Ok(WindowState { units, pending })
5160}
5161
5162fn snapshot_connection(
5163    conn: &Connection,
5164    sql: &str,
5165    params: &[Value],
5166    coverage: &[WindowCoverage],
5167) -> Result<QuerySnapshot, QueryReadFailure> {
5168    conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
5169    let result = (|| {
5170        let revision = conn
5171            .query_row(
5172                "SELECT value FROM _syncular_meta WHERE key = ?1",
5173                rusqlite::params![LOCAL_REVISION_KEY],
5174                |row| row.get::<_, String>(0),
5175            )
5176            .ok()
5177            .and_then(|value| value.parse::<u64>().ok())
5178            .unwrap_or(0);
5179        let rows = query_connection(conn, sql, params)?;
5180        let mut pending = Vec::new();
5181        let mut missing = Vec::new();
5182        for requested in coverage {
5183            let base_key = window_base_key(&requested.base);
5184            let state = persisted_window_state(conn, &requested.base)?;
5185            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
5186                let reference = WindowUnitRef {
5187                    base_key: base_key.clone(),
5188                    unit: unit.clone(),
5189                };
5190                if !state.units.iter().any(|held| held == &unit) {
5191                    missing.push(reference);
5192                } else if state.pending.iter().any(|held| held == &unit) {
5193                    pending.push(reference);
5194                }
5195            }
5196        }
5197        Ok(QuerySnapshot {
5198            revision: revision.to_string(),
5199            rows,
5200            coverage: CoverageSnapshot {
5201                complete: pending.is_empty() && missing.is_empty(),
5202                pending,
5203                missing,
5204            },
5205        })
5206    })();
5207    match result {
5208        Ok(snapshot) => {
5209            conn.execute_batch("RELEASE syncular_snapshot_read")?;
5210            Ok(snapshot)
5211        }
5212        Err(error) => {
5213            let _ = conn.execute_batch(
5214                "ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read",
5215            );
5216            Err(error)
5217        }
5218    }
5219}
5220
5221/// A long-lived read-only SQLite sidecar for latency-critical native views.
5222/// Network rounds stay serialized on the mutable core owner, while atomic
5223/// query snapshots use this independent connection and therefore never queue
5224/// behind HTTP/WebSocket latency.
5225pub struct FileQuerySnapshotReader {
5226    path: String,
5227    conn: Option<Connection>,
5228}
5229
5230impl FileQuerySnapshotReader {
5231    #[must_use]
5232    pub fn new(path: impl Into<String>) -> Self {
5233        Self {
5234            path: path.into(),
5235            conn: None,
5236        }
5237    }
5238
5239    fn connection(&mut self) -> Result<&Connection, String> {
5240        if self.conn.is_none() {
5241            let conn = Connection::open_with_flags(
5242                &self.path,
5243                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
5244            )
5245            .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
5246            conn.busy_timeout(std::time::Duration::from_millis(250))
5247                .map_err(|error| error.to_string())?;
5248            self.conn = Some(conn);
5249        }
5250        self.conn
5251            .as_ref()
5252            .ok_or_else(|| "read sidecar connection missing".to_owned())
5253    }
5254
5255    /// Hosts report an owned failure, and the first success after it, to the
5256    /// owning core through [`SyncClient::record_query_read`] (§7.6).
5257    pub fn query_snapshot(
5258        &mut self,
5259        sql: &str,
5260        params: &[Value],
5261        coverage: &[WindowCoverage],
5262    ) -> Result<QuerySnapshot, QueryReadFailure> {
5263        snapshot_connection(self.connection()?, sql, params, coverage)
5264    }
5265}
5266
5267impl SyncClient {
5268    pub fn new_with_identity(
5269        client_id: Option<String>,
5270        schema_json: &Value,
5271        limits: ClientLimits,
5272    ) -> Result<Self, String> {
5273        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5274        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5275        Self::with_connection(resolved, schema_json, limits, conn)
5276    }
5277
5278    pub fn new(
5279        client_id: String,
5280        schema_json: &Value,
5281        limits: ClientLimits,
5282    ) -> Result<Self, String> {
5283        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5284        Self::with_connection(client_id, schema_json, limits, conn)
5285    }
5286
5287    /// Build a client backed by an on-disk SQLite database at `path` — the
5288    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
5289    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
5290    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
5291    /// of the command router's dependency set (the router only holds a path).
5292    pub fn open_path(
5293        client_id: String,
5294        schema_json: &Value,
5295        limits: ClientLimits,
5296        path: &str,
5297    ) -> Result<Self, String> {
5298        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5299        Self::with_connection(client_id, schema_json, limits, conn)
5300    }
5301
5302    pub fn open_path_with_identity(
5303        client_id: Option<String>,
5304        schema_json: &Value,
5305        limits: ClientLimits,
5306        path: &str,
5307    ) -> Result<Self, String> {
5308        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5309        // File-backed native clients use an independent read connection for
5310        // latency-critical snapshots. WAL is SQLite's intended reader/writer
5311        // concurrency mode: a view read never holds a rollback-journal lock
5312        // that delays the mutable client's next commit, and a short busy
5313        // timeout absorbs the tiny checkpoint/schema-lock windows.
5314        conn.busy_timeout(std::time::Duration::from_millis(250))
5315            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
5316        conn.pragma_update(None, "journal_mode", "WAL")
5317            .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
5318        let persisted = conn
5319            .query_row(
5320                "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
5321                [],
5322                |row| row.get::<_, String>(0),
5323            )
5324            .ok();
5325        let resolved = persisted
5326            .clone()
5327            .or(client_id.clone())
5328            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5329        if let (Some(existing), Some(requested)) = (persisted, client_id) {
5330            if existing != requested {
5331                return Err(format!(
5332                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
5333                ));
5334            }
5335        }
5336        Self::with_connection(resolved, schema_json, limits, conn)
5337    }
5338
5339    #[must_use]
5340    pub fn client_id(&self) -> &str {
5341        &self.client_id
5342    }
5343
5344    /// Build a client over a caller-supplied rusqlite connection — the seam a
5345    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
5346    /// with an on-disk database (`Connection::open(path)`) rather than the
5347    /// default `:memory:`. The connection MUST be fresh (no pre-existing
5348    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
5349    /// the same file across process restarts reuses the persisted rows.
5350    pub fn with_connection(
5351        client_id: String,
5352        schema_json: &Value,
5353        limits: ClientLimits,
5354        conn: Connection,
5355    ) -> Result<Self, String> {
5356        if limits.outcome_retention_max_entries == Some(0) {
5357            return Err(
5358                "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
5359            );
5360        }
5361        let schema = parse_schema_json(schema_json)?;
5362        // RFC 0005 D8: resolve the previous-version config before the opening
5363        // reset runs — the capture happens inside it. Bad bounds fail loud.
5364        let previous_version = match limits.previous_version_context {
5365            Some(config) => {
5366                config.validate()?;
5367                Some(config)
5368            }
5369            None => None,
5370        };
5371        let mut client = SyncClient {
5372            #[cfg(feature = "bench-internals")]
5373            benchmark_phases: Recorder::default(),
5374            conn,
5375            schema,
5376            client_id,
5377            limits,
5378            subs: Vec::new(),
5379            outbox: Vec::new(),
5380            conflicts: Vec::new(),
5381            rejections: Vec::new(),
5382            schema_floor: None,
5383            lease_state: None,
5384            stopped: false,
5385            upgrading: false,
5386            sync_needed: false,
5387            realtime_policy: RealtimePolicy::Optional,
5388            realtime_state: RealtimeState::Disconnected,
5389            realtime_reason_code: None,
5390            realtime_retry_delay_ms: None,
5391            presence: HashMap::new(),
5392            now_ms: None,
5393            encryption: crate::values::EncryptionConfig::default(),
5394            security_preflight: false,
5395            previous_version,
5396            insert_sql: RefCell::new(HashMap::new()),
5397            overlay_dirty: Cell::new(false),
5398            #[cfg(test)]
5399            overlay_rebuild_count: Cell::new(0),
5400            #[cfg(test)]
5401            outcome_prune_count: Cell::new(0),
5402            progress: ProgressObserver::default(),
5403            change_queue: VecDeque::new(),
5404            sync_intent_queue: VecDeque::new(),
5405            retry_delay_ms: 250,
5406            last_round: None,
5407            last_change: None,
5408            query_failures: Vec::new(),
5409        };
5410        // The row write path leans on the prepared-statement cache (two
5411        // insert statements per synced table, plus the bookkeeping
5412        // statements); size it so a multi-table schema never thrashes.
5413        client
5414            .conn
5415            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
5416        // Protected bookkeeping must exist before inspecting the persisted
5417        // schema marker. New app indexes may reference columns that only
5418        // exist after a version-bump reset.
5419        client.create_bookkeeping_tables()?;
5420        match client.get_meta(CLIENT_ID_KEY) {
5421            Some(existing) if existing != client.client_id => {
5422                return Err(format!(
5423                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
5424                    client.client_id
5425                ));
5426            }
5427            None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
5428            _ => {}
5429        }
5430        client.restore_persisted_state()?;
5431        let marker = client
5432            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
5433            .and_then(|value| value.parse::<i32>().ok());
5434        match marker {
5435            None => {
5436                client.create_synced_tables()?;
5437                client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
5438                // D1: the descriptor is written beside every marker write.
5439                set_local_schema_descriptor(&client.conn, &client.schema);
5440            }
5441            Some(version) if version == client.schema.version => {
5442                client.create_synced_tables()?;
5443                // RFC 0005 D1: the same-version open backfills the descriptor
5444                // for a database first opened by an unaware binary, so the
5445                // NEXT bump can capture without needing a schema bump first.
5446                set_local_schema_descriptor(&client.conn, &client.schema);
5447            }
5448            Some(_) => client.run_schema_reset()?,
5449        }
5450        // RFC 0005 D9: the reset sweep cannot see a container whose capture
5451        // committed in its OWN file while the replica's savepoint never
5452        // released — a crash at that seam leaves a container beside a matching
5453        // OLD marker, and the next open at that version runs no reset. Discard
5454        // it before anything can read it. No-op when no container exists.
5455        client.reconcile_previous_version_at_boot()?;
5456        client.clear_satisfied_persisted_schema_floor();
5457        client.prune_unknown_subscriptions()?;
5458        if marker == Some(client.schema.version) && !client.outbox.is_empty() {
5459            // Reconstruct the visible optimistic overlay from the durable base
5460            // plus outbox instead of trusting a process-interrupted mirror.
5461            client.overlay_dirty.set(true);
5462            client.rebuild_overlay();
5463        }
5464        // Every persisted active subscription needs one catch-up pull on open:
5465        // realtime only covers changes after connection, while an idempotent
5466        // setWindow correctly creates no fresh command effect. Pending outbox
5467        // work has the same restart requirement. The core owns this intent so
5468        // native hosts never poll or require an application-issued sync().
5469        client.enqueue_startup_sync_if_needed()?;
5470        Ok(client)
5471    }
5472
5473    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
5474    /// against this instead of system time (conformance virtual clock).
5475    pub fn set_now_ms(&mut self, now_ms: i64) {
5476        self.now_ms = Some(now_ms);
5477    }
5478
5479    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
5480    /// The command router parses these from the `create` command's
5481    /// `encryption` config (keys as `{$bytes: hex}`).
5482    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
5483        self.encryption = encryption;
5484    }
5485
5486    #[must_use]
5487    pub fn security_lifecycle(&self) -> &'static str {
5488        if self.security_preflight {
5489            "preflight"
5490        } else {
5491            "active"
5492        }
5493    }
5494
5495    #[must_use]
5496    pub fn security_preflight(&self) -> bool {
5497        self.security_preflight
5498    }
5499
5500    /// Quarantine this replica: enter the fail-closed gate, release all
5501    /// core-owned key material, and record the gate in the database so
5502    /// reopening re-enters preflight until `activate_security` clears it.
5503    ///
5504    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
5505    /// which must NOT leave that durable mark.
5506    pub fn begin_security_preflight(&mut self) {
5507        self.seal_security_on_teardown();
5508        // Persist the quarantine so it survives handle teardown and restart:
5509        // reopening this replica re-enters preflight until activation clears it.
5510        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
5511    }
5512
5513    /// Release core-owned key material as a host tears the client down,
5514    /// WITHOUT recording a quarantine.
5515    ///
5516    /// Shutting an activated client down is not a quarantine event. Persisting
5517    /// the gate here marks every cleanly closed replica as pending, and the
5518    /// next plain `create` is then refused permanently: the reopen path
5519    /// restores the flag from the marker and the create guard rejects it. The
5520    /// in-memory flag still closes the gate for anything still holding this
5521    /// instance, which is all a teardown barrier needs.
5522    pub fn seal_security_on_teardown(&mut self) {
5523        self.security_preflight = true;
5524        self.encryption = crate::values::EncryptionConfig::default();
5525        self.sync_intent_queue.clear();
5526    }
5527
5528    /// Install the post-authentication keyring and release the host loop.
5529    pub fn activate_security(
5530        &mut self,
5531        encryption: crate::values::EncryptionConfig,
5532    ) -> Result<(), String> {
5533        if !self.security_preflight {
5534            return Err(
5535                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
5536            );
5537        }
5538        self.enqueue_startup_sync_if_needed()?;
5539        self.encryption = encryption;
5540        self.security_preflight = false;
5541        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
5542        Ok(())
5543    }
5544
5545    fn clock_now_ms(&self) -> i64 {
5546        self.now_ms.unwrap_or_else(|| {
5547            std::time::SystemTime::now()
5548                .duration_since(std::time::UNIX_EPOCH)
5549                .map(|d| d.as_millis() as i64)
5550                .unwrap_or(0)
5551        })
5552    }
5553
5554    fn create_bookkeeping_tables(&self) -> Result<(), String> {
5555        // Durable client bookkeeping (outbox + subscription + meta).
5556        self.conn
5557            .execute_batch(
5558                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
5559                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
5560                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
5561                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
5562                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
5563                   client_commit_id TEXT NOT NULL UNIQUE,
5564                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
5565                   recorded_at_ms INTEGER NOT NULL,
5566                   results_json TEXT NOT NULL,
5567                   operations_json TEXT,
5568                   resolution TEXT NOT NULL DEFAULT 'active'
5569                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
5570                   resolved_at_ms INTEGER,
5571                   replacement_client_commit_id TEXT);
5572                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
5573                   ON _syncular_commit_outcomes(resolution, seq);
5574                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
5575                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
5576                 CREATE TABLE IF NOT EXISTS _syncular_meta (
5577                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
5578                 CREATE TABLE IF NOT EXISTS _syncular_windows (
5579                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
5580                   PRIMARY KEY (base, unit));
5581                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
5582                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
5583                   effective_scopes TEXT NOT NULL);
5584                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
5585                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
5586                   PRIMARY KEY(commit_id, blob_id));
5587                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
5588                   ON _syncular_blob_commit_refs(blob_id);",
5589            )
5590            .map_err(|e| e.to_string())?;
5591        // Migrate an outcome journal created before failed aggregate
5592        // envelopes were retained. Historical rows intentionally stay NULL.
5593        let _ = self
5594            .conn
5595            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
5596        if self.get_meta(LOCAL_REVISION_KEY).is_none() {
5597            self.set_meta(LOCAL_REVISION_KEY, "0");
5598        }
5599        // §5.9.7 blob cache. Mutable upload state stays off the body row so
5600        // completing a large upload does not rewrite its BLOB record.
5601        if self.schema_has_blobs() {
5602            let exists = self
5603                .conn
5604                .query_row(
5605                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
5606                    [],
5607                    |row| row.get::<_, bool>(0),
5608                )
5609                .map_err(|e| e.to_string())?;
5610            if exists {
5611                let mut statement = self
5612                    .conn
5613                    .prepare("PRAGMA table_info(_syncular_blobs)")
5614                    .map_err(|e| e.to_string())?;
5615                let mut columns = statement
5616                    .query_map([], |row| row.get::<_, String>(1))
5617                    .map_err(|e| e.to_string())?
5618                    .collect::<Result<Vec<_>, _>>()
5619                    .map_err(|e| e.to_string())?;
5620                columns.sort();
5621                let mut expected = vec![
5622                    "blob_id",
5623                    "byte_length",
5624                    "bytes",
5625                    "created_at_ms",
5626                    "media_type",
5627                ];
5628                expected.sort_unstable();
5629                if columns != expected {
5630                    return Err(
5631                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
5632                    );
5633                }
5634            } else {
5635                self.conn
5636                    .execute_batch(
5637                        "CREATE TABLE _syncular_blobs(
5638                           blob_id TEXT PRIMARY KEY,
5639                           bytes BLOB NOT NULL,
5640                           byte_length INTEGER NOT NULL,
5641                           media_type TEXT,
5642                           created_at_ms INTEGER NOT NULL);",
5643                    )
5644                    .map_err(|e| e.to_string())?;
5645            }
5646            self.conn
5647                .execute_batch(
5648                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
5649                       blob_id TEXT PRIMARY KEY,
5650                       media_type TEXT,
5651                       created_at_ms INTEGER NOT NULL);",
5652                )
5653                .map_err(|e| e.to_string())?;
5654        }
5655        Ok(())
5656    }
5657
5658    /// True iff any synced table declares a `blob_ref` column (§5.9).
5659    fn schema_has_blobs(&self) -> bool {
5660        self.schema
5661            .tables
5662            .iter()
5663            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
5664    }
5665
5666    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
5667
5668    fn get_meta(&self, key: &str) -> Option<String> {
5669        meta_get(&self.conn, key)
5670    }
5671
5672    fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
5673        self.conn
5674            .query_row(
5675                "SELECT value FROM _syncular_meta WHERE key = ?1",
5676                rusqlite::params![key],
5677                |row| row.get::<_, String>(0),
5678            )
5679            .optional()
5680            .map_err(|_| {
5681                "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
5682            })
5683    }
5684
5685    fn set_meta(&self, key: &str, value: &str) {
5686        meta_set(&self.conn, key, value);
5687    }
5688
5689    fn delete_meta(&self, key: &str) {
5690        meta_delete(&self.conn, key);
5691    }
5692
5693    fn restore_persisted_state(&mut self) -> Result<(), String> {
5694        self.subs = {
5695            let mut stmt = self
5696                .conn
5697                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
5698                .map_err(|error| error.to_string())?;
5699            let rows = stmt
5700                .query_map([], |row| {
5701                    Ok((
5702                        row.get::<_, String>(0)?,
5703                        row.get::<_, String>(1)?,
5704                        row.get::<_, String>(2)?,
5705                    ))
5706                })
5707                .map_err(|error| error.to_string())?;
5708            let mut subscriptions = Vec::new();
5709            for row in rows {
5710                let (id, table, raw) = row.map_err(|error| error.to_string())?;
5711                let state: Value = serde_json::from_str(&raw)
5712                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
5713                let requested = json_to_scope_map(
5714                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
5715                )?;
5716                let effective = state
5717                    .get("effectiveScopes")
5718                    .filter(|value| !value.is_null())
5719                    .map(json_to_scope_map)
5720                    .transpose()?;
5721                subscriptions.push(Subscription {
5722                    id,
5723                    table,
5724                    requested,
5725                    params: state
5726                        .get("params")
5727                        .and_then(Value::as_str)
5728                        .map(str::to_owned),
5729                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
5730                    bootstrap_state: state
5731                        .get("bootstrapState")
5732                        .and_then(Value::as_str)
5733                        .map(str::to_owned),
5734                    state: SubState::parse(
5735                        state
5736                            .get("status")
5737                            .and_then(Value::as_str)
5738                            .unwrap_or("active"),
5739                    ),
5740                    reason_code: state
5741                        .get("reasonCode")
5742                        .and_then(Value::as_str)
5743                        .map(str::to_owned),
5744                    effective,
5745                    synced_once: state
5746                        .get("syncedOnce")
5747                        .and_then(Value::as_bool)
5748                        .unwrap_or(false),
5749                });
5750            }
5751            subscriptions
5752        };
5753
5754        self.outbox = {
5755            let mut stmt = self
5756                .conn
5757                .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
5758                .map_err(|error| error.to_string())?;
5759            let rows = stmt
5760                .query_map([], |row| {
5761                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5762                })
5763                .map_err(|error| error.to_string())?;
5764            let mut commits = Vec::new();
5765            for row in rows {
5766                let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
5767                let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
5768                    format!("invalid persisted outbox {client_commit_id:?}: {error}")
5769                })?;
5770                let mut ops = Vec::with_capacity(entries.len());
5771                for entry in entries {
5772                    let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
5773                    ops.push(OutboxOp {
5774                        upsert: op == "upsert",
5775                        table: entry
5776                            .get("table")
5777                            .and_then(Value::as_str)
5778                            .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
5779                            .to_owned(),
5780                        row_id: entry
5781                            .get("rowId")
5782                            .and_then(Value::as_str)
5783                            .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
5784                            .to_owned(),
5785                        base_version: entry.get("baseVersion").and_then(Value::as_i64),
5786                        // A legacy entry's `changedFields` key is ignored:
5787                        // the values map is the presence set (§6.1).
5788                        values: entry.get("values").and_then(Value::as_object).cloned(),
5789                    });
5790                }
5791                commits.push(OutboxCommit {
5792                    client_commit_id,
5793                    ops,
5794                });
5795            }
5796            commits
5797        };
5798
5799        self.prune_commit_outcomes()?;
5800        let active = self.commit_outcomes(CommitOutcomeQuery {
5801            active_only: true,
5802            ..CommitOutcomeQuery::default()
5803        })?;
5804        self.conflicts = active
5805            .iter()
5806            .flat_map(|outcome| outcome.results.iter())
5807            .filter_map(|result| match result {
5808                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
5809                _ => None,
5810            })
5811            .collect();
5812        self.rejections = active
5813            .iter()
5814            .flat_map(|outcome| outcome.results.iter())
5815            .filter_map(|result| match result {
5816                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
5817                _ => None,
5818            })
5819            .collect();
5820
5821        self.lease_state = self
5822            .get_meta(LEASE_STATE_KEY)
5823            .map(|raw| serde_json::from_str(&raw))
5824            .transpose()
5825            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
5826        self.schema_floor = self
5827            .get_meta(SCHEMA_FLOOR_KEY)
5828            .map(|raw| serde_json::from_str(&raw))
5829            .transpose()
5830            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
5831        self.stopped = self.schema_floor.is_some();
5832        // A replica left in unactivated preflight reopens gated: the quarantine
5833        // decision persists with the data across handle teardown and restart.
5834        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
5835            self.security_preflight = true;
5836        }
5837        Ok(())
5838    }
5839
5840    /// Remove registrations for tables the running schema no longer knows.
5841    /// Otherwise the server rejects every pull with `sync.unknown_table`.
5842    fn prune_unknown_subscriptions(&mut self) -> Result<(), String> {
5843        let valid_tables: BTreeSet<String> = self
5844            .schema
5845            .tables
5846            .iter()
5847            .map(|table| table.name.clone())
5848            .collect();
5849        let stale_ids: Vec<String> = self
5850            .subs
5851            .iter()
5852            .filter(|sub| !valid_tables.contains(&sub.table))
5853            .map(|sub| sub.id.clone())
5854            .collect();
5855        for id in &stale_ids {
5856            self.conn
5857                .execute(
5858                    "DELETE FROM _syncular_windows WHERE sub_id = ?1",
5859                    rusqlite::params![id],
5860                )
5861                .map_err(|error| error.to_string())?;
5862            self.conn
5863                .execute(
5864                    "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
5865                    rusqlite::params![id],
5866                )
5867                .map_err(|error| error.to_string())?;
5868            self.conn
5869                .execute(
5870                    "DELETE FROM _syncular_subscriptions WHERE id = ?1",
5871                    rusqlite::params![id],
5872                )
5873                .map_err(|error| error.to_string())?;
5874        }
5875        self.subs.retain(|sub| valid_tables.contains(&sub.table));
5876        Ok(())
5877    }
5878
5879    #[must_use]
5880    pub fn local_revision(&self) -> u64 {
5881        self.get_meta(LOCAL_REVISION_KEY)
5882            .and_then(|value| value.parse().ok())
5883            .unwrap_or(0)
5884    }
5885
5886    #[must_use]
5887    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
5888        SyncStatusSnapshot {
5889            current_schema_version: self.schema.version,
5890            outbox: self.outbox.len(),
5891            upgrading: self.upgrading,
5892            lease_state: self.lease_state.clone(),
5893            schema_floor: self.schema_floor.clone(),
5894            sync_needed: self.sync_needed,
5895            previous_version_context: self.previous_version_status(),
5896        }
5897    }
5898
5899    /// RFC 0005 A2: container presence for a host that must act on it (Diego's
5900    /// rollback precondition), read without creating the file.
5901    fn previous_version_status(&self) -> PreviousVersionStatus {
5902        let Some(replica_path) = self.previous_version_replica_path() else {
5903            return PreviousVersionStatus {
5904                present: false,
5905                created_at_ms: None,
5906            };
5907        };
5908        match crate::previous_version::read_previous_version_record(&replica_path) {
5909            Ok(Some(record)) => PreviousVersionStatus {
5910                present: true,
5911                created_at_ms: Some(record.created_at_ms),
5912            },
5913            _ => PreviousVersionStatus {
5914                present: std::path::Path::new(
5915                    &crate::previous_version::previous_version_container_path(&replica_path),
5916                )
5917                .exists(),
5918                created_at_ms: None,
5919            },
5920        }
5921    }
5922
5923    /// RFC 0005 D7: read captured rows from the previous local schema. `state`
5924    /// is always `previousVersion`; the container never makes
5925    /// `query_snapshot()` coverage complete. Lease stop/expiry, scope
5926    /// revocation, coverage completion and the TTL DISCARD the container and
5927    /// close the read with their own reason.
5928    pub fn previous_version_snapshot(
5929        &self,
5930        spec: &PreviousVersionReadSpec,
5931    ) -> Result<PreviousVersionSnapshot, String> {
5932        let now_ms = self.clock_now_ms();
5933        crate::previous_version::previous_version_snapshot(
5934            &self.conn,
5935            self.previous_version_replica_path().as_deref(),
5936            self.previous_version.as_ref(),
5937            PreviousVersionLifecycle {
5938                lease_inactive: self.previous_version_lease_inactive(now_ms),
5939                scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
5940                coverage_complete: self.previous_version_coverage_complete(),
5941            },
5942            now_ms,
5943            self.schema.version,
5944            spec,
5945        )
5946    }
5947
5948    /// RFC 0005 D6: the advisory pre-reset compatibility audit.
5949    #[must_use]
5950    pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
5951        crate::previous_version::stored_previous_version_audit(&self.conn)
5952    }
5953
5954    /// RFC 0005 A2/D9: the executable downgrade step. Drop the container file
5955    /// and both metadata records, and report whether anything was present.
5956    /// Idempotent: absence is a successful no-op.
5957    pub fn previous_version_discard(
5958        &self,
5959    ) -> crate::previous_version::PreviousVersionDiscardOutcome {
5960        self.drop_previous_version()
5961    }
5962
5963    /// The module's discard has no failure path (it removes the file even when
5964    /// the container cannot be opened), so the only error a caller could ever
5965    /// see is a bug; report absence rather than claiming a discard happened.
5966    fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
5967        let absent = crate::previous_version::PreviousVersionDiscardOutcome {
5968            present: false,
5969            discarded: false,
5970        };
5971        let Some(replica_path) = self.previous_version_replica_path() else {
5972            return absent;
5973        };
5974        crate::previous_version::discard_previous_version(&self.conn, &replica_path)
5975            .unwrap_or(absent)
5976    }
5977
5978    /// RFC 0005 D7: `leaseState.errorCode` or an expired lease.
5979    fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
5980        let Some(lease) = &self.lease_state else {
5981            return false;
5982        };
5983        if lease.error_code.is_some() {
5984            return true;
5985        }
5986        lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
5987    }
5988
5989    /// RFC 0005 D7: §7.4.5 replacement coverage — every ACTIVE subscription
5990    /// has `cursor >= 0` and no resume token. An empty active set is not
5991    /// completion (mirrors the TS predicate exactly).
5992    fn previous_version_coverage_complete(&self) -> bool {
5993        let active = self
5994            .subs
5995            .iter()
5996            .filter(|sub| sub.state == SubState::Active)
5997            .collect::<Vec<_>>();
5998        if active.is_empty() {
5999            return false;
6000        }
6001        active
6002            .iter()
6003            .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
6004    }
6005
6006    /// RFC 0005 D7: lifetime triggers evaluated once per sync round, after the
6007    /// apply. A container that no longer has a reason to exist is discarded so
6008    /// the rows do not outlive the window they were kept for.
6009    fn previous_version_lifetime_check(&self) {
6010        let Some(config) = self.previous_version.filter(|config| config.enabled) else {
6011            return;
6012        };
6013        let now_ms = self.clock_now_ms();
6014        if self.previous_version_lease_inactive(now_ms) {
6015            let _ = self.drop_previous_version();
6016            return;
6017        }
6018        let Some(replica_path) = self.previous_version_replica_path() else {
6019            return;
6020        };
6021        let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
6022        else {
6023            return;
6024        };
6025        if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
6026            let _ = self.drop_previous_version();
6027            return;
6028        }
6029        if now_ms - record.created_at_ms > config.max_age_ms {
6030            let _ = self.drop_previous_version();
6031            return;
6032        }
6033        if self.previous_version_coverage_complete() {
6034            let _ = self.drop_previous_version();
6035        }
6036    }
6037
6038    pub fn diagnostics_snapshot(
6039        &self,
6040        request: &ClientDiagnosticsRequest,
6041    ) -> Result<ClientDiagnosticsSnapshot, String> {
6042        if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
6043            return Err(format!(
6044                "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
6045            ));
6046        }
6047        let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
6048        for sub in &self.subs {
6049            let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
6050            let complete =
6051                sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
6052            let state = match sub.state {
6053                SubState::Revoked => "revoked",
6054                SubState::Failed => "failed",
6055                SubState::Active if reset => "reset",
6056                SubState::Active if complete => "complete",
6057                SubState::Active => "bootstrapping",
6058            };
6059            subscriptions.insert(
6060                sub.id.clone(),
6061                DiagnosticSubscription {
6062                    id: sub.id.clone(),
6063                    table: sub.table.clone(),
6064                    state: state.to_owned(),
6065                    complete,
6066                    cursor: Some(sub.cursor),
6067                    reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
6068                },
6069            );
6070        }
6071        for expected in &request.expected_subscriptions {
6072            if expected.id.is_empty() || expected.table.is_empty() {
6073                return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
6074            }
6075            if subscriptions
6076                .get(&expected.id)
6077                .is_some_and(|registered| registered.table != expected.table)
6078            {
6079                subscriptions.insert(
6080                    expected.id.clone(),
6081                    DiagnosticSubscription {
6082                        id: expected.id.clone(),
6083                        table: expected.table.clone(),
6084                        state: "failed".to_owned(),
6085                        complete: false,
6086                        cursor: None,
6087                        reason_code: Some("client.subscription_intent_mismatch".to_owned()),
6088                    },
6089                );
6090            } else {
6091                subscriptions.entry(expected.id.clone()).or_insert_with(|| {
6092                    DiagnosticSubscription {
6093                        id: expected.id.clone(),
6094                        table: expected.table.clone(),
6095                        state: "unregistered".to_owned(),
6096                        complete: false,
6097                        cursor: None,
6098                        reason_code: None,
6099                    }
6100                });
6101            }
6102        }
6103        let mut ordered_subscriptions = Vec::new();
6104        let mut included = BTreeSet::new();
6105        for expected in &request.expected_subscriptions {
6106            if included.insert(expected.id.clone()) {
6107                if let Some(subscription) = subscriptions.get(&expected.id) {
6108                    ordered_subscriptions.push(subscription.clone());
6109                }
6110            }
6111        }
6112        for (id, subscription) in &subscriptions {
6113            if included.insert(id.clone()) {
6114                ordered_subscriptions.push(subscription.clone());
6115            }
6116        }
6117        let subscriptions_truncated =
6118            ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
6119        ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
6120        let captured_at_ms = self.clock_now_ms();
6121        let lease = if let Some(error_code) = self
6122            .lease_state
6123            .as_ref()
6124            .and_then(|state| state.error_code.clone())
6125        {
6126            ClientDiagnosticsLease {
6127                state: "stopped".to_owned(),
6128                expires_at_ms: self
6129                    .lease_state
6130                    .as_ref()
6131                    .and_then(|state| state.expires_at_ms),
6132                error_code: Some(Self::diagnostic_code(&error_code)),
6133            }
6134        } else if let Some(expires_at_ms) = self
6135            .lease_state
6136            .as_ref()
6137            .and_then(|state| state.expires_at_ms)
6138        {
6139            ClientDiagnosticsLease {
6140                state: if expires_at_ms <= captured_at_ms {
6141                    "expired".to_owned()
6142                } else {
6143                    "active".to_owned()
6144                },
6145                expires_at_ms: Some(expires_at_ms),
6146                error_code: None,
6147            }
6148        } else {
6149            ClientDiagnosticsLease {
6150                state: "none".to_owned(),
6151                expires_at_ms: None,
6152                error_code: None,
6153            }
6154        };
6155        let connectivity = match self.last_round.as_ref() {
6156            Some(round) if round.status == "succeeded" => "online",
6157            Some(round)
6158                if round.status == "failed"
6159                    && round
6160                        .error_code
6161                        .as_deref()
6162                        .is_some_and(Self::retryable_transport_code) =>
6163            {
6164                "offline"
6165            }
6166            _ => "unknown",
6167        };
6168        Ok(ClientDiagnosticsSnapshot {
6169            version: CLIENT_DIAGNOSTICS_VERSION,
6170            captured_at_ms,
6171            host: ClientDiagnosticsHost {
6172                kind: "direct".to_owned(),
6173                role: "single".to_owned(),
6174                connectivity: connectivity.to_owned(),
6175                realtime: self.realtime_state,
6176                realtime_policy: self.realtime_policy,
6177                realtime_reason_code: self.realtime_reason_code.clone(),
6178                realtime_retry_delay_ms: self.realtime_retry_delay_ms,
6179            },
6180            security_lifecycle: self.security_lifecycle().to_owned(),
6181            schema: ClientDiagnosticsSchema {
6182                current_version: self.schema.version,
6183                upgrading: self.upgrading,
6184                required_version: self
6185                    .schema_floor
6186                    .as_ref()
6187                    .and_then(|floor| floor.required_schema_version),
6188                latest_version: self
6189                    .schema_floor
6190                    .as_ref()
6191                    .and_then(|floor| floor.latest_schema_version),
6192            },
6193            replica: ClientDiagnosticsReplica {
6194                local_revision: self.local_revision().to_string(),
6195                sync_needed: self.sync_needed,
6196                pending_outbox: self.outbox.len(),
6197            },
6198            lease,
6199            subscriptions: ordered_subscriptions,
6200            subscriptions_truncated,
6201            last_round: self.last_round.clone(),
6202            last_change: self.last_change.clone(),
6203            storage: self.diagnostics_storage(),
6204            query_failures: self.query_failures.clone(),
6205        })
6206    }
6207
6208    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
6209        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
6210            let page_count: i64 = self
6211                .conn
6212                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
6213            let page_size: i64 = self
6214                .conn
6215                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
6216            // Reuse compiled aggregates; every invocation still reads current storage.
6217            let outbox_bytes: i64 = self
6218                .conn
6219                .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
6220                .query_row([], |row| row.get(0))?;
6221            let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
6222                "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
6223            )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
6224            let blob_bytes = if self.schema_has_blobs() {
6225                self.conn
6226                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
6227                    .query_row([], |row| row.get(0))?
6228            } else {
6229                0
6230            };
6231            let pressure = self
6232                .limits
6233                .blob_cache_max_bytes
6234                .is_some_and(|limit| blob_bytes > limit);
6235            Ok(ClientDiagnosticsStorage {
6236                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
6237                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
6238                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
6239                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
6240                retained_outcome_entries: Some(outcome_entries.max(0)),
6241                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
6242                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
6243            })
6244        };
6245        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
6246            status: "unreadable".to_owned(),
6247            database_bytes_approx: None,
6248            pending_outbox_bytes_approx: None,
6249            retained_outcome_bytes_approx: None,
6250            retained_outcome_entries: None,
6251            blob_cache_bytes_approx: None,
6252            pressure_reason_code: None,
6253        })
6254    }
6255
6256    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
6257        self.change_queue.drain(..).collect()
6258    }
6259
6260    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
6261        self.sync_intent_queue.drain(..).collect()
6262    }
6263
6264    fn schedule_background_retry(&mut self) -> u64 {
6265        let delay_ms = self.retry_delay_ms;
6266        self.sync_intent_queue
6267            .push_back(SyncIntent::Background { delay_ms });
6268        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
6269        delay_ms
6270    }
6271
6272    fn reset_background_retry(&mut self) {
6273        self.retry_delay_ms = 250;
6274    }
6275
6276    fn retryable_transport_code(code: &str) -> bool {
6277        code == "transport.failed"
6278            || code == "transport.unavailable"
6279            || code == "sync.transport_failed"
6280    }
6281
6282    fn diagnostic_code(code: &str) -> String {
6283        let valid = !code.is_empty()
6284            && code.len() <= 96
6285            && code.contains('.')
6286            && code
6287                .bytes()
6288                .next()
6289                .is_some_and(|byte| byte.is_ascii_lowercase())
6290            && code.bytes().all(|byte| {
6291                byte.is_ascii_lowercase()
6292                    || byte.is_ascii_digit()
6293                    || matches!(byte, b'.' | b'_' | b'-')
6294            });
6295        if valid {
6296            code.to_owned()
6297        } else {
6298            "client.unknown_failure".to_owned()
6299        }
6300    }
6301
6302    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
6303        if self.sync_needed != value {
6304            if self.begin_observation("syncular_status").is_ok() {
6305                self.sync_needed = value;
6306                let batch = ChangeAccumulator {
6307                    status: true,
6308                    ..ChangeAccumulator::default()
6309                };
6310                if self.finish_observation("syncular_status", batch).is_err() {
6311                    self.rollback_observation("syncular_status");
6312                }
6313            } else {
6314                self.sync_needed = value;
6315            }
6316        }
6317        if value && interactive {
6318            self.sync_intent_queue.push_back(SyncIntent::Interactive);
6319        }
6320    }
6321
6322    fn begin_observation(&self, name: &str) -> Result<(), String> {
6323        self.conn
6324            .execute_batch(&format!("SAVEPOINT {name}"))
6325            .map_err(|error| error.to_string())
6326    }
6327
6328    fn rollback_observation(&self, name: &str) {
6329        let _ = self
6330            .conn
6331            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"));
6332    }
6333
6334    fn finish_observation(&mut self, name: &str, batch: ChangeAccumulator) -> Result<(), String> {
6335        #[cfg(feature = "bench-internals")]
6336        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
6337        if !batch.touched() {
6338            self.conn
6339                .execute_batch(&format!("RELEASE {name}"))
6340                .map_err(|error| error.to_string())?;
6341            return Ok(());
6342        }
6343        let revision = self
6344            .local_revision()
6345            .checked_add(1)
6346            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
6347        self.conn
6348            .execute(
6349                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
6350                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
6351            )
6352            .map_err(|error| error.to_string())?;
6353        let status = batch.status.then(|| self.status_snapshot());
6354        let event = ClientChangeBatch {
6355            revision: revision.to_string(),
6356            tables: batch
6357                .tables
6358                .into_iter()
6359                .map(|(table, scope_keys)| TableChange {
6360                    table,
6361                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
6362                })
6363                .collect(),
6364            windows: batch
6365                .windows
6366                .into_iter()
6367                .map(|((base_key, table), units)| WindowChange {
6368                    base_key,
6369                    table,
6370                    units: units.into_iter().collect(),
6371                })
6372                .collect(),
6373            status,
6374            conflicts_changed: batch.conflicts,
6375            rejections_changed: batch.rejections,
6376            outcomes_changed: batch.outcomes,
6377        };
6378        self.conn
6379            .execute_batch(&format!("RELEASE {name}"))
6380            .map_err(|error| error.to_string())?;
6381        let mut diagnostic_tables = event
6382            .tables
6383            .iter()
6384            .map(|entry| entry.table.clone())
6385            .collect::<BTreeSet<_>>()
6386            .into_iter()
6387            .collect::<Vec<_>>();
6388        let mut diagnostic_windows = event
6389            .windows
6390            .iter()
6391            .map(|entry| entry.table.clone())
6392            .collect::<BTreeSet<_>>()
6393            .into_iter()
6394            .collect::<Vec<_>>();
6395        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
6396            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
6397        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
6398        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
6399        self.last_change = Some(DiagnosticLastChange {
6400            revision: event.revision.clone(),
6401            recorded_at_ms: self.clock_now_ms(),
6402            tables: diagnostic_tables,
6403            windows: diagnostic_windows,
6404            domains_truncated,
6405            status_changed: event.status.is_some(),
6406            conflicts_changed: event.conflicts_changed,
6407            rejections_changed: event.rejections_changed,
6408            outcomes_changed: event.outcomes_changed,
6409        });
6410        self.change_queue.push_back(event);
6411        Ok(())
6412    }
6413
6414    fn record_scope_map(
6415        &self,
6416        batch: &mut ChangeAccumulator,
6417        table_name: &str,
6418        scopes: &[(String, Vec<String>)],
6419    ) {
6420        let Some(table) = self.schema.table(table_name) else {
6421            return;
6422        };
6423        for (variable, values) in scopes {
6424            let Some(scope) = table
6425                .scope_variables
6426                .iter()
6427                .find(|scope| &scope.variable == variable)
6428            else {
6429                continue;
6430            };
6431            for value in values {
6432                batch.scope(table_name, format!("{}:{value}", scope.prefix));
6433            }
6434        }
6435    }
6436
6437    /// Record a row's current scope keys from the base or visible table.
6438    fn record_row_scopes(
6439        &self,
6440        batch: &mut ChangeAccumulator,
6441        table_name: &str,
6442        row_id: &str,
6443        base: bool,
6444    ) -> bool {
6445        let Some(table) = self.schema.table(table_name) else {
6446            return false;
6447        };
6448        if table.scope_variables.is_empty() {
6449            return false;
6450        }
6451        let columns = table
6452            .scope_variables
6453            .iter()
6454            .map(|scope| quote_ident(&scope.column))
6455            .collect::<Vec<_>>()
6456            .join(", ");
6457        let full_table = if base {
6458            base_table(table_name)
6459        } else {
6460            visible_table(table_name)
6461        };
6462        let sql = format!(
6463            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
6464            row_id_predicate(table)
6465        );
6466        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
6467            return false;
6468        };
6469        let values = stmt.query_row(rusqlite::params![row_id], |row| {
6470            let mut values = Vec::with_capacity(table.scope_variables.len());
6471            for index in 0..table.scope_variables.len() {
6472                values.push(row.get::<_, Option<String>>(index)?);
6473            }
6474            Ok(values)
6475        });
6476        let Ok(values) = values else {
6477            return false;
6478        };
6479        let mut recorded = false;
6480        for (scope, value) in table.scope_variables.iter().zip(values) {
6481            if let Some(value) = value {
6482                batch.scope(table_name, format!("{}:{value}", scope.prefix));
6483                recorded = true;
6484            }
6485        }
6486        recorded
6487    }
6488
6489    fn record_commit_changes(
6490        &self,
6491        batch: &mut ChangeAccumulator,
6492        tables: &[String],
6493        changes: &[ssp2::model::Change],
6494    ) {
6495        #[cfg(feature = "bench-internals")]
6496        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
6497        for change in changes {
6498            let Some(table_name) = tables.get(change.table_index as usize) else {
6499                continue;
6500            };
6501            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
6502            if let Some(table) = self.schema.table(table_name) {
6503                for (variable, value) in &change.scopes {
6504                    if let Some(scope) = table
6505                        .scope_variables
6506                        .iter()
6507                        .find(|scope| &scope.variable == variable)
6508                    {
6509                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
6510                        precise = true;
6511                    }
6512                }
6513            }
6514            if !precise {
6515                batch.table(table_name);
6516            }
6517        }
6518    }
6519
6520    fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
6521        if effective.is_empty() {
6522            return false;
6523        }
6524        let Some(table) = self.schema.table(table_name) else {
6525            return false;
6526        };
6527        let mut clauses = Vec::new();
6528        let mut params = Vec::new();
6529        for (variable, values) in effective {
6530            let Some(column) = table.scope_column(variable) else {
6531                return false;
6532            };
6533            if values.is_empty() {
6534                return false;
6535            }
6536            let placeholders = values
6537                .iter()
6538                .map(|value| {
6539                    params.push(SqlValue::Text(value.clone()));
6540                    "?"
6541                })
6542                .collect::<Vec<_>>()
6543                .join(", ");
6544            clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
6545        }
6546        let sql = format!(
6547            "SELECT 1 FROM {} WHERE {} LIMIT 1",
6548            base_table(table_name),
6549            clauses.join(" AND ")
6550        );
6551        self.conn
6552            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
6553            .is_ok()
6554    }
6555
6556    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
6557    pub fn upgrading(&self) -> bool {
6558        self.upgrading
6559    }
6560
6561    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
6562    /// keeping this client's local database (identity, outbox, tables). The
6563    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
6564    /// version changed. Mirrors the TS client's boot-time detection —
6565    /// the Rust core has no persistent restart, so recreation IS the boot.
6566    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
6567        let new_schema = parse_schema_json(schema_json)?;
6568        let marker: Option<i32> = self
6569            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6570            .and_then(|v| v.parse().ok());
6571        self.schema = new_schema;
6572        self.create_bookkeeping_tables()?;
6573        if marker != Some(self.schema.version) {
6574            self.run_schema_reset()?;
6575        }
6576        self.prune_unknown_subscriptions()?;
6577        // The conformance recreate is the in-memory equivalent of reopening a
6578        // durable client. Apply the same startup catch-up contract even when
6579        // the schema itself did not change.
6580        self.enqueue_startup_sync_if_needed()?;
6581        Ok(())
6582    }
6583
6584    fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
6585        let startup_work = !self.stopped
6586            && (!self.outbox.is_empty()
6587                || !self.load_pending_evictions()?.is_empty()
6588                || self.subs.iter().any(|sub| sub.state == SubState::Active));
6589        if startup_work {
6590            self.sync_needed = true;
6591            self.sync_intent_queue.push_back(SyncIntent::Interactive);
6592        }
6593        Ok(())
6594    }
6595
6596    /// A native client persists schema-floor stops across process restarts.
6597    /// Once the running app already satisfies that floor, the persisted stop
6598    /// is only stale evidence from an older server round (for example, the
6599    /// server was restarted after catching up to an app that was ahead).
6600    /// Clear it and let the normal startup pull re-negotiate. If the server is
6601    /// still incompatible it will return the floor again in that first round.
6602    fn clear_satisfied_persisted_schema_floor(&mut self) {
6603        let satisfied = self
6604            .schema_floor
6605            .as_ref()
6606            .and_then(|floor| floor.required_schema_version)
6607            .is_some_and(|required| self.schema.version >= required);
6608        if !satisfied {
6609            return;
6610        }
6611        self.schema_floor = None;
6612        self.stopped = false;
6613        self.delete_meta(SCHEMA_FLOOR_KEY);
6614    }
6615
6616    /// RFC 0005 D1: discard an orphan or stale container at boot. The reset
6617    /// path already swept it; this covers a same-version or fresh-install open,
6618    /// which runs no reset and therefore no sweep — plus the D7 TTL, which only
6619    /// an aware binary can apply.
6620    fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
6621        let Some(replica_path) = self.previous_version_replica_path() else {
6622            return Ok(());
6623        };
6624        let max_age_ms = self
6625            .previous_version
6626            .filter(|config| config.enabled)
6627            .map(|config| config.max_age_ms);
6628        reconcile_previous_version_at_boot(
6629            &self.conn,
6630            &replica_path,
6631            self.schema.version,
6632            max_age_ms,
6633            self.clock_now_ms(),
6634        )
6635    }
6636
6637    /// RFC 0005 D3: the sibling container path, or `None` when the replica has
6638    /// no file — an in-memory replica returns an empty path, and a `file:` URI
6639    /// has no plain sibling path — so the feature resolves as off there.
6640    fn previous_version_replica_path(&self) -> Option<String> {
6641        let path = self.conn.path()?;
6642        if path.is_empty() || path.starts_with("file:") {
6643            return None;
6644        }
6645        Some(path.to_owned())
6646    }
6647
6648    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
6649    /// and leaseState. Drops/recreates every synced table from the new
6650    /// schema, resets subscription sync-state (keeping registrations), clears
6651    /// the schema-floor stop state, rewrites the marker, drops outbox commits
6652    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
6653    fn run_schema_reset(&mut self) -> Result<(), String> {
6654        self.begin_observation("syncular_schema_reset")?;
6655        let mut batch = ChangeAccumulator::default();
6656        let result = self.run_schema_reset_observed(&mut batch, true, true);
6657        if let Err(error) = result {
6658            self.rollback_observation("syncular_schema_reset");
6659            return Err(error);
6660        }
6661        if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
6662            self.rollback_observation("syncular_schema_reset");
6663            return Err(error);
6664        }
6665        Ok(())
6666    }
6667
6668    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
6669        let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
6670        self.begin_observation("syncular_log_epoch_reset")?;
6671        let mut batch = ChangeAccumulator::default();
6672        let result = self.run_schema_reset_observed(&mut batch, false, false);
6673        if let Err(error) = result {
6674            self.rollback_observation("syncular_log_epoch_reset");
6675            return Err(error);
6676        }
6677        self.set_meta(LOG_EPOCH_KEY, log_epoch);
6678        self.sync_needed = true;
6679        batch.status = true;
6680        if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
6681            self.rollback_observation("syncular_log_epoch_reset");
6682            return Err(error);
6683        }
6684        self.sync_intent_queue.push_back(SyncIntent::Interactive);
6685        Ok(resets)
6686    }
6687
6688    fn run_schema_reset_observed(
6689        &mut self,
6690        batch: &mut ChangeAccumulator,
6691        drop_incompatible: bool,
6692        capture: bool,
6693    ) -> Result<(), String> {
6694        self.upgrading = true;
6695        batch.status = true;
6696        // RFC 0005 D6: classify the pending outbox against the NEW compiled
6697        // schema BEFORE the wipe, and record the advisory audit. It drops
6698        // nothing — the §7.4.4 send-time drop stays the only path that removes
6699        // a commit. The classification is shared with that drop so the two can
6700        // never disagree about what is incompatible.
6701        if self.previous_version.is_some_and(|config| config.enabled) {
6702            let previous_version = self
6703                .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6704                .and_then(|value| value.parse::<i32>().ok())
6705                .unwrap_or(self.schema.version);
6706            let pending = self
6707                .outbox
6708                .iter()
6709                .map(|commit| PendingCommitAudit {
6710                    commit_id: &commit.client_commit_id,
6711                    operations: commit_audit_operations(commit),
6712                })
6713                .collect::<Vec<_>>();
6714            crate::previous_version::write_previous_version_audit(
6715                &self.conn,
6716                &crate::previous_version::build_previous_version_audit(
6717                    &self.schema,
6718                    &pending,
6719                    previous_version,
6720                    self.schema.version,
6721                    self.clock_now_ms(),
6722                ),
6723            );
6724        }
6725        // RFC 0005 D5: the container lives in its own FILE, so the reset can
6726        // never reach it. Sweep first (unconditional — also on the log-epoch
6727        // reset, which captures nothing), then capture BEFORE the wipe drops
6728        // any row. A refused capture records its reason and leaves no file.
6729        if let Some(replica_path) = self.previous_version_replica_path() {
6730            sweep_previous_version_container(&self.conn, &replica_path)?;
6731            if capture {
6732                if let Some(config) = self.previous_version.filter(|config| config.enabled) {
6733                    let previous_version = self
6734                        .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6735                        .and_then(|value| value.parse::<i32>().ok())
6736                        .unwrap_or(self.schema.version);
6737                    capture_previous_version_from_replica(
6738                        &self.conn,
6739                        &replica_path,
6740                        previous_version,
6741                        self.schema.version,
6742                        &config,
6743                        self.clock_now_ms(),
6744                    )?;
6745                }
6746            }
6747        }
6748        for table in &self.schema.tables {
6749            batch.table(&table.name);
6750        }
6751        for (base_key, unit, table) in self.load_registered_window_units() {
6752            batch.window(&base_key, &table, &unit);
6753        }
6754        // The per-table insert SQL is derived from the OLD column lists.
6755        self.insert_sql.borrow_mut().clear();
6756        self.overlay_dirty.set(true);
6757        // Drop every synced table (base + visible) that currently exists —
6758        // discovered from sqlite_master so a bump that adds/removes tables is
6759        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
6760        // and the blob cache) are preserved; base tables are `_syncular_base_*`
6761        // so they are matched explicitly, not by the bookkeeping filter.
6762        // Drop virtual tables first so SQLite removes every FTS shadow table
6763        // atomically instead of the generic discovery tearing it apart.
6764        let virtual_tables: Vec<String> = {
6765            let mut stmt = self
6766                .conn
6767                .prepare(
6768                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
6769                )
6770                .map_err(|e| e.to_string())?;
6771            let rows = stmt
6772                .query_map([], |row| row.get::<_, String>(0))
6773                .map_err(|e| e.to_string())?;
6774            rows.filter_map(Result::ok)
6775                .filter(|name| is_synced_table_name(name))
6776                .collect()
6777        };
6778        for name in virtual_tables {
6779            self.conn
6780                .execute_batch(&format!(
6781                    "DROP TABLE IF EXISTS {}",
6782                    quote_ident(&format!("_syncular_fts_{name}"))
6783                ))
6784                .map_err(|e| e.to_string())?;
6785            self.conn
6786                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
6787                .map_err(|e| e.to_string())?;
6788        }
6789        let existing: Vec<String> = {
6790            let mut stmt = self
6791                .conn
6792                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
6793                .map_err(|e| e.to_string())?;
6794            let rows = stmt
6795                .query_map([], |row| row.get::<_, String>(0))
6796                .map_err(|e| e.to_string())?;
6797            rows.filter_map(Result::ok)
6798                .filter(|name| is_synced_table_name(name))
6799                .collect()
6800        };
6801        for name in &existing {
6802            if let Some(table) = name.strip_prefix("_syncular_base_") {
6803                batch.table(table);
6804            } else if !name.starts_with("_syncular_") {
6805                batch.table(name);
6806            }
6807        }
6808        for name in existing {
6809            self.conn
6810                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
6811                .map_err(|e| e.to_string())?;
6812        }
6813        // Recreate the synced tables from the NEW schema.
6814        self.create_synced_tables()?;
6815        // Reset every subscription's sync-state, keeping the registration.
6816        for sub in &mut self.subs {
6817            sub.cursor = -1;
6818            sub.bootstrap_state = None;
6819            sub.effective = None;
6820            sub.state = SubState::Active;
6821            sub.reason_code = None;
6822            sub.synced_once = false;
6823        }
6824        let subs = self.subs.clone();
6825        for sub in &subs {
6826            self.persist_sub(sub)?;
6827        }
6828        // The stop state is over: this client now ships a servable schema.
6829        self.stopped = false;
6830        self.schema_floor = None;
6831        self.delete_meta(SCHEMA_FLOOR_KEY);
6832        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
6833        self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
6834        // D1: the descriptor follows every marker write. A crash in between
6835        // self-heals on the next same-version open, which backfills it.
6836        set_local_schema_descriptor(&self.conn, &self.schema);
6837        // §7.4.4: drop outbox commits that cannot re-encode under the new
6838        // schema (a referenced column/table the bump removed), surfacing each
6839        // as a `sync.outbox_incompatible` rejection.
6840        if drop_incompatible && self.drop_incompatible_outbox()? {
6841            batch.rejections = true;
6842            batch.status = true;
6843            batch.outcomes = true;
6844        }
6845        // Re-apply the surviving outbox optimistically over the empty tables.
6846        self.rebuild_overlay();
6847        Ok(())
6848    }
6849
6850    /// §7.4.4: a persisted upsert whose values reference a column the current
6851    /// schema lacks (or a removed table) cannot be encoded. Drop the commit
6852    /// and raise a client-local `sync.outbox_incompatible` rejection.
6853    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
6854        let incompatible = self
6855            .outbox
6856            .iter()
6857            .filter(|commit| {
6858                first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
6859            })
6860            .cloned()
6861            .collect::<Vec<_>>();
6862        if incompatible.is_empty() {
6863            return Ok(false);
6864        }
6865        let mut rejections = Vec::new();
6866        for commit in &incompatible {
6867            let results = commit
6868                .ops
6869                .iter()
6870                .enumerate()
6871                .map(|(op_index, operation)| {
6872                    let rejection = RejectionRecord {
6873                        client_commit_id: commit.client_commit_id.clone(),
6874                        op_index: op_index as i32,
6875                        code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
6876                        message: "the persisted commit cannot encode under the current schema"
6877                            .to_owned(),
6878                        retryable: false,
6879                        details: None,
6880                        operation: Some(CommitOperation::from(operation)),
6881                    };
6882                    rejections.push(rejection.clone());
6883                    CommitOperationOutcome::Error { rejection }
6884                })
6885                .collect::<Vec<_>>();
6886            self.persist_commit_outcome(
6887                &commit.client_commit_id,
6888                CommitOutcomeStatus::Rejected,
6889                &results,
6890                Some(&commit.ops),
6891            )?;
6892            self.delete_outbox_persisted(&commit.client_commit_id)?;
6893        }
6894        self.prune_commit_outcomes()?;
6895        let incompatible_ids = incompatible
6896            .iter()
6897            .map(|commit| commit.client_commit_id.as_str())
6898            .collect::<BTreeSet<_>>();
6899        self.outbox
6900            .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
6901        self.rejections.extend(rejections);
6902        Ok(true)
6903    }
6904
6905    /// §7.4.3: (re)create the base + visible table pair for every synced
6906    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
6907    fn create_synced_tables(&self) -> Result<(), String> {
6908        for table in &self.schema.tables {
6909            // The base half + the visible half form the synced-table pair. An
6910            // index name is global in SQLite, so the base half's indexes are
6911            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
6912            for (full, index_prefix) in [
6913                (base_table(&table.name), "_syncular_base_"),
6914                (visible_table(&table.name), ""),
6915            ] {
6916                let mut cols: Vec<String> =
6917                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
6918                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
6919                let sql = format!(
6920                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
6921                    cols.join(", "),
6922                    quote_ident(&table.primary_key)
6923                );
6924                self.conn.execute(&sql, []).map_err(|e| e.to_string())?;
6925                // Local secondary indexes (CREATE INDEX subset). Created on
6926                // both halves so mirror reads hit an index on either. Runs on
6927                // both the initial create and the §7.4.3 reset recreate path.
6928                for index in &table.indexes {
6929                    let unique = if index.unique { "UNIQUE " } else { "" };
6930                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
6931                    let cols_sql = index
6932                        .columns
6933                        .iter()
6934                        .map(|c| quote_ident(c))
6935                        .collect::<Vec<_>>()
6936                        .join(", ");
6937                    let index_sql = format!(
6938                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
6939                    );
6940                    self.conn
6941                        .execute(&index_sql, [])
6942                        .map_err(|e| e.to_string())?;
6943                }
6944            }
6945        }
6946        // FTS exists only on the visible half. It is a local search
6947        // projection, never a synced/base table or a wire-schema member.
6948        for table in &self.schema.tables {
6949            for index in &table.fts_indexes {
6950                self.create_fts_projection(table, index)?;
6951            }
6952        }
6953        Ok(())
6954    }
6955
6956    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
6957        let count: i64 = self
6958            .conn
6959            .query_row(
6960                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
6961                rusqlite::params![index.name],
6962                |row| row.get(0),
6963            )
6964            .map_err(|e| e.to_string())?;
6965        Ok(count > 0)
6966    }
6967
6968    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
6969        for suffix in ["ai", "ad", "au"] {
6970            self.conn
6971                .execute(
6972                    &format!(
6973                        "DROP TRIGGER IF EXISTS {}",
6974                        quote_ident(&format!("{}_{suffix}", index.name))
6975                    ),
6976                    [],
6977                )
6978                .map_err(|e| e.to_string())?;
6979        }
6980        Ok(())
6981    }
6982
6983    fn create_fts_triggers(
6984        &self,
6985        table: &TableSchema,
6986        index: &FtsIndexSchema,
6987    ) -> Result<(), String> {
6988        let fts = quote_ident(&index.name);
6989        let source = visible_table(&table.name);
6990        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
6991        let pk = quote_ident(&table.primary_key);
6992        let projection_columns = std::iter::once(source_id.clone())
6993            .chain(index.columns.iter().map(|column| quote_ident(column)))
6994            .collect::<Vec<_>>()
6995            .join(", ");
6996        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
6997            .chain(
6998                index
6999                    .columns
7000                    .iter()
7001                    .map(|column| format!("new.{}", quote_ident(column))),
7002            )
7003            .collect::<Vec<_>>()
7004            .join(", ");
7005        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7006        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)");
7007        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)");
7008        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})");
7009        let bi = quote_ident(&format!("{}_bi", index.name));
7010        let ai = quote_ident(&format!("{}_ai", index.name));
7011        let ad = quote_ident(&format!("{}_ad", index.name));
7012        let au = quote_ident(&format!("{}_au", index.name));
7013        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
7014        let sql = format!(
7015            "DROP TRIGGER IF EXISTS {bi};
7016             DROP TRIGGER IF EXISTS {ai};
7017             DROP TRIGGER IF EXISTS {ad};
7018             DROP TRIGGER IF EXISTS {au};
7019             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
7020             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
7021             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
7022             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
7023        );
7024        self.conn.execute_batch(&sql).map_err(|e| e.to_string())
7025    }
7026
7027    fn rebuild_fts_projection(
7028        &self,
7029        table: &TableSchema,
7030        index: &FtsIndexSchema,
7031    ) -> Result<(), String> {
7032        let fts = quote_ident(&index.name);
7033        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7034        let pk = quote_ident(&table.primary_key);
7035        let indexed_columns = index
7036            .columns
7037            .iter()
7038            .map(|column| quote_ident(column))
7039            .collect::<Vec<_>>();
7040        let projection_columns = std::iter::once(source_id.clone())
7041            .chain(indexed_columns.iter().cloned())
7042            .collect::<Vec<_>>()
7043            .join(", ");
7044        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7045        let sql = format!(
7046            "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};",
7047            columns = indexed_columns.join(", "),
7048            source = visible_table(&table.name),
7049        );
7050        self.conn.execute_batch(&sql).map_err(|e| e.to_string())
7051    }
7052
7053    fn create_fts_projection(
7054        &self,
7055        table: &TableSchema,
7056        index: &FtsIndexSchema,
7057    ) -> Result<(), String> {
7058        self.begin_observation("syncular_fts_schema")?;
7059        let result = (|| {
7060            let existed = self.fts_projection_exists(index)?;
7061            let tokenizer = index.tokenize.replace('\'', "''");
7062            let columns = index
7063                .columns
7064                .iter()
7065                .map(|column| quote_ident(column))
7066                .collect::<Vec<_>>()
7067                .join(", ");
7068            let sql = format!(
7069            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
7070            fts = quote_ident(&index.name),
7071            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
7072        );
7073            self.conn.execute(&sql, []).map_err(|error| {
7074                format!(
7075                    "cannot create local FTS5 projection {:?}: {error}",
7076                    index.name
7077                )
7078            })?;
7079            let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7080            let mapped: bool = self
7081                .conn
7082                .query_row(
7083                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
7084                    [format!("_syncular_fts_{}", index.name)],
7085                    |row| row.get(0),
7086                )
7087                .map_err(|e| e.to_string())?;
7088            self.conn.execute_batch(&format!("CREATE TABLE IF NOT EXISTS {mapping}(id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE)")).map_err(|e| e.to_string())?;
7089            if !mapped && existed {
7090                self.conn
7091                    .execute_batch(&format!(
7092                        "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
7093                        quote_ident(FTS_SOURCE_ID_COLUMN),
7094                        quote_ident(&index.name)
7095                    ))
7096                    .map_err(|e| e.to_string())?;
7097            }
7098            self.create_fts_triggers(table, index)?;
7099            if !existed {
7100                self.rebuild_fts_projection(table, index)?;
7101            }
7102            Ok(())
7103        })();
7104        if let Err(error) = result {
7105            self.rollback_observation("syncular_fts_schema");
7106            return Err(error);
7107        }
7108        if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
7109            self.rollback_observation("syncular_fts_schema");
7110            return Err(error.to_string());
7111        }
7112        Ok(())
7113    }
7114
7115    // -- persistence write-through --------------------------------------------
7116
7117    fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
7118        let state = serde_json::json!({
7119            "requested": scope_map_to_json(&sub.requested),
7120            "params": sub.params,
7121            "cursor": sub.cursor,
7122            "bootstrapState": sub.bootstrap_state,
7123            "status": sub.state.name(),
7124            "reasonCode": sub.reason_code,
7125            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
7126            "syncedOnce": sub.synced_once,
7127        });
7128        self.conn.execute(
7129            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
7130            rusqlite::params![sub.id, sub.table, state.to_string()],
7131        ).map(|_| ()).map_err(|error| error.to_string())
7132    }
7133
7134    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
7135        let ops: Vec<Value> = commit
7136            .ops
7137            .iter()
7138            .map(|op| {
7139                serde_json::json!({
7140                    "op": if op.upsert { "upsert" } else { "delete" },
7141                    "table": op.table,
7142                    "rowId": op.row_id,
7143                    "baseVersion": op.base_version,
7144                    "values": op.values.clone().map(Value::Object),
7145                })
7146            })
7147            .collect();
7148        self.conn
7149            .execute(
7150                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
7151                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
7152            )
7153            .map_err(|error| error.to_string())?;
7154        for operation in &commit.ops {
7155            let Some(values) = &operation.values else {
7156                continue;
7157            };
7158            let Some(table) = self.schema.table(&operation.table) else {
7159                continue;
7160            };
7161            for column in table
7162                .columns
7163                .iter()
7164                .filter(|column| column.ty == ColumnType::BlobRef)
7165            {
7166                let Some(Value::String(raw)) = values.get(&column.name) else {
7167                    continue;
7168                };
7169                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
7170                    continue;
7171                };
7172                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
7173                    continue;
7174                };
7175                self.conn
7176                    .execute(
7177                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
7178                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
7179                        rusqlite::params![commit.client_commit_id, blob_id],
7180                    )
7181                    .map_err(|error| error.to_string())?;
7182            }
7183        }
7184        Ok(())
7185    }
7186
7187    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
7188        self.conn
7189            .execute(
7190                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
7191                rusqlite::params![client_commit_id],
7192            )
7193            .map_err(|error| error.to_string())?;
7194        self.conn
7195            .execute(
7196                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
7197                rusqlite::params![client_commit_id],
7198            )
7199            .map(|_| ())
7200            .map_err(|error| error.to_string())
7201    }
7202
7203    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
7204        match status {
7205            CommitOutcomeStatus::Applied => "applied",
7206            CommitOutcomeStatus::Cached => "cached",
7207            CommitOutcomeStatus::Conflict => "conflict",
7208            CommitOutcomeStatus::Rejected => "rejected",
7209        }
7210    }
7211
7212    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
7213        match resolution {
7214            CommitOutcomeResolution::Active => "active",
7215            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
7216            CommitOutcomeResolution::Superseded => "superseded",
7217            CommitOutcomeResolution::Dismissed => "dismissed",
7218        }
7219    }
7220
7221    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
7222        match value {
7223            "applied" => Ok(CommitOutcomeStatus::Applied),
7224            "cached" => Ok(CommitOutcomeStatus::Cached),
7225            "conflict" => Ok(CommitOutcomeStatus::Conflict),
7226            "rejected" => Ok(CommitOutcomeStatus::Rejected),
7227            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
7228        }
7229    }
7230
7231    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
7232        match value {
7233            "active" => Ok(CommitOutcomeResolution::Active),
7234            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
7235            "superseded" => Ok(CommitOutcomeResolution::Superseded),
7236            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
7237            _ => Err(format!(
7238                "invalid persisted commit outcome resolution {value:?}"
7239            )),
7240        }
7241    }
7242
7243    fn persist_commit_outcome(
7244        &self,
7245        client_commit_id: &str,
7246        status: CommitOutcomeStatus,
7247        results: &[CommitOperationOutcome],
7248        operations: Option<&[OutboxOp]>,
7249    ) -> Result<(), String> {
7250        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
7251        let operations_json = operations
7252            .map(|items| {
7253                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
7254            })
7255            .transpose()
7256            .map_err(|error| error.to_string())?;
7257        self.conn
7258            .execute(
7259                "INSERT INTO _syncular_commit_outcomes (
7260                   client_commit_id, status, recorded_at_ms, results_json,
7261                   operations_json, resolution
7262                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
7263                rusqlite::params![
7264                    client_commit_id,
7265                    Self::outcome_status_name(status),
7266                    self.clock_now_ms(),
7267                    results_json,
7268                    operations_json
7269                ],
7270            )
7271            .map(|_| ())
7272            .map_err(|error| error.to_string())
7273    }
7274
7275    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
7276        let StoredCommitOutcomeRow {
7277            sequence,
7278            client_commit_id,
7279            status,
7280            recorded_at_ms,
7281            results_json,
7282            operations_json,
7283            resolution,
7284            resolved_at_ms,
7285            replacement_client_commit_id,
7286        } = row;
7287        Ok(CommitOutcome {
7288            sequence,
7289            client_commit_id,
7290            status: Self::parse_outcome_status(&status)?,
7291            recorded_at_ms,
7292            results: serde_json::from_str(&results_json)
7293                .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
7294            operations: operations_json
7295                .map(|value| {
7296                    serde_json::from_str(&value).map_err(|error| {
7297                        format!("invalid persisted commit outcome operations: {error}")
7298                    })
7299                })
7300                .transpose()?,
7301            resolution: Self::parse_outcome_resolution(&resolution)?,
7302            resolved_at_ms,
7303            replacement_client_commit_id,
7304        })
7305    }
7306
7307    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
7308        let row = self
7309            .conn
7310            .query_row(
7311                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7312                        resolution, resolved_at_ms, replacement_client_commit_id
7313                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
7314                rusqlite::params![client_commit_id],
7315                |row| {
7316                    Ok(StoredCommitOutcomeRow {
7317                        sequence: row.get(0)?,
7318                        client_commit_id: row.get(1)?,
7319                        status: row.get(2)?,
7320                        recorded_at_ms: row.get(3)?,
7321                        results_json: row.get(4)?,
7322                        operations_json: row.get(5)?,
7323                        resolution: row.get(6)?,
7324                        resolved_at_ms: row.get(7)?,
7325                        replacement_client_commit_id: row.get(8)?,
7326                    })
7327                },
7328            )
7329            .optional()
7330            .map_err(|error| error.to_string())?;
7331        row.map(Self::outcome_from_row).transpose()
7332    }
7333
7334    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
7335        if query.limit == Some(0) {
7336            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
7337        }
7338        let mut sql = String::from(
7339            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7340                    resolution, resolved_at_ms, replacement_client_commit_id
7341               FROM _syncular_commit_outcomes",
7342        );
7343        if query.active_only {
7344            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
7345        }
7346        sql.push_str(" ORDER BY seq DESC");
7347        if let Some(limit) = query.limit {
7348            sql.push_str(&format!(" LIMIT {limit}"));
7349        }
7350        let mut stmt = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
7351        let rows = stmt
7352            .query_map([], |row| {
7353                Ok(StoredCommitOutcomeRow {
7354                    sequence: row.get(0)?,
7355                    client_commit_id: row.get(1)?,
7356                    status: row.get(2)?,
7357                    recorded_at_ms: row.get(3)?,
7358                    results_json: row.get(4)?,
7359                    operations_json: row.get(5)?,
7360                    resolution: row.get(6)?,
7361                    resolved_at_ms: row.get(7)?,
7362                    replacement_client_commit_id: row.get(8)?,
7363                })
7364            })
7365            .map_err(|error| error.to_string())?;
7366        let mut outcomes = Vec::new();
7367        for row in rows {
7368            outcomes.push(Self::outcome_from_row(
7369                row.map_err(|error| error.to_string())?,
7370            )?);
7371        }
7372        Ok(outcomes)
7373    }
7374
7375    fn prune_commit_outcomes(&self) -> Result<(), String> {
7376        #[cfg(test)]
7377        self.outcome_prune_count
7378            .set(self.outcome_prune_count.get() + 1);
7379        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
7380        let count = self
7381            .conn
7382            .query_row(
7383                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
7384                [],
7385                |row| row.get::<_, i64>(0),
7386            )
7387            .map_err(|error| error.to_string())? as usize;
7388        let excess = count.saturating_sub(max_entries);
7389        if excess == 0 {
7390            return Ok(());
7391        }
7392        let mut stmt = self
7393            .conn
7394            .prepare(
7395                "SELECT seq FROM _syncular_commit_outcomes
7396                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
7397                  ORDER BY seq ASC LIMIT ?1",
7398            )
7399            .map_err(|error| error.to_string())?;
7400        let rows = stmt
7401            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
7402            .map_err(|error| error.to_string())?;
7403        let sequences = rows
7404            .collect::<Result<Vec<_>, _>>()
7405            .map_err(|error| error.to_string())?;
7406        drop(stmt);
7407        for sequence in sequences {
7408            self.conn
7409                .execute(
7410                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
7411                    rusqlite::params![sequence],
7412                )
7413                .map_err(|error| error.to_string())?;
7414        }
7415        Ok(())
7416    }
7417
7418    // -- driver surface ---------------------------------------------------------
7419
7420    pub fn subscribe(
7421        &mut self,
7422        id: String,
7423        table: String,
7424        scopes: Vec<(String, Vec<String>)>,
7425        params: Option<String>,
7426    ) -> Result<(), String> {
7427        if self.schema.table(&table).is_none() {
7428            return Err(format!("unknown table {table:?}"));
7429        }
7430        if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
7431            let same_intent = existing.table == table
7432                && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
7433                && existing.params == params;
7434            if same_intent {
7435                return Ok(());
7436            }
7437            return Err(
7438                "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
7439                    .to_owned(),
7440            );
7441        }
7442        let sub = Subscription {
7443            id: id.clone(),
7444            table,
7445            requested: scopes,
7446            params,
7447            cursor: -1,
7448            bootstrap_state: None,
7449            state: SubState::Active,
7450            reason_code: None,
7451            effective: None,
7452            synced_once: false,
7453        };
7454        self.persist_sub(&sub)?;
7455        self.subs.push(sub);
7456        Ok(())
7457    }
7458
7459    pub fn unsubscribe(&mut self, id: &str) {
7460        self.subs.retain(|s| s.id != id);
7461        let _ = self.conn.execute(
7462            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7463            rusqlite::params![id],
7464        );
7465    }
7466
7467    // -- windowed subscriptions (§4.8) ------------------------------------------
7468
7469    /// §4.8: set the live window units for a base — a value-sharded family
7470    /// of subscriptions, one per unit. Added units get fresh subscriptions
7471    /// (image-lane bootstrap on the next sync); removed units are
7472    /// unsubscribed with their first eviction chunk in one transaction (E1–E4).
7473    /// Idempotent; re-entry cancels any deferred eviction.
7474    pub fn set_window(
7475        &mut self,
7476        base: &WindowBase,
7477        units: &[String],
7478    ) -> Result<CommandEffects, String> {
7479        let table = self
7480            .schema
7481            .table(&base.table)
7482            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
7483        if table.scope_column(&base.variable).is_none() {
7484            return Err(format!(
7485                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
7486                base.table, base.variable
7487            ));
7488        }
7489        let base_key = window_base_key(base);
7490        let wanted: std::collections::HashSet<&String> = units.iter().collect();
7491        let live = self.load_window_units(&base_key);
7492        self.begin_observation("syncular_window")?;
7493        let mut batch = ChangeAccumulator::default();
7494        let mut changed = false;
7495
7496        let prior_subs = self.subs.clone();
7497        let widened = (|| {
7498            // Widen: units wanted but not live → fresh subscription + registry row.
7499            for unit in units {
7500                if live.iter().any(|(u, _)| u == unit) {
7501                    continue;
7502                }
7503                let sub_id = derive_sub_id(base, unit);
7504                self.delete_pending_evict(&sub_id)?;
7505                self.insert_window_unit(&base_key, unit, &sub_id)?;
7506                self.subscribe(
7507                    sub_id,
7508                    base.table.clone(),
7509                    unit_scopes(base, unit),
7510                    base.params.clone(),
7511                )?;
7512                batch.window(&base_key, &base.table, unit);
7513                changed = true;
7514            }
7515
7516            self.finish_observation("syncular_window", batch)
7517        })();
7518        if let Err(error) = widened {
7519            self.rollback_observation("syncular_window");
7520            self.subs = prior_subs;
7521            return Err(error);
7522        }
7523        for (unit, sub_id) in live {
7524            if wanted.contains(&unit) {
7525                continue;
7526            }
7527            self.rebuild_overlay_if_dirty();
7528            let prior_subs = self.subs.clone();
7529            self.begin_observation("syncular_window")?;
7530            let effective = self
7531                .subs
7532                .iter()
7533                .find(|sub| sub.id == sub_id)
7534                .and_then(|sub| sub.effective.clone())
7535                .unwrap_or_else(|| unit_scopes(base, &unit));
7536            let mut batch = ChangeAccumulator::default();
7537            self.record_scope_map(&mut batch, &base.table, &effective);
7538            batch.window(&base_key, &base.table, &unit);
7539            let result = self.evict_unit(&base_key, base, &unit, &sub_id);
7540            let remaining = match result {
7541                Ok(remaining) => remaining,
7542                Err(error) => {
7543                    self.rollback_observation("syncular_window");
7544                    self.subs = prior_subs;
7545                    return Err(error);
7546                }
7547            };
7548            if let Err(error) = self.finish_observation("syncular_window", batch) {
7549                self.rollback_observation("syncular_window");
7550                self.subs = prior_subs;
7551                return Err(error);
7552            }
7553            if remaining {
7554                self.drain_pending_evictions()?;
7555            }
7556            changed = true;
7557        }
7558        Ok(if changed {
7559            CommandEffects::interactive()
7560        } else {
7561            CommandEffects::none()
7562        })
7563    }
7564
7565    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
7566    /// the subset still bootstrap-pending. Registration alone is not
7567    /// completeness — a unit is pending until its subscription completes a
7568    /// bootstrap round (cursor advances past -1 with no resume token held).
7569    pub fn window_state(&self, base: &WindowBase) -> WindowState {
7570        let mut units = Vec::new();
7571        let mut pending = Vec::new();
7572        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
7573            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
7574                Some(sub) => {
7575                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
7576                }
7577                None => true,
7578            };
7579            if is_pending {
7580                pending.push(unit.clone());
7581            }
7582            units.push(unit);
7583        }
7584        WindowState { units, pending }
7585    }
7586
7587    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
7588        let mut stmt = match self
7589            .conn
7590            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
7591        {
7592            Ok(stmt) => stmt,
7593            Err(_) => return Vec::new(),
7594        };
7595        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
7596            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7597        });
7598        match rows {
7599            Ok(rows) => rows.filter_map(Result::ok).collect(),
7600            Err(_) => Vec::new(),
7601        }
7602    }
7603
7604    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
7605        let mut stmt = match self.conn.prepare(
7606            "SELECT windows.base, windows.unit, subscriptions.tbl
7607               FROM _syncular_windows AS windows
7608               JOIN _syncular_subscriptions AS subscriptions
7609                 ON subscriptions.id = windows.sub_id
7610               ORDER BY windows.base, windows.unit",
7611        ) {
7612            Ok(stmt) => stmt,
7613            Err(_) => return Vec::new(),
7614        };
7615        let rows = stmt.query_map([], |row| {
7616            Ok((
7617                row.get::<_, String>(0)?,
7618                row.get::<_, String>(1)?,
7619                row.get::<_, String>(2)?,
7620            ))
7621        });
7622        match rows {
7623            Ok(rows) => rows.filter_map(Result::ok).collect(),
7624            Err(_) => Vec::new(),
7625        }
7626    }
7627
7628    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
7629        self.conn
7630            .query_row(
7631                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
7632                rusqlite::params![sub_id],
7633                |row| Ok((row.get(0)?, row.get(1)?)),
7634            )
7635            .ok()
7636    }
7637
7638    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
7639        self.conn
7640            .execute(
7641                "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
7642                rusqlite::params![base_key, unit, sub_id],
7643            )
7644            .map(|_| ())
7645            .map_err(|error| error.to_string())
7646    }
7647
7648    fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
7649        self.conn
7650            .execute(
7651                "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
7652                rusqlite::params![base_key, unit],
7653            )
7654            .map(|_| ())
7655            .map_err(|error| error.to_string())
7656    }
7657
7658    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
7659    /// Deletes the unit's rows except those pinned by a pending outbox
7660    /// commit (E1); records a deferred eviction if any pin remains; discards
7661    /// the subscription's cursor/resume/effective-echo (E3) and version
7662    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
7663    /// nothing.
7664    fn evict_unit(
7665        &mut self,
7666        base_key: &str,
7667        base: &WindowBase,
7668        unit: &str,
7669        sub_id: &str,
7670    ) -> Result<bool, String> {
7671        let effective = self
7672            .subs
7673            .iter()
7674            .find(|s| s.id == sub_id)
7675            .and_then(|s| s.effective.clone())
7676            .unwrap_or_else(|| unit_scopes(base, unit));
7677        let pinned = self.pinned_row_ids(&base.table);
7678        let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
7679        self.delete_window_unit(base_key, unit)?;
7680        self.conn
7681            .execute(
7682                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7683                [sub_id],
7684            )
7685            .map_err(|error| error.to_string())?;
7686        self.subs.retain(|sub| sub.id != sub_id);
7687        if deferred {
7688            self.save_pending_evict(sub_id, &base.table, &effective)?;
7689        } else {
7690            self.delete_pending_evict(sub_id)?;
7691        }
7692        Ok(remaining)
7693    }
7694
7695    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
7696    /// primary keys. Returns (more unpinned work, any scoped rows remain).
7697    /// A missing scope-column mapping fails without deleting rows.
7698    fn evict_scope_rows(
7699        &self,
7700        table_name: &str,
7701        effective: &[(String, Vec<String>)],
7702        pinned: &std::collections::HashSet<String>,
7703    ) -> Result<(bool, bool), String> {
7704        if effective.is_empty() {
7705            return Ok((false, false));
7706        }
7707        let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
7708        let mut clauses = Vec::new();
7709        let mut params: Vec<SqlValue> = Vec::new();
7710        for (variable, values) in effective {
7711            let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
7712            if values.is_empty() {
7713                return Ok((false, false));
7714            }
7715            let holes = values
7716                .iter()
7717                .map(|v| {
7718                    params.push(SqlValue::Text(v.clone()));
7719                    "?"
7720                })
7721                .collect::<Vec<_>>()
7722                .join(", ");
7723            clauses.push(format!("{} IN ({holes})", quote_ident(column)));
7724        }
7725        let scope = clauses.join(" AND ");
7726        let scope_params = params.len();
7727        let pk = quote_ident(&table.primary_key);
7728        if !pinned.is_empty() {
7729            let holes = pinned
7730                .iter()
7731                .map(|id| {
7732                    params.push(SqlValue::Text(id.clone()));
7733                    "?"
7734                })
7735                .collect::<Vec<_>>()
7736                .join(", ");
7737            clauses.push(format!("{pk} NOT IN ({holes})"));
7738        }
7739        let predicate = clauses.join(" AND ");
7740        let base = base_table(table_name);
7741        let visible = visible_table(table_name);
7742        let mut ids: Vec<SqlValue> = Vec::new();
7743        for source in [&base, &visible] {
7744            let mut stmt = self
7745                .conn
7746                .prepare_cached(&format!(
7747                    "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
7748                ))
7749                .map_err(|e| e.to_string())?;
7750            ids = stmt
7751                .query_map(rusqlite::params_from_iter(&params), |row| row.get(0))
7752                .map_err(|e| e.to_string())?
7753                .collect::<Result<_, _>>()
7754                .map_err(|e| e.to_string())?;
7755            if !ids.is_empty() {
7756                break;
7757            }
7758        }
7759        if !ids.is_empty() {
7760            let holes = vec!["?"; ids.len()].join(", ");
7761            for source in [&base, &visible] {
7762                self.conn
7763                    .execute(
7764                        &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
7765                        rusqlite::params_from_iter(&ids),
7766                    )
7767                    .map_err(|e| e.to_string())?;
7768            }
7769        }
7770        let mut remaining = false;
7771        let mut deferred = false;
7772        for source in [&base, &visible] {
7773            remaining |= self
7774                .conn
7775                .query_row(
7776                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
7777                    rusqlite::params_from_iter(&params),
7778                    |row| row.get::<_, bool>(0),
7779                )
7780                .map_err(|e| e.to_string())?;
7781            deferred |= self
7782                .conn
7783                .query_row(
7784                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
7785                    rusqlite::params_from_iter(&params[..scope_params]),
7786                    |row| row.get::<_, bool>(0),
7787                )
7788                .map_err(|e| e.to_string())?;
7789        }
7790        Ok((remaining, deferred))
7791    }
7792
7793    /// Resume committed eviction chunks, retaining outbox pins and their durable marker.
7794    fn drain_pending_evictions(&mut self) -> Result<(), String> {
7795        let pending = self.load_pending_evictions()?;
7796        if pending.is_empty() {
7797            return Ok(());
7798        }
7799        self.rebuild_overlay_if_dirty();
7800        for (sub_id, table_name, effective) in pending {
7801            if self.schema.table(&table_name).is_none() {
7802                self.delete_pending_evict(&sub_id)?;
7803                continue;
7804            }
7805            loop {
7806                std::thread::yield_now();
7807                self.begin_observation("syncular_evict")?;
7808                let pinned = self.pinned_row_ids(&table_name);
7809                let result = self.evict_scope_rows(&table_name, &effective, &pinned);
7810                let (remaining, deferred) = match result {
7811                    Ok(result) => result,
7812                    Err(error) => {
7813                        self.rollback_observation("syncular_evict");
7814                        return Err(error);
7815                    }
7816                };
7817                if !deferred {
7818                    if let Err(error) = self.delete_pending_evict(&sub_id) {
7819                        self.rollback_observation("syncular_evict");
7820                        return Err(error);
7821                    }
7822                }
7823                let mut batch = ChangeAccumulator::default();
7824                self.record_scope_map(&mut batch, &table_name, &effective);
7825                if let Err(error) = self.finish_observation("syncular_evict", batch) {
7826                    self.rollback_observation("syncular_evict");
7827                    return Err(error);
7828                }
7829                if !remaining {
7830                    break;
7831                }
7832            }
7833        }
7834        Ok(())
7835    }
7836
7837    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
7838    /// commit — rows that MUST NOT be evicted until the commit drains.
7839    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
7840        let mut pinned = std::collections::HashSet::new();
7841        for commit in &self.outbox {
7842            for op in &commit.ops {
7843                if op.table == table {
7844                    pinned.insert(op.row_id.clone());
7845                }
7846            }
7847        }
7848        pinned
7849    }
7850
7851    fn save_pending_evict(
7852        &self,
7853        sub_id: &str,
7854        table: &str,
7855        effective: &[(String, Vec<String>)],
7856    ) -> Result<(), String> {
7857        self.conn.execute(
7858            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
7859            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
7860        ).map(|_| ()).map_err(|error| error.to_string())
7861    }
7862
7863    fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
7864        self.conn
7865            .execute(
7866                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
7867                rusqlite::params![sub_id],
7868            )
7869            .map(|_| ())
7870            .map_err(|error| error.to_string())
7871    }
7872
7873    fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
7874        let mut stmt = self
7875            .conn
7876            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
7877            .map_err(|error| error.to_string())?;
7878        let rows = stmt
7879            .query_map([], |row| {
7880                Ok((
7881                    row.get::<_, String>(0)?,
7882                    row.get::<_, String>(1)?,
7883                    row.get::<_, String>(2)?,
7884                ))
7885            })
7886            .map_err(|error| error.to_string())?;
7887        rows.map(|row| {
7888            let (sub_id, table, json) = row.map_err(|error| error.to_string())?;
7889            let value: Value = serde_json::from_str(&json)
7890                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
7891            let effective = json_to_scope_map(&value)
7892                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
7893            Ok((sub_id, table, effective))
7894        })
7895        .collect()
7896    }
7897
7898    /// Record one atomic local commit (§7.1) and apply it optimistically.
7899    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
7900        if mutations.is_empty() {
7901            return Err("a commit must contain at least one operation (§6.1)".to_owned());
7902        }
7903        let mut ops = Vec::with_capacity(mutations.len());
7904        for mutation in mutations {
7905            match mutation {
7906                Mutation::Upsert {
7907                    table,
7908                    values,
7909                    base_version,
7910                } => {
7911                    let schema_table = self
7912                        .schema
7913                        .table(&table)
7914                        .ok_or_else(|| format!("unknown table {table:?}"))?;
7915                    // §5: value keys are accepted in snake_case AND the
7916                    // generated row types' camelCase; normalize to SQL truth
7917                    // before the pk lookup / codec see them.
7918                    let values = normalize_values_casing(schema_table, values)?;
7919                    let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
7920                    // §6.7 `mutate` marks every column present: fill missing
7921                    // nullable columns with NULL before the payload is built.
7922                    let values = full_row_values(schema_table, values)?;
7923                    // §5.11: validate the payload encodes with the current
7924                    // codec. A key-selection or unknown-key failure is a
7925                    // durable rejection at the push seam (§10.3), never an
7926                    // author-time error. Full-row `mutate` presents every
7927                    // column, so no stored fallback is needed.
7928                    self.validate_author_encode(schema_table, &row_id, &values)?;
7929                    ops.push(OutboxOp {
7930                        upsert: true,
7931                        table,
7932                        row_id,
7933                        base_version,
7934                        values: Some(values),
7935                    });
7936                }
7937                Mutation::Delete {
7938                    table,
7939                    row_id,
7940                    base_version,
7941                } => {
7942                    if self.schema.table(&table).is_none() {
7943                        return Err(format!("unknown table {table:?}"));
7944                    }
7945                    ops.push(OutboxOp {
7946                        upsert: false,
7947                        table,
7948                        row_id,
7949                        base_version,
7950                        values: None,
7951                    });
7952                }
7953            }
7954        }
7955        self.record_outbox_commit(ops)
7956    }
7957
7958    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
7959        let commit = OutboxCommit {
7960            client_commit_id: uuid::Uuid::new_v4().to_string(),
7961            ops,
7962        };
7963        self.begin_observation("syncular_mutation")?;
7964        let mut batch = ChangeAccumulator::default();
7965        for op in &commit.ops {
7966            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
7967            if let Some(values) = &op.values {
7968                if let Some(table) = self.schema.table(&op.table) {
7969                    for scope in &table.scope_variables {
7970                        if let Some(Value::String(value)) = values.get(&scope.column) {
7971                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
7972                            precise = true;
7973                        }
7974                    }
7975                }
7976            }
7977            if !precise {
7978                batch.table(&op.table);
7979            }
7980        }
7981        if let Err(error) = self.persist_outbox_insert(&commit) {
7982            self.rollback_observation("syncular_mutation");
7983            return Err(error);
7984        }
7985        let was_dirty = self.overlay_dirty.get();
7986        // A clean overlay contains the FIFO fold of every previous commit, so
7987        // the new commit applies over it. §7.1: an operation that does not
7988        // apply (a secondary unique collision) fails the whole commit; the
7989        // savepoint rollback removes the outbox entry and every visible write.
7990        if was_dirty {
7991            self.rebuild_overlay();
7992        }
7993        if let Some(error) = commit
7994            .ops
7995            .iter()
7996            .find_map(|op| self.apply_outbox_op(op).err())
7997        {
7998            self.rollback_observation("syncular_mutation");
7999            self.overlay_dirty.set(was_dirty);
8000            return Err(error);
8001        }
8002        let id = commit.client_commit_id.clone();
8003        self.outbox.push(commit);
8004        batch.status = true;
8005        if let Err(error) = self.finish_observation("syncular_mutation", batch) {
8006            self.rollback_observation("syncular_mutation");
8007            self.outbox.pop();
8008            self.overlay_dirty.set(was_dirty);
8009            return Err(error);
8010        }
8011        Ok(id)
8012    }
8013
8014    /// Record one §6.1 sparse upsert: the primary key plus the supplied
8015    /// non-scope columns are present; every other column is absent and stays
8016    /// untouched on the server and in the local overlay. The row need not be
8017    /// locally present — an absent local row leaves the overlay absent (§7.1)
8018    /// and the server answers per §5.2.
8019    pub fn patch(
8020        &mut self,
8021        table: &str,
8022        row_id: &str,
8023        partial: Map<String, Value>,
8024        base_version: Option<i64>,
8025    ) -> Result<String, String> {
8026        let schema_table = self
8027            .schema
8028            .table(table)
8029            .ok_or_else(|| format!("unknown table {table:?}"))?;
8030        let partial = normalize_values_casing(schema_table, partial)?;
8031        if let Some(pk) = partial.get(&schema_table.primary_key) {
8032            if pk.as_str() != Some(row_id) {
8033                return Err(format!(
8034                    "sync.invalid_request: table {table:?}: patch cannot change the primary key"
8035                ));
8036            }
8037        }
8038        // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
8039        // server accepts a present scope column whose value equals the
8040        // stored row and applies it as a no-op, so a decoded envelope
8041        // round-trips. The local row is the only value the client can prove
8042        // equality against; when the row is absent locally the client cannot
8043        // prove equality against the server's stored row and fails closed.
8044        // A primary key that is also a scope column needs no stored-row
8045        // read: the primary key in a sparse payload is by construction the
8046        // row id being patched (§6.1), so its value is proven equal already.
8047        for scope in &schema_table.scope_variables {
8048            if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
8049                continue;
8050            }
8051            let Some(column) = schema_table
8052                .columns
8053                .iter()
8054                .find(|column| column.name == scope.column)
8055            else {
8056                continue;
8057            };
8058            let stored = self
8059                .conn
8060                .query_row(
8061                    &format!(
8062                        "SELECT {} FROM {} WHERE {}",
8063                        quote_ident(&scope.column),
8064                        visible_table(&schema_table.name),
8065                        row_id_predicate(schema_table)
8066                    ),
8067                    rusqlite::params![row_id],
8068                    |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
8069                )
8070                .ok();
8071            // Decode the supplied value through the same column-type path the
8072            // rest of the patch encoding uses, so a value that only equals
8073            // the stored one in another number representation still compares
8074            // equal.
8075            let incoming = json_to_column_value(column, partial.get(&scope.column))
8076                .ok()
8077                .map(|value| column_value_to_json(&value));
8078            // A stored blob never equals a patch value, matching the server's
8079            // scope-column comparison (§3.4): bytes are unstorable as scope
8080            // values.
8081            let equal = stored.as_ref().is_some_and(|stored| {
8082                stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
8083            });
8084            if !equal {
8085                return Err(format!(
8086                    "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
8087                    scope.column
8088                ));
8089            }
8090        }
8091        let mut values = partial;
8092        // Presence-set semantics: a proven-equal scope column is dropped, so
8093        // the patch leaves the stored value untouched (§6.2).
8094        for scope in &schema_table.scope_variables {
8095            values.remove(&scope.column);
8096        }
8097        values.insert(
8098            schema_table.primary_key.clone(),
8099            Value::from(row_id.to_owned()),
8100        );
8101        // §5.11: structural validation only; an absent key-id selector is
8102        // resolved at the push seam from the stored local row, and a
8103        // key-selection or unknown-key failure becomes a durable
8104        // `client.encrypt_failed` rejection there (§10.3), never an
8105        // author-time error.
8106        self.validate_author_encode(schema_table, row_id, &values)?;
8107        self.record_outbox_commit(vec![OutboxOp {
8108            upsert: true,
8109            table: table.to_owned(),
8110            row_id: row_id.to_owned(),
8111            base_version,
8112            values: Some(values),
8113        }])
8114    }
8115
8116    /// §5.11 key-id fallback: the stored local row's selector value as a
8117    /// positional row (only the selector slot filled). Returns None when no
8118    /// selector is configured, the row is locally absent, or the stored
8119    /// value is not a usable key id.
8120    fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
8121        let selector = self.encryption.key_id_columns.get(&table.name)?;
8122        let index = table
8123            .columns
8124            .iter()
8125            .position(|column| &column.name == selector)?;
8126        let sql = format!(
8127            "SELECT {} FROM {} WHERE {}",
8128            quote_ident(selector),
8129            visible_table(&table.name),
8130            row_id_predicate(table)
8131        );
8132        let key: Option<String> = self
8133            .conn
8134            .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
8135            .ok()?;
8136        let key = key.filter(|key| !key.is_empty())?;
8137        let mut fallback: Row = vec![None; table.columns.len()];
8138        fallback[index] = Some(ColumnValue::String(key));
8139        Some(fallback)
8140    }
8141
8142    /// §5.11/§10.3 author-time encode check: a structural failure (a
8143    /// non-nullable NULL, a value the codec rejects) is an author-time
8144    /// error; a key-selection or unknown-key failure is deferred to the push
8145    /// seam, where it becomes a durable `client.encrypt_failed` rejection.
8146    fn validate_author_encode(
8147        &self,
8148        table: &TableSchema,
8149        row_id: &str,
8150        values: &Map<String, Value>,
8151    ) -> Result<(), String> {
8152        match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
8153            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
8154            result => result.map(|_| ()),
8155        }
8156    }
8157
8158    /// §5.11/§10.3: drop every pending commit whose push encode fails with
8159    /// `client.encrypt_failed` (an unresolvable key id or an unknown key) and
8160    /// raise one durable rejection per commit. `sync()` never aborts for an
8161    /// encode failure. Mirrors §7.4.4's `drop_incompatible_outbox`, including
8162    /// undoing the dropped commit's purely-optimistic rows.
8163    fn drop_unencodable_outbox(&mut self) -> Result<bool, String> {
8164        if !self
8165            .schema
8166            .tables
8167            .iter()
8168            .any(|table| table.has_encrypted_columns())
8169        {
8170            return Ok(false);
8171        }
8172        let failures = self
8173            .outbox
8174            .iter()
8175            .filter_map(|commit| {
8176                commit
8177                    .ops
8178                    .iter()
8179                    .find_map(|op| {
8180                        let values = op.values.as_ref().filter(|_| op.upsert)?;
8181                        let table = self.schema.table(&op.table)?;
8182                        let fallback = self.stored_key_fallback(table, &op.row_id);
8183                        match encode_sparse_row_json(
8184                            table,
8185                            &op.row_id,
8186                            values,
8187                            &self.encryption,
8188                            fallback.as_ref(),
8189                        ) {
8190                            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Some(error),
8191                            _ => None,
8192                        }
8193                    })
8194                    .map(|error| (commit.clone(), error))
8195            })
8196            .collect::<Vec<_>>();
8197        if failures.is_empty() {
8198            return Ok(false);
8199        }
8200        for (commit, message) in failures {
8201            let rejection = RejectionRecord {
8202                client_commit_id: commit.client_commit_id.clone(),
8203                op_index: 0,
8204                code: ENCRYPT_FAILED_CODE.to_owned(),
8205                message,
8206                retryable: false,
8207                details: None,
8208                operation: commit.ops.first().map(CommitOperation::from),
8209            };
8210            self.persist_commit_outcome(
8211                &commit.client_commit_id,
8212                CommitOutcomeStatus::Rejected,
8213                &[CommitOperationOutcome::Error {
8214                    rejection: rejection.clone(),
8215                }],
8216                Some(&commit.ops),
8217            )?;
8218            self.delete_outbox_persisted(&commit.client_commit_id)?;
8219            self.outbox
8220                .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
8221            self.rejections.push(rejection);
8222        }
8223        self.prune_commit_outcomes()?;
8224        self.overlay_dirty.set(true);
8225        self.rebuild_overlay();
8226        Ok(true)
8227    }
8228
8229    pub fn pending_commit_ids(&self) -> Vec<String> {
8230        self.outbox
8231            .iter()
8232            .map(|c| c.client_commit_id.clone())
8233            .collect()
8234    }
8235
8236    /// §6.1: the sparse push payload bytes of every pending upsert operation,
8237    /// FIFO — exactly what the next push round would put on the wire. Used by
8238    /// conformance B.21(h) to pin cross-core byte identity.
8239    pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
8240        let mut payloads = Vec::new();
8241        for commit in &self.outbox {
8242            for op in &commit.ops {
8243                let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
8244                    continue;
8245                };
8246                let Some(table) = self.schema.table(&op.table) else {
8247                    continue;
8248                };
8249                let fallback = self.stored_key_fallback(table, &op.row_id);
8250                if let Ok(payload) = encode_sparse_row_json(
8251                    table,
8252                    &op.row_id,
8253                    values,
8254                    &self.encryption,
8255                    fallback.as_ref(),
8256                ) {
8257                    payloads.push(payload);
8258                }
8259            }
8260        }
8261        payloads
8262    }
8263
8264    pub fn conflicts(&self) -> &[ConflictRecord] {
8265        &self.conflicts
8266    }
8267
8268    pub fn rejections(&self) -> &[RejectionRecord] {
8269        &self.rejections
8270    }
8271
8272    pub fn resolve_commit_outcome(
8273        &mut self,
8274        input: ResolveCommitOutcomeInput,
8275    ) -> Result<CommitOutcome, String> {
8276        let current = self
8277            .commit_outcome(&input.client_commit_id)?
8278            .ok_or_else(|| {
8279                format!(
8280                    "sync.outcome_not_found: no durable outcome exists for {:?}",
8281                    input.client_commit_id
8282                )
8283            })?;
8284        if current.resolution != CommitOutcomeResolution::Active {
8285            return Ok(current);
8286        }
8287        if input.resolution == CommitOutcomeResolution::Active {
8288            return Err("sync.invalid_request: resolution must leave active state".to_owned());
8289        }
8290        match input.resolution {
8291            CommitOutcomeResolution::Superseded => {
8292                let replacement = input
8293                    .replacement_client_commit_id
8294                    .as_deref()
8295                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
8296                    .ok_or_else(|| {
8297                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
8298                            .to_owned()
8299                    })?;
8300                let _ = replacement;
8301            }
8302            _ if input.replacement_client_commit_id.is_some() => {
8303                return Err(
8304                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
8305                        .to_owned(),
8306                );
8307            }
8308            _ => {}
8309        }
8310        let allowed = match current.status {
8311            CommitOutcomeStatus::Conflict => matches!(
8312                input.resolution,
8313                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
8314            ),
8315            CommitOutcomeStatus::Rejected => {
8316                input.resolution == CommitOutcomeResolution::Superseded
8317            }
8318            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
8319                input.resolution == CommitOutcomeResolution::Dismissed
8320            }
8321        };
8322        if !allowed {
8323            return Err(format!(
8324                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
8325                input.resolution, current.status
8326            ));
8327        }
8328
8329        self.begin_observation("syncular_outcome_resolution")?;
8330        let result = (|| {
8331            self.conn
8332                .execute(
8333                    "UPDATE _syncular_commit_outcomes
8334                        SET resolution = ?1, resolved_at_ms = ?2,
8335                            replacement_client_commit_id = ?3
8336                      WHERE client_commit_id = ?4 AND resolution = 'active'",
8337                    rusqlite::params![
8338                        Self::outcome_resolution_name(input.resolution),
8339                        self.clock_now_ms(),
8340                        input.replacement_client_commit_id,
8341                        input.client_commit_id
8342                    ],
8343                )
8344                .map_err(|error| error.to_string())?;
8345            let resolved = self
8346                .commit_outcome(&current.client_commit_id)?
8347                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
8348            self.prune_commit_outcomes()?;
8349            Ok(resolved)
8350        })();
8351        let resolved = match result {
8352            Ok(outcome) => outcome,
8353            Err(error) => {
8354                self.rollback_observation("syncular_outcome_resolution");
8355                return Err(error);
8356            }
8357        };
8358        self.conflicts
8359            .retain(|record| record.client_commit_id != current.client_commit_id);
8360        self.rejections
8361            .retain(|record| record.client_commit_id != current.client_commit_id);
8362        let batch = ChangeAccumulator {
8363            conflicts: current.status == CommitOutcomeStatus::Conflict,
8364            rejections: current.status == CommitOutcomeStatus::Rejected,
8365            outcomes: true,
8366            ..ChangeAccumulator::default()
8367        };
8368        if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
8369            self.rollback_observation("syncular_outcome_resolution");
8370            return Err(error);
8371        }
8372        Ok(resolved)
8373    }
8374
8375    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
8376        self.schema_floor.as_ref()
8377    }
8378
8379    /// §7.3.5: the client's opaque auth-lease state, if any.
8380    pub fn lease_state(&self) -> Option<&LeaseState> {
8381        self.lease_state.as_ref()
8382    }
8383
8384    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
8385    /// the two lease codes set it; other errors leave leaseState untouched.
8386    fn record_lease_error(&mut self, code: &str) {
8387        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
8388            return;
8389        }
8390        let mut next = self.lease_state.clone().unwrap_or_default();
8391        next.error_code = Some(code.to_owned());
8392        self.set_lease_state(Some(next));
8393    }
8394
8395    fn set_lease_state(&mut self, next: Option<LeaseState>) {
8396        if self.lease_state == next {
8397            return;
8398        }
8399        if self.begin_observation("syncular_lease").is_err() {
8400            return;
8401        }
8402        self.lease_state = next;
8403        if let Some(lease) = &self.lease_state {
8404            if let Ok(json) = serde_json::to_string(lease) {
8405                self.set_meta(LEASE_STATE_KEY, &json);
8406            }
8407        } else {
8408            self.delete_meta(LEASE_STATE_KEY);
8409        }
8410        let batch = ChangeAccumulator {
8411            status: true,
8412            ..ChangeAccumulator::default()
8413        };
8414        if self.finish_observation("syncular_lease", batch).is_err() {
8415            self.rollback_observation("syncular_lease");
8416        }
8417    }
8418
8419    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
8420        if self.schema_floor == next {
8421            return;
8422        }
8423        if self.begin_observation("syncular_schema_floor").is_err() {
8424            return;
8425        }
8426        self.schema_floor = next;
8427        self.stopped = self.schema_floor.is_some();
8428        if let Some(floor) = &self.schema_floor {
8429            if let Ok(json) = serde_json::to_string(floor) {
8430                self.set_meta(SCHEMA_FLOOR_KEY, &json);
8431            }
8432        } else {
8433            self.delete_meta(SCHEMA_FLOOR_KEY);
8434        }
8435        let batch = ChangeAccumulator {
8436            status: true,
8437            ..ChangeAccumulator::default()
8438        };
8439        if self
8440            .finish_observation("syncular_schema_floor", batch)
8441            .is_err()
8442        {
8443            self.rollback_observation("syncular_schema_floor");
8444        }
8445    }
8446
8447    fn set_upgrading(&mut self, value: bool) {
8448        if self.upgrading == value {
8449            return;
8450        }
8451        if self.begin_observation("syncular_upgrading").is_err() {
8452            return;
8453        }
8454        self.upgrading = value;
8455        let batch = ChangeAccumulator {
8456            status: true,
8457            ..ChangeAccumulator::default()
8458        };
8459        if self
8460            .finish_observation("syncular_upgrading", batch)
8461            .is_err()
8462        {
8463            self.rollback_observation("syncular_upgrading");
8464        }
8465    }
8466
8467    pub fn sync_needed(&self) -> bool {
8468        self.sync_needed
8469    }
8470
8471    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
8472        let sub = self.subs.iter().find(|s| s.id == id)?;
8473        Some(SubscriptionStateView {
8474            id: sub.id.clone(),
8475            table: sub.table.clone(),
8476            status: sub.state.name().to_owned(),
8477            cursor: sub.cursor,
8478            has_resume_token: sub.bootstrap_state.is_some(),
8479            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
8480            reason_code: sub.reason_code.clone(),
8481        })
8482    }
8483
8484    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
8485        let schema_table = self
8486            .schema
8487            .table(table)
8488            .ok_or_else(|| format!("unknown table {table:?}"))?;
8489        let sql = format!(
8490            "SELECT * FROM {} ORDER BY {} ASC",
8491            visible_table(table),
8492            quote_ident(&schema_table.primary_key)
8493        );
8494        let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?;
8495        let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
8496        let mut out = Vec::new();
8497        while let Some(row) = rows.next().map_err(|e| e.to_string())? {
8498            let mut values = Map::new();
8499            for (i, column) in schema_table.columns.iter().enumerate() {
8500                let value = row.get_ref(i).map_err(|e| e.to_string())?;
8501                values.insert(column.name.clone(), sql_ref_to_json(column, value));
8502            }
8503            let version: i64 = row
8504                .get(schema_table.columns.len())
8505                .map_err(|e| e.to_string())?;
8506            let row_id = match values.get(&schema_table.primary_key) {
8507                Some(Value::String(s)) => s.clone(),
8508                Some(Value::Number(n)) => n.to_string(),
8509                Some(Value::Bool(b)) => b.to_string(),
8510                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
8511            };
8512            out.push(RowState {
8513                row_id,
8514                version,
8515                values,
8516            });
8517        }
8518        Ok(out)
8519    }
8520
8521    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
8522    //
8523    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
8524    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
8525    // the op with `yrs`, re-encodes the whole doc state, and records a
8526    // baseVersion-less sparse upsert carrying only the primary key and the
8527    // crdt column (§5.10.3, §6.6). No local merge — merging is server-side;
8528    // the overlay re-materializes the edit immediately (optimistic apply,
8529    // §7.1) and the server-merged bytes arrive on the next pull, idempotently.
8530    // Byte-compatible with `@syncular/crdt-yjs`.
8531
8532    /// The current stored value of a `crdt` column for one row — the visible
8533    /// (optimistic) bytes, or `None` when the row is absent or the column is
8534    /// NULL (the empty document, §5.10.1). Errors if the column is not a
8535    /// `crdt` column (guards the app against a typo'd column name).
8536    #[cfg(feature = "crdt-yjs")]
8537    fn crdt_column_bytes(
8538        &self,
8539        table: &str,
8540        row_id: &str,
8541        column: &str,
8542    ) -> Result<Option<Vec<u8>>, String> {
8543        let schema_table = self
8544            .schema
8545            .table(table)
8546            .ok_or_else(|| format!("unknown table {table:?}"))?;
8547        let col = schema_table
8548            .columns
8549            .iter()
8550            .find(|c| c.name == column)
8551            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
8552        if col.ty != ColumnType::Crdt {
8553            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
8554        }
8555        let sql = format!(
8556            "SELECT {} FROM {} WHERE {}",
8557            quote_ident(column),
8558            visible_table(table),
8559            row_id_predicate(schema_table)
8560        );
8561        let bytes: Option<Vec<u8>> = self
8562            .conn
8563            .prepare_cached(&sql)
8564            .map_err(|error| error.to_string())?
8565            .query_row(rusqlite::params![row_id], |row| {
8566                row.get::<_, Option<Vec<u8>>>(0)
8567            })
8568            .map_err(|e| match e {
8569                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
8570                other => other.to_string(),
8571            })?;
8572        Ok(bytes)
8573    }
8574
8575    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
8576    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
8577    /// An absent row / NULL column is the empty document (empty string).
8578    #[cfg(feature = "crdt-yjs")]
8579    pub fn crdt_text(
8580        &self,
8581        table: &str,
8582        row_id: &str,
8583        column: &str,
8584        name: &str,
8585    ) -> Result<String, String> {
8586        let bytes = self
8587            .crdt_column_bytes(table, row_id, column)?
8588            .unwrap_or_default();
8589        crate::crdt::text(&bytes, name)
8590    }
8591
8592    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and
8593    /// record the resulting crdt-only sparse operation. Returns the enqueued
8594    /// `clientCommitId`.
8595    #[cfg(feature = "crdt-yjs")]
8596    pub fn crdt_insert_text(
8597        &mut self,
8598        table: &str,
8599        row_id: &str,
8600        column: &str,
8601        name: &str,
8602        index: u32,
8603        value: &str,
8604    ) -> Result<String, String> {
8605        let current = self
8606            .crdt_column_bytes(table, row_id, column)?
8607            .unwrap_or_default();
8608        let update = crate::crdt::insert_text(&current, name, index, value)?;
8609        self.crdt_push_update(table, row_id, column, &update)
8610    }
8611
8612    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and
8613    /// record the resulting crdt-only sparse operation. Returns the enqueued
8614    /// `clientCommitId`.
8615    #[cfg(feature = "crdt-yjs")]
8616    pub fn crdt_delete_text(
8617        &mut self,
8618        table: &str,
8619        row_id: &str,
8620        column: &str,
8621        name: &str,
8622        index: u32,
8623        len: u32,
8624    ) -> Result<String, String> {
8625        let current = self
8626            .crdt_column_bytes(table, row_id, column)?
8627            .unwrap_or_default();
8628        let update = crate::crdt::delete_text(&current, name, index, len)?;
8629        self.crdt_push_update(table, row_id, column, &update)
8630    }
8631
8632    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
8633    /// `crdt` column's current state and record the resulting crdt-only sparse
8634    /// operation. The app authored the update with its own `yrs` model.
8635    /// Returns the enqueued `clientCommitId`.
8636    #[cfg(feature = "crdt-yjs")]
8637    pub fn crdt_apply_update(
8638        &mut self,
8639        table: &str,
8640        row_id: &str,
8641        column: &str,
8642        update: &[u8],
8643    ) -> Result<String, String> {
8644        let current = self
8645            .crdt_column_bytes(table, row_id, column)?
8646            .unwrap_or_default();
8647        let next = crate::crdt::apply_update(&current, update)?;
8648        self.crdt_push_update(table, row_id, column, &next)
8649    }
8650
8651    /// Shared tail of the crdt edit methods: record a §6.1 sparse crdt-only
8652    /// upsert — the primary key plus the crdt column, no `baseVersion` — and
8653    /// enqueue it. The server merges the crdt column and leaves every other
8654    /// column untouched (§5.10.3), so a baseVersion-less crdt edit cannot
8655    /// clobber a concurrent edit to another column. A locally absent row
8656    /// records the same partial operation; the server answers per §6.2.
8657    #[cfg(feature = "crdt-yjs")]
8658    fn crdt_push_update(
8659        &mut self,
8660        table: &str,
8661        row_id: &str,
8662        column: &str,
8663        crdt_bytes: &[u8],
8664    ) -> Result<String, String> {
8665        let mut bytes_obj = Map::new();
8666        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
8667        let mut partial = Map::new();
8668        partial.insert(column.to_owned(), Value::Object(bytes_obj));
8669        self.patch(table, row_id, partial, None)
8670    }
8671
8672    /// Run an arbitrary read-only SQL query against the local database and
8673    /// return each row as a `column-name → JSON value` map. This is the seam
8674    /// the React `useSyncQuery` live-query API needs (it takes app-authored
8675    /// SQL over the visible tables/views, not a fixed table read like
8676    /// [`read_rows`]).
8677    ///
8678    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
8679    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
8680    /// same envelope the command surface uses everywhere else. Output BLOB
8681    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
8682    ///
8683    /// The result column typing is dynamic (SQLite's stored affinity), because
8684    /// arbitrary SQL can alias, join, and compute — there is no schema column
8685    /// to consult per output cell, unlike [`read_rows`].
8686    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
8687        query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
8688    }
8689
8690    /// Repository benchmark and conformance access, present only with `bench-internals`.
8691    #[cfg(feature = "bench-internals")]
8692    #[doc(hidden)]
8693    pub fn benchmark_connection(&mut self) -> &mut Connection {
8694        &mut self.conn
8695    }
8696
8697    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
8698    #[cfg(feature = "bench-internals")]
8699    #[doc(hidden)]
8700    pub fn benchmark_phases(
8701        &self,
8702        enabled: Option<bool>,
8703        reset: bool,
8704    ) -> Result<Option<Value>, String> {
8705        self.benchmark_phases.configure(enabled, reset)?;
8706        Ok(self.benchmark_phases.snapshot())
8707    }
8708
8709    /// Rows, coverage, and local revision from one SQLite read snapshot.
8710    /// §7.5 atomic snapshot read. An `owner` records a failure in
8711    /// `diagnostics_snapshot().query_failures` until its next successful read.
8712    pub fn query_snapshot(
8713        &mut self,
8714        sql: &str,
8715        params: &[QueryValue],
8716        coverage: &[WindowCoverage],
8717        owner: Option<QueryOwner<'_>>,
8718    ) -> Result<QuerySnapshot, String> {
8719        let result = snapshot_connection(&self.conn, sql, params, coverage);
8720        if let Some(owner) = owner {
8721            self.record_query_read(owner, result.as_ref().err());
8722        }
8723        result.map_err(|failure| failure.to_string())
8724    }
8725
8726    /// §7.6: record the outcome of one owned snapshot read, including reads a
8727    /// host ran on a separate connection. A success removes the owner's entry;
8728    /// an identical failure leaves it unchanged; a changed failure moves last.
8729    pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
8730        let position = self
8731            .query_failures
8732            .iter()
8733            .position(|entry| entry.id == owner.id);
8734        let Some(failure) = failure else {
8735            if let Some(index) = position {
8736                self.query_failures.remove(index);
8737            }
8738            return;
8739        };
8740        let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
8741            .into_iter()
8742            .map(str::to_owned)
8743            .collect();
8744        let code = failure.code.unwrap_or("client.query_failed").to_owned();
8745        if position.is_some_and(|index| {
8746            let entry = &self.query_failures[index];
8747            entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
8748        }) {
8749            return;
8750        }
8751        if let Some(index) = position {
8752            self.query_failures.remove(index);
8753        }
8754        self.query_failures.push(DiagnosticQueryFailure {
8755            id: owner.id.to_owned(),
8756            tables,
8757            code,
8758            sqlite_code: failure.sqlite_code,
8759            at_ms: self.clock_now_ms(),
8760        });
8761        if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
8762            self.query_failures.remove(0);
8763        }
8764    }
8765
8766    // -- request building ---------------------------------------------------------
8767
8768    fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
8769        #[cfg(feature = "bench-internals")]
8770        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
8771        let log_epoch = self.get_meta(LOG_EPOCH_KEY);
8772        let mut frames = vec![Frame::ReqHeader {
8773            client_id: self.client_id.clone(),
8774            schema_version: self.schema.version,
8775            log_epoch: log_epoch.clone(),
8776        }];
8777        let mut pushed_ids = Vec::new();
8778        let mut ops_in_request = 0usize;
8779        let mut deferred_commits = 0usize;
8780        #[cfg(feature = "bench-internals")]
8781        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
8782        for (index, commit) in self
8783            .outbox
8784            .iter()
8785            .take(if log_epoch.is_some() {
8786                self.outbox.len()
8787            } else {
8788                0
8789            })
8790            .enumerate()
8791        {
8792            // §6.1 splitBatch: stop at the operation cap — commits apply in
8793            // order, so everything from the first non-fitting commit on is
8794            // deferred to the next round. A single over-cap commit still goes
8795            // alone (commits are atomic and cannot be split).
8796            if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
8797                deferred_commits = self.outbox.len() - index;
8798                break;
8799            }
8800            ops_in_request += commit.ops.len();
8801            let operations = commit
8802                .ops
8803                .iter()
8804                .map(|op| {
8805                    let payload = op.values.as_ref().and_then(|values| {
8806                        let table = self.schema.table(&op.table)?;
8807                        // §0: outbox entries encode at send time with the
8808                        // current codec (validated at mutate()). §6.1: the
8809                        // values map's keys are the sparse presence set.
8810                        // §5.11: encrypted columns are encrypted here, with
8811                        // an absent key-id selector read from the stored row.
8812                        // `sync_inner` runs `drop_unencodable_outbox` first,
8813                        // so an encrypt failure cannot reach this encode; the
8814                        // author seam validated every other failure.
8815                        let fallback = self.stored_key_fallback(table, &op.row_id);
8816                        encode_sparse_row_json(
8817                            table,
8818                            &op.row_id,
8819                            values,
8820                            &self.encryption,
8821                            fallback.as_ref(),
8822                        )
8823                        .ok()
8824                    });
8825                    ssp2::model::Operation {
8826                        table: op.table.clone(),
8827                        row_id: op.row_id.clone(),
8828                        op: if op.upsert { Op::Upsert } else { Op::Delete },
8829                        base_version: op.base_version,
8830                        payload,
8831                    }
8832                })
8833                .collect();
8834            frames.push(Frame::PushCommit {
8835                client_commit_id: commit.client_commit_id.clone(),
8836                operations,
8837            });
8838            pushed_ids.push(commit.client_commit_id.clone());
8839        }
8840        #[cfg(feature = "bench-internals")]
8841        drop(outbox_phase);
8842        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
8843        // bare URL — capability negotiation, decided per transport.
8844        let accept = self.limits.accept.unwrap_or(if url_capable {
8845            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
8846        } else {
8847            DEFAULT_ACCEPT
8848        });
8849        frames.push(Frame::PullHeader {
8850            limit_commits: self.limits.limit_commits.unwrap_or(0),
8851            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
8852            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
8853            accept,
8854        });
8855        let mut fresh = Vec::new();
8856        for sub in &self.subs {
8857            if sub.state != SubState::Active {
8858                continue;
8859            }
8860            let mut scopes = sub.requested.clone();
8861            sort_scope_map(&mut scopes);
8862            frames.push(Frame::Subscription {
8863                id: sub.id.clone(),
8864                table: sub.table.clone(),
8865                scopes,
8866                params: sub.params.clone().map(RawJson),
8867                cursor: sub.cursor,
8868                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
8869            });
8870            fresh.push((
8871                sub.id.clone(),
8872                sub.cursor < 0 && sub.bootstrap_state.is_none(),
8873            ));
8874        }
8875        let message = Message {
8876            wire_version: WIRE_VERSION,
8877            msg_kind: MsgKind::Request,
8878            frames,
8879        };
8880        (
8881            message,
8882            RequestMeta {
8883                pushed_ids,
8884                fresh,
8885                accept,
8886                deferred_commits,
8887            },
8888        )
8889    }
8890
8891    // -- sync -------------------------------------------------------------------
8892
8893    /// Cloneable observation handle; no client lock is needed to read it.
8894    pub fn progress(&self) -> ProgressObserver {
8895        self.progress.clone()
8896    }
8897
8898    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
8899        self.progress.start();
8900        let started_at_ms = self.clock_now_ms();
8901        let outcome = self.sync_inner(transport);
8902        self.progress.update(|p| match &outcome {
8903            SyncOutcome::Ok(report) if report.failed.is_empty() => {
8904                p.state = ProgressState::Complete
8905            }
8906            SyncOutcome::Ok(_) => {
8907                p.state = ProgressState::Failed;
8908                p.error_code = Some("sync.scope_revoked".into());
8909            }
8910            SyncOutcome::Failed { error_code, .. } => {
8911                p.state = ProgressState::Failed;
8912                p.error_code = Some(Self::diagnostic_code(error_code));
8913            }
8914            SyncOutcome::RealtimeUnavailable { .. } => {
8915                p.state = ProgressState::Failed;
8916                p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
8917            }
8918        });
8919        let completed_at_ms = self.clock_now_ms();
8920        self.last_round = Some(match &outcome {
8921            SyncOutcome::Ok(report) => DiagnosticLastRound {
8922                status: "succeeded".to_owned(),
8923                started_at_ms,
8924                completed_at_ms,
8925                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
8926                counters: Some(DiagnosticRoundCounters {
8927                    pushed: report.pushed,
8928                    applied: report.applied.len(),
8929                    rejected: report.rejected.len(),
8930                    retryable: report.retryable.len(),
8931                    conflicts: report.conflicts,
8932                    commits_applied: report.commits_applied,
8933                    segment_rows_applied: report.segment_rows_applied,
8934                    bootstrapping: report.bootstrapping.len(),
8935                    resets: report.resets.len(),
8936                    revoked: report.revoked.len(),
8937                    failed: report.failed.len(),
8938                    deferred_commits: report.deferred_commits,
8939                }),
8940                error_code: None,
8941            },
8942            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
8943                status: "failed".to_owned(),
8944                started_at_ms,
8945                completed_at_ms,
8946                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
8947                counters: None,
8948                error_code: Some(Self::diagnostic_code(error_code)),
8949            },
8950            SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
8951                status: "failed".to_owned(),
8952                started_at_ms,
8953                completed_at_ms,
8954                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
8955                counters: None,
8956                error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
8957            },
8958        });
8959        outcome
8960    }
8961
8962    fn sync_inner(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
8963        if self.stopped {
8964            // §1.6: the client stopped at the schema floor; syncing is inert
8965            // until an upgrade. The outbox is preserved for replay.
8966            return SyncOutcome::Ok(SyncReport {
8967                schema_floor: self.schema_floor.clone(),
8968                ..SyncReport::default()
8969            });
8970        }
8971        // §8.4: the coalesced sync-needed signal clears when a pull round
8972        // BEGINS, so a wake-up landing mid-round survives it.
8973        self.set_sync_needed(false, false);
8974        // §5.9.7 B4: upload pending blobs before pushing the referencing
8975        // rows, so the server-side existence check (§6.6) passes.
8976        if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
8977            if let Err(TransportError { code, message }) = self.flush_blob_uploads(transport) {
8978                if Self::retryable_transport_code(&code) {
8979                    self.schedule_background_retry();
8980                }
8981                return SyncOutcome::Failed {
8982                    error_code: code,
8983                    message,
8984                };
8985            }
8986        }
8987        if let Err(message) = self.drain_pending_evictions() {
8988            return SyncOutcome::Failed {
8989                error_code: "storage.failed".into(),
8990                message,
8991            };
8992        }
8993        // §5.11/§10.3: an unresolvable key id or an unknown key is a durable
8994        // per-commit rejection, never a reason to abort the round. Drop those
8995        // commits before the request is built.
8996        if let Err(message) = self.drop_unencodable_outbox() {
8997            return SyncOutcome::Failed {
8998                error_code: "storage.failed".into(),
8999                message,
9000            };
9001        }
9002        let (message, meta) = self.build_request(transport.supports_url_fetch());
9003        let request_bytes = {
9004            #[cfg(feature = "bench-internals")]
9005            let _phase = self.benchmark_phases.start(Phase::RequestEncode);
9006            encode_message(&message)
9007        };
9008        // §8.7: rounds ride the socket whenever it is connected (one
9009        // loop, no fallback pair); the transport seam stays bytes-in /
9010        // bytes-out either way. Registration-at-round-end is server-side.
9011        // §8.8: under `required`, a round entered while the socket is not
9012        // connected is refused as a typed state and NEVER falls back to the
9013        // request/response binding.
9014        let round = if self.realtime_state == RealtimeState::Connected {
9015            transport.realtime_sync(&request_bytes)
9016        } else if self.realtime_policy == RealtimePolicy::Required {
9017            let retry_delay_ms = self.schedule_background_retry();
9018            self.realtime_retry_delay_ms = Some(retry_delay_ms);
9019            return SyncOutcome::RealtimeUnavailable {
9020                state: self.realtime_state,
9021                reason_code: self.realtime_reason_code.clone(),
9022                retry_delay_ms,
9023            };
9024        } else {
9025            transport.sync(&request_bytes)
9026        };
9027        let realtime_round = self.realtime_state == RealtimeState::Connected;
9028        let response_bytes = match round {
9029            Ok(bytes) => bytes,
9030            Err(TransportError { code, message }) => {
9031                // §8.8: the socket could not carry the round. Record the loss
9032                // with the transport's code so a later round refuses under
9033                // `required` and reports the explicit state.
9034                if realtime_round {
9035                    self.set_realtime_state(RealtimeState::Lost, Some(&code));
9036                }
9037                // §7.3.5: a request-level lease code stops-and-surfaces —
9038                // record it in leaseState (no local-data purge, §7.3.4).
9039                self.record_lease_error(&code);
9040                if Self::retryable_transport_code(&code) {
9041                    let retry_delay_ms = self.schedule_background_retry();
9042                    if realtime_round {
9043                        self.realtime_retry_delay_ms = Some(retry_delay_ms);
9044                    }
9045                }
9046                return SyncOutcome::Failed {
9047                    error_code: code,
9048                    message,
9049                };
9050            }
9051        };
9052        #[cfg(feature = "bench-internals")]
9053        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
9054        let response = match decode_message(&response_bytes) {
9055            Ok(message) => message,
9056            Err(error) => {
9057                // §1.2 rule 1 / §1.4 rule 5: truncated or malformed
9058                // responses abort without persisting anything.
9059                return SyncOutcome::Failed {
9060                    error_code: error.code.as_str().to_owned(),
9061                    message: error.detail,
9062                };
9063            }
9064        };
9065        #[cfg(feature = "bench-internals")]
9066        drop(decode_phase);
9067        if response.msg_kind != MsgKind::Response {
9068            return SyncOutcome::Failed {
9069                error_code: "sync.invalid_request".to_owned(),
9070                message: "expected a response message".to_owned(),
9071            };
9072        }
9073        let mut outcome = self.process_response(transport, response, &meta);
9074        if let SyncOutcome::Ok(report) = &mut outcome {
9075            report.deferred_commits = meta.deferred_commits;
9076        }
9077        match &outcome {
9078            SyncOutcome::Ok(_) => self.reset_background_retry(),
9079            SyncOutcome::Failed { error_code, .. }
9080                if Self::retryable_transport_code(error_code) =>
9081            {
9082                self.schedule_background_retry();
9083            }
9084            SyncOutcome::Failed { .. } => {}
9085            SyncOutcome::RealtimeUnavailable { .. } => {}
9086        }
9087        if meta.deferred_commits > 0 {
9088            // §6.1 splitBatch: commits past the operation cap wait for the
9089            // next round — keep the host's sync signal raised until then.
9090            self.set_sync_needed(true, true);
9091        }
9092        // RFC 0005 D7: coverage completion, lease end, scope revocation and the
9093        // TTL are evaluated once per round, after the apply.
9094        self.previous_version_lifetime_check();
9095        outcome
9096    }
9097
9098    pub fn sync_until_idle(
9099        &mut self,
9100        transport: &mut dyn Transport,
9101        max_rounds: Option<u32>,
9102    ) -> SyncOutcome {
9103        let rounds = max_rounds.unwrap_or(20).max(1);
9104        let mut aggregate = SyncReport::default();
9105        let mut spent = 0;
9106        while spent < rounds {
9107            spent += 1;
9108            let before = self
9109                .subs
9110                .iter()
9111                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
9112                .collect::<Vec<_>>();
9113            let outcome = self.sync(transport);
9114            match outcome {
9115                SyncOutcome::Failed {
9116                    error_code,
9117                    message,
9118                } => {
9119                    return SyncOutcome::Failed {
9120                        error_code,
9121                        message,
9122                    };
9123                }
9124                SyncOutcome::RealtimeUnavailable { .. } => return outcome,
9125                SyncOutcome::Ok(report) => {
9126                    if max_rounds.is_none()
9127                        && report.segment_rows_applied > 0
9128                        && !report.bootstrapping.is_empty()
9129                        && before
9130                            != self
9131                                .subs
9132                                .iter()
9133                                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
9134                                .collect::<Vec<_>>()
9135                    {
9136                        spent = 0;
9137                    }
9138                    aggregate.pushed += report.pushed;
9139                    aggregate.applied.extend(report.applied.iter().cloned());
9140                    aggregate.rejected.extend(report.rejected.iter().cloned());
9141                    aggregate.retryable.extend(report.retryable.iter().cloned());
9142                    aggregate.conflicts += report.conflicts;
9143                    aggregate.commits_applied += report.commits_applied;
9144                    aggregate.segment_rows_applied += report.segment_rows_applied;
9145                    aggregate.bootstrapping = report.bootstrapping.clone();
9146                    aggregate.resets.extend(report.resets.iter().cloned());
9147                    aggregate.revoked.extend(report.revoked.iter().cloned());
9148                    aggregate.failed.extend(report.failed.iter().cloned());
9149                    aggregate.deferred_commits = report.deferred_commits;
9150                    if report.schema_floor.is_some() {
9151                        aggregate.schema_floor = report.schema_floor.clone();
9152                    }
9153                    // §4.5: pull again whenever the response contained
9154                    // commits or segments; resets re-bootstrap; a pending
9155                    // resume token continues paging (§4.7); a raised
9156                    // sync-needed signal covers §6.1 splitBatch remainders
9157                    // (deferred outbox commits push on the next round).
9158                    let more = !report.bootstrapping.is_empty()
9159                        || report.commits_applied > 0
9160                        || report.segment_rows_applied > 0
9161                        || !report.resets.is_empty()
9162                        || self.sync_needed;
9163                    if !more {
9164                        return SyncOutcome::Ok(aggregate);
9165                    }
9166                }
9167            }
9168        }
9169        SyncOutcome::Failed {
9170            error_code: "sync.invalid_request".into(),
9171            message: "sync did not reach idle within the round budget".into(),
9172        }
9173    }
9174
9175    fn process_response(
9176        &mut self,
9177        transport: &mut dyn Transport,
9178        response: Message,
9179        meta: &RequestMeta,
9180    ) -> SyncOutcome {
9181        #[cfg(feature = "bench-internals")]
9182        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
9183        let mut report = SyncReport {
9184            pushed: meta.pushed_ids.len() as u32,
9185            ..SyncReport::default()
9186        };
9187        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
9188            HashMap::new();
9189        let pushed_ids = meta
9190            .pushed_ids
9191            .iter()
9192            .map(String::as_str)
9193            .collect::<HashSet<_>>();
9194        let mut last_final_push_result_id: Option<String> = None;
9195        for frame in &response.frames {
9196            match frame {
9197                Frame::PushResultDetails {
9198                    client_commit_id,
9199                    entries,
9200                } => {
9201                    let details = rejection_details_by_commit
9202                        .entry(client_commit_id.clone())
9203                        .or_default();
9204                    for entry in entries {
9205                        let parsed = match RejectionDetails::parse(&entry.details.0) {
9206                            Ok(value) => value,
9207                            Err(message) => {
9208                                return SyncOutcome::Failed {
9209                                    error_code: "sync.invalid_request".to_owned(),
9210                                    message,
9211                                };
9212                            }
9213                        };
9214                        details.insert(entry.op_index, parsed);
9215                    }
9216                }
9217                Frame::PushResult {
9218                    client_commit_id,
9219                    status,
9220                    results,
9221                    ..
9222                } if pushed_ids.contains(client_commit_id.as_str())
9223                    && Self::push_result_is_final(*status, results) =>
9224                {
9225                    last_final_push_result_id = Some(client_commit_id.clone());
9226                }
9227                _ => {}
9228            }
9229        }
9230        let mut frames = response.frames.into_iter().peekable();
9231        if response.wire_version < 2 {
9232            return SyncOutcome::Failed {
9233                error_code: "client.invalid_host_response".to_owned(),
9234                message: "server response does not carry wire version 2 log-epoch state".to_owned(),
9235            };
9236        }
9237        match frames.next() {
9238            Some(Frame::RespHeader {
9239                required_schema_version,
9240                latest_schema_version,
9241                log_epoch,
9242                reset_required,
9243            }) => {
9244                let Some(log_epoch) = log_epoch else {
9245                    return SyncOutcome::Failed {
9246                        error_code: "client.invalid_host_response".to_owned(),
9247                        message: "response header omits logEpoch".to_owned(),
9248                    };
9249                };
9250                let Some(reset_required) = reset_required else {
9251                    return SyncOutcome::Failed {
9252                        error_code: "client.invalid_host_response".to_owned(),
9253                        message: "response header omits resetRequired".to_owned(),
9254                    };
9255                };
9256                if let Some(required) = required_schema_version {
9257                    // §1.6 schema-floor response: nothing else is processed;
9258                    // stop syncing and surface the upgrade requirement.
9259                    let floor = SchemaFloor {
9260                        required_schema_version: Some(required),
9261                        latest_schema_version,
9262                    };
9263                    self.set_schema_floor(Some(floor.clone()));
9264                    report.schema_floor = Some(floor);
9265                    return SyncOutcome::Ok(report);
9266                }
9267                if reset_required {
9268                    if frames.next().is_some() {
9269                        return SyncOutcome::Failed {
9270                            error_code: "client.invalid_host_response".to_owned(),
9271                            message: "log-epoch reset response contains body frames".to_owned(),
9272                        };
9273                    }
9274                    match self.run_log_epoch_reset(&log_epoch) {
9275                        Ok(resets) => {
9276                            report.resets = resets;
9277                            return SyncOutcome::Ok(report);
9278                        }
9279                        Err(message) => {
9280                            return SyncOutcome::Failed {
9281                                error_code: "sync.local_corrupt".to_owned(),
9282                                message,
9283                            };
9284                        }
9285                    }
9286                }
9287                if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
9288                    return SyncOutcome::Failed {
9289                        error_code: "client.invalid_host_response".to_owned(),
9290                        message: "server changed logEpoch without requiring a reset".to_owned(),
9291                    };
9292                }
9293            }
9294            _ => {
9295                return SyncOutcome::Failed {
9296                    error_code: "sync.invalid_request".to_owned(),
9297                    message: "response does not start with RESP_HEADER".to_owned(),
9298                };
9299            }
9300        }
9301
9302        let mut failure: Option<(String, String)> = None;
9303        while let Some(frame) = frames.next() {
9304            match frame {
9305                frame @ Frame::PushResult { .. } => {
9306                    let successful = matches!(
9307                        &frame,
9308                        Frame::PushResult {
9309                            status: PushStatus::Applied | PushStatus::Cached,
9310                            ..
9311                        }
9312                    );
9313                    let mut results = vec![frame];
9314                    if successful {
9315                        while matches!(
9316                            frames.peek(),
9317                            Some(Frame::PushResult {
9318                                status: PushStatus::Applied | PushStatus::Cached,
9319                                ..
9320                            })
9321                        ) {
9322                            if let Some(next) = frames.next() {
9323                                results.push(next);
9324                            }
9325                        }
9326                    }
9327                    if self
9328                        .handle_push_results(
9329                            &results,
9330                            &pushed_ids,
9331                            &rejection_details_by_commit,
9332                            &mut report,
9333                            last_final_push_result_id.as_deref(),
9334                        )
9335                        .is_err()
9336                    {
9337                        failure = Some((
9338                            "client.outcome_persistence_failed".to_owned(),
9339                            "local commit outcome could not be persisted".to_owned(),
9340                        ));
9341                        break;
9342                    }
9343                }
9344                Frame::PushResultDetails { .. } => {}
9345                Frame::SubStart {
9346                    id,
9347                    status,
9348                    reason_code,
9349                    effective_scopes,
9350                    bootstrap: _,
9351                } => {
9352                    let mut body = Vec::new();
9353                    let mut sub_end: Option<(i64, Option<String>)> = None;
9354                    for inner in frames.by_ref() {
9355                        match inner {
9356                            Frame::SubEnd {
9357                                next_cursor,
9358                                bootstrap_state,
9359                            } => {
9360                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
9361                                break;
9362                            }
9363                            Frame::Unknown { .. } => {}
9364                            other => body.push(other),
9365                        }
9366                    }
9367                    if let Err(SectionError::Abort(code, message)) = self.process_section(
9368                        transport,
9369                        &id,
9370                        status,
9371                        &reason_code,
9372                        effective_scopes,
9373                        body,
9374                        sub_end,
9375                        meta,
9376                        &mut report,
9377                    ) {
9378                        failure = Some((code, message));
9379                        break;
9380                    }
9381                }
9382                Frame::Lease {
9383                    lease_id,
9384                    expires_at_ms,
9385                } => {
9386                    // §7.3.5: persist the opaque lease; a fresh lease clears
9387                    // any prior lease error (the outage/revocation is over).
9388                    self.set_lease_state(Some(LeaseState {
9389                        lease_id: Some(lease_id),
9390                        expires_at_ms: Some(expires_at_ms),
9391                        error_code: None,
9392                    }));
9393                }
9394                Frame::Error { code, message, .. } => {
9395                    // §1.6: the whole request failed; already-completed
9396                    // subscriptions keep their applied data and cursors.
9397                    failure = Some((code, message));
9398                    break;
9399                }
9400                Frame::Unknown { .. } => {}
9401                _ => {
9402                    failure = Some((
9403                        "sync.invalid_request".to_owned(),
9404                        "unexpected frame in response".to_owned(),
9405                    ));
9406                    break;
9407                }
9408            }
9409        }
9410
9411        // §7.1: reconcile the visible overlay once at the response boundary
9412        // after all outbox acknowledgements and server data applied so a
9413        // batch of PUSH_RESULT frames cannot trigger repeated full-table
9414        // rebuilds. This also covers a round that aborted mid-way.
9415        if self.overlay_dirty.get() {
9416            self.rebuild_overlay();
9417        }
9418        if let Some((error_code, message)) = failure {
9419            return SyncOutcome::Failed {
9420                error_code,
9421                message,
9422            };
9423        }
9424        // §4.8 E1: the push half may have drained commits that pinned rows of
9425        // a shrunk window unit — retry any deferred evictions now.
9426        if let Err(message) = self.drain_pending_evictions() {
9427            return SyncOutcome::Failed {
9428                error_code: "storage.failed".into(),
9429                message,
9430            };
9431        }
9432        self.ack_after_pull(transport);
9433        // §7.4.5: the reset is over once the first post-reset pull round
9434        // leaves no subscription mid-bootstrap — the tables are rebuilt.
9435        if self.upgrading && report.bootstrapping.is_empty() {
9436            self.set_upgrading(false);
9437        }
9438        SyncOutcome::Ok(report)
9439    }
9440
9441    // -- push results (§6.3, §7.2) ------------------------------------------------
9442
9443    fn handle_push_results(
9444        &mut self,
9445        frames: &[Frame],
9446        pushed_ids: &HashSet<&str>,
9447        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
9448        report: &mut SyncReport,
9449        last_final_push_result_id: Option<&str>,
9450    ) -> Result<(), String> {
9451        self.begin_observation("syncular_push_results")?;
9452        let conflict_count = self.conflicts.len();
9453        let rejection_count = self.rejections.len();
9454        let applied_count = report.applied.len();
9455        let rejected_count = report.rejected.len();
9456        let retryable_count = report.retryable.len();
9457        let was_dirty = self.overlay_dirty.get();
9458        let mut removed = Vec::new();
9459        let mut batch = ChangeAccumulator::default();
9460        let persisted = (|| {
9461            for frame in frames {
9462                let Frame::PushResult {
9463                    client_commit_id,
9464                    status,
9465                    results,
9466                    ..
9467                } = frame
9468                else {
9469                    return Err("unexpected acknowledgement frame".to_owned());
9470                };
9471                if !pushed_ids.contains(client_commit_id.as_str()) {
9472                    continue;
9473                }
9474                let Some(index) = self
9475                    .outbox
9476                    .iter()
9477                    .position(|commit| commit.client_commit_id == *client_commit_id)
9478                else {
9479                    continue;
9480                };
9481                let status = *status;
9482                let rejection_details = rejection_details_by_commit.get(client_commit_id);
9483                let operations = self.outbox[index].ops.clone();
9484                match status {
9485                    PushStatus::Applied | PushStatus::Cached => {
9486                        // §7.2: a lost ack replays as `cached` — proceed as if the
9487                        // ack had arrived.
9488                        let journal_results = results
9489                            .iter()
9490                            .map(|result| {
9491                                let op_index = match result {
9492                                    OpResult::Applied { op_index }
9493                                    | OpResult::Conflict { op_index, .. }
9494                                    | OpResult::Error { op_index, .. } => *op_index,
9495                                };
9496                                CommitOperationOutcome::Applied { op_index }
9497                            })
9498                            .collect::<Vec<_>>();
9499                        let outcome_status = if status == PushStatus::Applied {
9500                            CommitOutcomeStatus::Applied
9501                        } else {
9502                            CommitOutcomeStatus::Cached
9503                        };
9504                        let persisted = self
9505                            .persist_commit_outcome(
9506                                client_commit_id,
9507                                outcome_status,
9508                                &journal_results,
9509                                None,
9510                            )
9511                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
9512                        persisted?;
9513                        self.overlay_dirty.set(true);
9514                        batch.status = true;
9515                        batch.outcomes = true;
9516                    }
9517                    PushStatus::Rejected => {
9518                        if results.iter().any(|result| {
9519                            matches!(
9520                                result,
9521                                OpResult::Error {
9522                                    code,
9523                                    retryable: true,
9524                                    ..
9525                                } if code == "sync.idempotency_cache_miss"
9526                            )
9527                        }) {
9528                            // §6.3/§7.2: a serving failure, not an outcome — keep the
9529                            // exact commit queued for an identical retry.
9530                            report.retryable.push(client_commit_id.to_owned());
9531                            continue;
9532                        }
9533
9534                        let mut journal_results = Vec::with_capacity(results.len());
9535                        let mut conflicts = Vec::new();
9536                        let mut rejections = Vec::new();
9537                        for result in results {
9538                            match result {
9539                                OpResult::Applied { op_index } => {
9540                                    journal_results.push(CommitOperationOutcome::Applied {
9541                                        op_index: *op_index,
9542                                    });
9543                                }
9544                                OpResult::Conflict {
9545                                    op_index,
9546                                    code,
9547                                    message,
9548                                    server_version,
9549                                    server_row,
9550                                    conflict_columns: conflict_columns_bitmap,
9551                                } => {
9552                                    let operation = operations
9553                                        .get(*op_index as usize)
9554                                        .map(CommitOperation::from);
9555                                    let (table, row_id) = operation
9556                                        .as_ref()
9557                                        .map(|op| (op.table.clone(), op.row_id.clone()))
9558                                        .unwrap_or_default();
9559                                    let server_row_json = self
9560                                        .schema
9561                                        .table(&table)
9562                                        .and_then(|t| {
9563                                            decode_row_bytes(t, server_row, &self.encryption)
9564                                                .ok()
9565                                                .map(|row| (t, row))
9566                                        })
9567                                        .map(|(t, row)| {
9568                                            let mut map = Map::new();
9569                                            for (i, column) in t.columns.iter().enumerate() {
9570                                                map.insert(
9571                                                    column.name.clone(),
9572                                                    column_value_to_json(
9573                                                        row.get(i).unwrap_or(&None),
9574                                                    ),
9575                                                );
9576                                            }
9577                                            map
9578                                        })
9579                                        .unwrap_or_default();
9580                                    // §6.3: decode the presence-layout bitmap
9581                                    // into the marked column names.
9582                                    let conflict_columns = self
9583                                        .schema
9584                                        .table(&table)
9585                                        .map(|t| {
9586                                            t.columns
9587                                                .iter()
9588                                                .enumerate()
9589                                                .filter(|(index, _)| {
9590                                                    conflict_columns_bitmap
9591                                                        .get(index / 8)
9592                                                        .is_some_and(|byte| {
9593                                                            byte & (1 << (index % 8)) != 0
9594                                                        })
9595                                                })
9596                                                .map(|(_, column)| column.name.clone())
9597                                                .collect::<Vec<_>>()
9598                                        })
9599                                        .unwrap_or_default();
9600                                    let conflict = ConflictRecord {
9601                                        client_commit_id: client_commit_id.to_owned(),
9602                                        op_index: *op_index,
9603                                        table,
9604                                        row_id,
9605                                        code: code.clone(),
9606                                        message: message.clone(),
9607                                        server_version: *server_version,
9608                                        server_row: server_row_json,
9609                                        conflict_columns,
9610                                        operation,
9611                                    };
9612                                    journal_results.push(CommitOperationOutcome::Conflict {
9613                                        conflict: conflict.clone(),
9614                                    });
9615                                    conflicts.push(conflict);
9616                                }
9617                                OpResult::Error {
9618                                    op_index,
9619                                    code,
9620                                    message,
9621                                    retryable,
9622                                } => {
9623                                    let rejection = RejectionRecord {
9624                                        client_commit_id: client_commit_id.to_owned(),
9625                                        op_index: *op_index,
9626                                        code: code.clone(),
9627                                        message: message.clone(),
9628                                        retryable: *retryable,
9629                                        details: rejection_details
9630                                            .and_then(|details| details.get(op_index))
9631                                            .cloned(),
9632                                        operation: operations
9633                                            .get(*op_index as usize)
9634                                            .map(CommitOperation::from),
9635                                    };
9636                                    journal_results.push(CommitOperationOutcome::Error {
9637                                        rejection: rejection.clone(),
9638                                    });
9639                                    rejections.push(rejection);
9640                                }
9641                            }
9642                        }
9643                        let outcome_status = if conflicts.is_empty() {
9644                            CommitOutcomeStatus::Rejected
9645                        } else {
9646                            CommitOutcomeStatus::Conflict
9647                        };
9648                        let persisted = self
9649                            .persist_commit_outcome(
9650                                client_commit_id,
9651                                outcome_status,
9652                                &journal_results,
9653                                Some(&operations),
9654                            )
9655                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
9656                        persisted?;
9657                        batch.conflicts = !conflicts.is_empty();
9658                        batch.rejections = !rejections.is_empty();
9659                        batch.status = true;
9660                        batch.outcomes = true;
9661                        self.conflicts.extend(conflicts);
9662                        self.rejections.extend(rejections);
9663                        self.overlay_dirty.set(true);
9664                    }
9665                }
9666                removed.push((index, self.outbox.remove(index)));
9667                if batch.status {
9668                    for operation in &operations {
9669                        if !self.record_row_scopes(
9670                            &mut batch,
9671                            &operation.table,
9672                            &operation.row_id,
9673                            false,
9674                        ) {
9675                            batch.table(&operation.table);
9676                        }
9677                    }
9678                }
9679                match status {
9680                    PushStatus::Applied | PushStatus::Cached => {
9681                        report.applied.push(client_commit_id.to_owned())
9682                    }
9683                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
9684                }
9685            }
9686            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)) {
9687                self.prune_commit_outcomes()?;
9688            }
9689            self.finish_observation("syncular_push_results", batch)
9690        })();
9691        if let Err(error) = persisted {
9692            self.rollback_observation("syncular_push_results");
9693            for (index, commit) in removed.into_iter().rev() {
9694                self.outbox.insert(index, commit);
9695            }
9696            self.conflicts.truncate(conflict_count);
9697            self.rejections.truncate(rejection_count);
9698            self.overlay_dirty.set(was_dirty);
9699            report.applied.truncate(applied_count);
9700            report.rejected.truncate(rejected_count);
9701            report.retryable.truncate(retryable_count);
9702            return Err(error);
9703        }
9704        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
9705        Ok(())
9706    }
9707
9708    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
9709        status != PushStatus::Rejected
9710            || !results.iter().any(|result| {
9711                matches!(
9712                    result,
9713                    OpResult::Error {
9714                        code,
9715                        retryable: true,
9716                        ..
9717                    } if code == "sync.idempotency_cache_miss"
9718                )
9719            })
9720    }
9721
9722    // -- subscription sections ------------------------------------------------------
9723
9724    #[allow(clippy::too_many_arguments)]
9725    fn process_section(
9726        &mut self,
9727        transport: &mut dyn Transport,
9728        id: &str,
9729        status: SubStatus,
9730        reason_code: &str,
9731        effective_scopes: Vec<(String, Vec<String>)>,
9732        body: Vec<Frame>,
9733        sub_end: Option<(i64, Option<String>)>,
9734        meta: &RequestMeta,
9735        report: &mut SyncReport,
9736    ) -> Result<(), SectionError> {
9737        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
9738            return Ok(()); // unknown echo: ignore
9739        };
9740        if status != SubStatus::Active && sub_end.is_none() {
9741            return Err(SectionError::Abort(
9742                "sync.invalid_request".into(),
9743                "subscription section without SUB_END".into(),
9744            ));
9745        }
9746        match status {
9747            SubStatus::Revoked => {
9748                self.begin_observation("syncular_revocation")
9749                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
9750                let previous = self.subs[sub_index].clone();
9751                let was_dirty = self.overlay_dirty.get();
9752                let previous_outbox = self.outbox.clone();
9753                let rejection_count = self.rejections.len();
9754                let revoked_count = report.revoked.len();
9755                let failed_count = report.failed.len();
9756                let result =
9757                    (|| {
9758                        let mut batch = ChangeAccumulator::default();
9759                        let registered = self.window_unit_by_sub_id(id);
9760                        // §3.3: stop pulling, purge exactly the last effective grant.
9761                        let (table, effective) = {
9762                            let sub = &self.subs[sub_index];
9763                            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
9764                        };
9765                        let purged = self.purge_scope_rows(&table, &effective);
9766                        match purged {
9767                            Ok(()) => {
9768                                self.record_scope_map(&mut batch, &table, &effective);
9769                                let sub = &mut self.subs[sub_index];
9770                                sub.state = SubState::Revoked;
9771                                sub.reason_code = Some(if reason_code.is_empty() {
9772                                    "sync.scope_revoked".to_owned()
9773                                } else {
9774                                    reason_code.to_owned()
9775                                });
9776                                report.revoked.push(id.to_owned());
9777                                let doomed_effective = effective;
9778                                let sub_table = table;
9779                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
9780                                    |message| SectionError::Abort("storage.failed".into(), message),
9781                                )?;
9782                                let dropped = self
9783                                    .drop_doomed_outbox(&sub_table, &doomed_effective)
9784                                    .map_err(|message| {
9785                                        SectionError::Abort("storage.failed".to_owned(), message)
9786                                    })?;
9787                                if dropped {
9788                                    batch.status = true;
9789                                    batch.rejections = true;
9790                                    batch.outcomes = true;
9791                                }
9792                                // §5.9.7 B2: revocation deletes now-unauthorized blob
9793                                // bodies (evicted ≠ revoked).
9794                                self.delete_unreferenced_cached_blobs();
9795                            }
9796                            Err(()) => {
9797                                // §3.3 fail closed: no local mapping — never clear by
9798                                // approximation; fatal configuration error.
9799                                let sub = &mut self.subs[sub_index];
9800                                sub.state = SubState::Failed;
9801                                sub.reason_code = Some("sync.scope_revoked".to_owned());
9802                                report.failed.push(id.to_owned());
9803                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
9804                                    |message| SectionError::Abort("storage.failed".into(), message),
9805                                )?;
9806                            }
9807                        }
9808                        if let Some((base_key, unit)) = registered {
9809                            batch.window(&base_key, &self.subs[sub_index].table, &unit);
9810                        }
9811                        self.rebuild_overlay_if_dirty();
9812                        self.finish_observation("syncular_revocation", batch)
9813                            .map_err(|message| {
9814                                SectionError::Abort("storage.failed".to_owned(), message)
9815                            })?;
9816                        Ok(())
9817                    })();
9818                if result.is_err() {
9819                    self.rollback_observation("syncular_revocation");
9820                    self.subs[sub_index] = previous;
9821                    self.overlay_dirty.set(was_dirty);
9822                    self.outbox = previous_outbox;
9823                    self.rejections.truncate(rejection_count);
9824                    report.revoked.truncate(revoked_count);
9825                    report.failed.truncate(failed_count);
9826                }
9827                result
9828            }
9829            SubStatus::Reset => {
9830                self.begin_observation("syncular_reset")
9831                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
9832                let previous = self.subs[sub_index].clone();
9833                let was_dirty = self.overlay_dirty.get();
9834                let reset_count = report.resets.len();
9835                let result = (|| {
9836                    let mut batch = ChangeAccumulator::default();
9837                    let registered = self.window_unit_by_sub_id(id);
9838                    // §4.6: discard cursor + bootstrap state, keep local rows —
9839                    // reset is a staleness signal, not a purge signal.
9840                    let sub = &mut self.subs[sub_index];
9841                    sub.cursor = -1;
9842                    sub.bootstrap_state = None;
9843                    report.resets.push(id.to_owned());
9844                    self.persist_sub(&self.subs[sub_index].clone())
9845                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9846                    if let Some((base_key, unit)) = registered {
9847                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
9848                    }
9849                    self.finish_observation("syncular_reset", batch)
9850                        .map_err(|message| {
9851                            SectionError::Abort("storage.failed".to_owned(), message)
9852                        })?;
9853                    Ok(())
9854                })();
9855                if result.is_err() {
9856                    self.rollback_observation("syncular_reset");
9857                    self.subs[sub_index] = previous;
9858                    self.overlay_dirty.set(was_dirty);
9859                    report.resets.truncate(reset_count);
9860                }
9861                result
9862            }
9863            SubStatus::Active => {
9864                let fresh = meta
9865                    .fresh
9866                    .iter()
9867                    .find(|(fid, _)| fid == id)
9868                    .map(|(_, fresh)| *fresh)
9869                    .unwrap_or(false);
9870                let previous = self.subs[sub_index].clone();
9871                self.subs[sub_index].effective = Some(effective_scopes);
9872                let outcome = self
9873                    .apply_section_body(transport, sub_index, body, fresh, meta, report)
9874                    .and_then(|()| {
9875                        let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
9876                            SectionError::Abort(
9877                                "sync.invalid_request".into(),
9878                                "subscription section without SUB_END".into(),
9879                            )
9880                        })?;
9881                        self.apply_sub_end(sub_index, cursor, bootstrap_state)
9882                            .map_err(|message| {
9883                                SectionError::Abort("storage.failed".into(), message)
9884                            })
9885                    });
9886                match outcome {
9887                    Ok(()) => {
9888                        if self.subs[sub_index].bootstrap_state.is_some() {
9889                            report.bootstrapping.push(id.to_owned());
9890                        }
9891                        Ok(())
9892                    }
9893                    Err(SectionError::FailClosed) => {
9894                        self.subs[sub_index] = previous.clone();
9895                        self.begin_observation("syncular_section_failure")
9896                            .map_err(|message| {
9897                                SectionError::Abort("storage.failed".into(), message)
9898                            })?;
9899                        let persisted = (|| {
9900                            let mut batch = ChangeAccumulator::default();
9901                            if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
9902                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
9903                            }
9904                            self.subs[sub_index].state = SubState::Failed;
9905                            self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
9906                            self.persist_sub(&self.subs[sub_index])?;
9907                            self.finish_observation("syncular_section_failure", batch)
9908                        })();
9909                        if let Err(message) = persisted {
9910                            self.rollback_observation("syncular_section_failure");
9911                            self.subs[sub_index] = previous;
9912                            return Err(SectionError::Abort("storage.failed".into(), message));
9913                        }
9914                        report.failed.push(id.to_owned());
9915                        Ok(())
9916                    }
9917                    Err(error) => {
9918                        // Frame/block transactions already completed. Preserve their
9919                        // rows and revisions, but leave this subscription cursor unchanged.
9920                        self.subs[sub_index] = previous;
9921                        Err(error)
9922                    }
9923                }
9924            }
9925        }
9926    }
9927
9928    fn apply_sub_end(
9929        &mut self,
9930        sub_index: usize,
9931        cursor: i64,
9932        bootstrap_state: Option<String>,
9933    ) -> Result<(), String> {
9934        #[cfg(feature = "bench-internals")]
9935        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
9936        let previous = self.subs[sub_index].clone();
9937        self.begin_observation("syncular_sub_end")?;
9938        let persisted = (|| {
9939            let mut batch = ChangeAccumulator::default();
9940            let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
9941                && cursor >= 0
9942                && bootstrap_state.is_none();
9943            if completed {
9944                if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
9945                    batch.window(&base_key, &previous.table, &unit);
9946                }
9947            }
9948            let sub = &mut self.subs[sub_index];
9949            sub.cursor = cursor;
9950            sub.bootstrap_state = bootstrap_state;
9951            sub.synced_once = true;
9952            self.persist_sub(&self.subs[sub_index])?;
9953            self.finish_observation("syncular_sub_end", batch)
9954        })();
9955        if persisted.is_err() {
9956            self.rollback_observation("syncular_sub_end");
9957            self.subs[sub_index] = previous;
9958        }
9959        persisted
9960    }
9961
9962    fn apply_section_body(
9963        &mut self,
9964        transport: &mut dyn Transport,
9965        sub_index: usize,
9966        body: Vec<Frame>,
9967        fresh: bool,
9968        meta: &RequestMeta,
9969        report: &mut SyncReport,
9970    ) -> Result<(), SectionError> {
9971        let mut saw_segment = false;
9972        for frame in body {
9973            match frame {
9974                Frame::Commit {
9975                    tables, changes, ..
9976                } => {
9977                    self.apply_commit_frame(&tables, &changes)
9978                        .map_err(|(c, m)| SectionError::Abort(c, m))?;
9979                    report.commits_applied += 1;
9980                }
9981                Frame::SegmentInline { payload } => {
9982                    let segment = decode_rows_segment(&payload)
9983                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
9984                    self.progress.update(|p| {
9985                        p.phase = ProgressPhase::Import;
9986                        p.subscription_id = Some(self.subs[sub_index].id.clone());
9987                        p.table = Some(self.subs[sub_index].table.clone());
9988                        p.segment_id = None;
9989                        p.bytes_received = payload.len() as u64;
9990                        p.bytes_total = Some(payload.len() as u64);
9991                        p.rows_processed = 0;
9992                        p.rows_total =
9993                            Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
9994                    });
9995                    let first = !saw_segment;
9996                    saw_segment = true;
9997                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
9998                    report.segment_rows_applied += applied;
9999                }
10000                Frame::SegmentRef {
10001                    segment_id,
10002                    byte_length,
10003                    media_type,
10004                    table,
10005                    row_count,
10006                    as_of_commit_seq,
10007                    scope_digest,
10008                    row_cursor,
10009                    next_row_cursor,
10010                    url,
10011                    url_expires_at_ms,
10012                    ..
10013                } => {
10014                    // §4.2: reject a descriptor whose mediaType was not
10015                    // advertised — never skip or guess.
10016                    let advertised = match media_type {
10017                        MediaType::Rows => {
10018                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
10019                                || meta.accept & ACCEPT_INLINE_ROWS != 0
10020                        }
10021                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
10022                    };
10023                    if !advertised {
10024                        return Err(SectionError::Abort(
10025                            "sync.invalid_request".to_owned(),
10026                            format!(
10027                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
10028                                media_type.name()
10029                            ),
10030                        ));
10031                    }
10032                    self.progress.update(|p| {
10033                        p.phase = ProgressPhase::Download;
10034                        p.subscription_id = Some(self.subs[sub_index].id.clone());
10035                        p.table = Some(table.clone());
10036                        p.segment_id = Some(segment_id.clone());
10037                        p.bytes_received = 0;
10038                        p.bytes_total = u64::try_from(byte_length).ok();
10039                        p.rows_processed = 0;
10040                        p.rows_total = u64::try_from(row_count).ok();
10041                    });
10042                    let observer = self.progress.clone();
10043                    let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
10044                    let bytes = if let Some(url) = url {
10045                        // §5.4: a url-carrying descriptor MUST be fetched
10046                        // from that URL; failure invalidates the whole
10047                        // descriptor (no fall-through to §5.5 — re-pull
10048                        // recovers, §1.4 rule 5).
10049                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
10050                            return Err(SectionError::Abort(
10051                                "sync.invalid_request".to_owned(),
10052                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
10053                                    .to_owned(),
10054                            ));
10055                        }
10056                        // §5.4: MUST NOT start a fetch at/past expiry.
10057                        if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
10058                            return Err(SectionError::Abort(
10059                                "sync.segment_expired".to_owned(),
10060                                format!(
10061                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
10062                                ),
10063                            ));
10064                        }
10065                        transport
10066                            .fetch_url(&url, &mut on_progress)
10067                            .map_err(|e| SectionError::Abort(e.code, e.message))?
10068                    } else {
10069                        let requested_scopes_json =
10070                            canonical_scope_json(&self.subs[sub_index].requested);
10071                        transport
10072                            .download_segment(
10073                                &SegmentRequest {
10074                                    segment_id: segment_id.clone(),
10075                                    table,
10076                                    requested_scopes_json,
10077                                },
10078                                &mut on_progress,
10079                            )
10080                            .map_err(|e| SectionError::Abort(e.code, e.message))?
10081                    };
10082                    // §5.1: verify the content address before applying.
10083                    on_progress(bytes.len() as u64);
10084                    let digest = Sha256::digest(&bytes);
10085                    let expected = segment_id
10086                        .strip_prefix("sha256:")
10087                        .unwrap_or(segment_id.as_str());
10088                    if bytes_to_hex(&digest) != expected {
10089                        return Err(SectionError::Abort(
10090                            "sync.invalid_request".to_owned(),
10091                            "segment bytes do not match the content address (§5.1)".to_owned(),
10092                        ));
10093                    }
10094                    self.progress.update(|p| p.phase = ProgressPhase::Import);
10095                    if media_type == MediaType::Sqlite {
10096                        // §5.3: images are whole-table — a paged sqlite
10097                        // descriptor is invalid.
10098                        if row_cursor.is_some() || next_row_cursor.is_some() {
10099                            return Err(SectionError::Abort(
10100                                "sync.invalid_request".to_owned(),
10101                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
10102                                    .to_owned(),
10103                            ));
10104                        }
10105                        let first = !saw_segment;
10106                        saw_segment = true;
10107                        let applied = self.apply_sqlite_segment(
10108                            sub_index,
10109                            &bytes,
10110                            fresh && first,
10111                            row_count,
10112                            as_of_commit_seq,
10113                            &scope_digest,
10114                        )?;
10115                        report.segment_rows_applied += applied;
10116                    } else {
10117                        let segment = decode_rows_segment(&bytes).map_err(|e| {
10118                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
10119                        })?;
10120                        let first = row_cursor.is_none();
10121                        saw_segment = true;
10122                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
10123                        report.segment_rows_applied += applied;
10124                    }
10125                }
10126                Frame::Unknown { .. } => {}
10127                Frame::Error { code, message, .. } => {
10128                    return Err(SectionError::Abort(code, message))
10129                }
10130                _ => {
10131                    return Err(SectionError::Abort(
10132                        "sync.invalid_request".to_owned(),
10133                        "unexpected frame inside a subscription section".to_owned(),
10134                    ));
10135                }
10136            }
10137        }
10138        Ok(())
10139    }
10140
10141    // Both pull and realtime frames use the same durable observation boundary.
10142    fn apply_commit_frame(
10143        &mut self,
10144        tables: &[String],
10145        changes: &[ssp2::model::Change],
10146    ) -> Result<(), (String, String)> {
10147        #[cfg(feature = "bench-internals")]
10148        let _phase = self.benchmark_phases.start(Phase::CommitApply);
10149        let was_dirty = self.overlay_dirty.get();
10150        self.begin_observation("syncular_commit")
10151            .map_err(|message| ("storage.failed".into(), message))?;
10152        let applied = (|| {
10153            let mut batch = ChangeAccumulator::default();
10154            self.record_commit_changes(&mut batch, tables, changes);
10155            self.apply_commit_changes(tables, changes)?;
10156            self.rebuild_overlay_if_dirty();
10157            self.finish_observation("syncular_commit", batch)
10158                .map_err(|message| ("storage.failed".into(), message))
10159        })();
10160        if applied.is_err() {
10161            self.rollback_observation("syncular_commit");
10162            self.overlay_dirty.set(was_dirty);
10163        }
10164        applied
10165    }
10166
10167    fn apply_commit_changes(
10168        &mut self,
10169        tables: &[String],
10170        changes: &[ssp2::model::Change],
10171    ) -> Result<(), (String, String)> {
10172        // A clean overlay can reconcile changed primary keys independently
10173        // when their tables have no secondary unique constraints. Such a
10174        // constraint can change whether another row's pending write succeeds,
10175        // so those frames still require the complete FIFO replay below.
10176        let mirror_visible = !self.overlay_dirty.get()
10177            && (self.outbox.is_empty()
10178                || changes.iter().all(|change| {
10179                    tables
10180                        .get(change.table_index as usize)
10181                        .and_then(|name| self.schema.table(name))
10182                        .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
10183                }));
10184        for change in changes {
10185            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
10186                (
10187                    "sync.invalid_request".to_owned(),
10188                    "change tableIndex out of range".to_owned(),
10189                )
10190            })?;
10191            let table = self.schema.table(table_name).ok_or_else(|| {
10192                (
10193                    "sync.schema_mismatch".to_owned(),
10194                    format!("change targets unknown table {table_name:?}"),
10195                )
10196            })?;
10197            match change.op {
10198                Op::Upsert => {
10199                    let payload = change.row.as_ref().ok_or_else(|| {
10200                        (
10201                            "sync.invalid_request".to_owned(),
10202                            "upsert change without row payload".to_owned(),
10203                        )
10204                    })?;
10205                    // §5.11: decrypt encrypted columns on apply.
10206                    let row = {
10207                        #[cfg(feature = "bench-internals")]
10208                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
10209                        decode_row_bytes(table, payload, &self.encryption)
10210                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?
10211                    };
10212                    let version = change.row_version.unwrap_or(0);
10213                    let table_name = table.name.clone();
10214                    self.write_base_row(&table_name, &row, version)
10215                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10216                    if mirror_visible {
10217                        self.write_row(&visible_table(&table_name), &table_name, &row, version)
10218                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10219                    }
10220                }
10221                Op::Delete => {
10222                    self.delete_base_row(table_name, &change.row_id)
10223                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10224                    if mirror_visible {
10225                        let sql = format!(
10226                            "DELETE FROM {} WHERE {}",
10227                            visible_table(table_name),
10228                            row_id_predicate(table)
10229                        );
10230                        self.conn
10231                            .prepare_cached(&sql)
10232                            .and_then(|mut statement| {
10233                                statement.execute(rusqlite::params![change.row_id])
10234                            })
10235                            .map_err(|error| {
10236                                ("sync.invalid_request".to_owned(), error.to_string())
10237                            })?;
10238                    }
10239                }
10240            }
10241        }
10242        if mirror_visible {
10243            if !self.outbox.is_empty() {
10244                let changed_rows: HashSet<(&str, &str)> = changes
10245                    .iter()
10246                    .map(|change| {
10247                        (
10248                            tables[change.table_index as usize].as_str(),
10249                            change.row_id.as_str(),
10250                        )
10251                    })
10252                    .collect();
10253                self.apply_outbox_ops(
10254                    self.outbox
10255                        .iter()
10256                        .flat_map(|commit| &commit.ops)
10257                        .filter(|op| {
10258                            changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
10259                        }),
10260                );
10261            }
10262            self.overlay_dirty.set(false);
10263        }
10264        Ok(())
10265    }
10266
10267    /// §5.6 segment application: validate against the generated schema,
10268    /// clear the grant on a fresh bootstrap's first page (fail closed
10269    /// without a mapping), then replace-or-upsert each row with its
10270    /// segment-carried server version (§5.2).
10271    fn apply_segment(
10272        &mut self,
10273        sub_index: usize,
10274        segment: &RowsSegment,
10275        first_fresh_page: bool,
10276    ) -> Result<u32, SectionError> {
10277        let (sub_table, effective) = {
10278            let sub = &self.subs[sub_index];
10279            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10280        };
10281        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
10282            SectionError::Abort(
10283                "sync.schema_mismatch".to_owned(),
10284                format!("subscription table {sub_table:?} is not in the client schema"),
10285            )
10286        })?;
10287        // §5.2: the column table validates against the generated schema —
10288        // order, names, types, nullability; mismatch is fatal. §5.11: the
10289        // server sends the WIRE types (bytes for an encrypted column), so
10290        // validate against wire_columns.
10291        let matches = segment.table == table.name
10292            && segment.schema_version == self.schema.version
10293            && segment.columns.len() == table.wire_columns.len()
10294            && segment
10295                .columns
10296                .iter()
10297                .zip(table.wire_columns.iter())
10298                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
10299        if !matches {
10300            return Err(SectionError::Abort(
10301                "sync.schema_mismatch".to_owned(),
10302                "segment column table does not match the generated schema (§5.2)".to_owned(),
10303            ));
10304        }
10305        let mut applied = 0u32;
10306        let empty = Vec::new();
10307        let blocks = if segment.blocks.is_empty() {
10308            std::slice::from_ref(&empty)
10309        } else {
10310            &segment.blocks
10311        };
10312        for (index, block) in blocks.iter().enumerate() {
10313            let clear = first_fresh_page && index == 0;
10314            if !clear && block.is_empty() {
10315                continue;
10316            }
10317            let was_dirty = self.overlay_dirty.get();
10318            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
10319                && self
10320                    .outbox
10321                    .iter()
10322                    .flat_map(|commit| &commit.ops)
10323                    .any(|op| op.table == table.name);
10324            let mirror_visible = !reconcile_pending;
10325            self.begin_observation("syncular_segment_block")
10326                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10327            let outcome = (|| {
10328                self.rebuild_overlay_if_dirty();
10329                let mut changed_keys = Vec::new();
10330                let mut batch = ChangeAccumulator::default();
10331                if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
10332                    batch.table(&table.name);
10333                }
10334                if clear {
10335                    self.purge_scope_rows(&table.name, &effective)
10336                        .map_err(|()| SectionError::FailClosed)?;
10337                }
10338                for (row_index, row) in block.iter().enumerate() {
10339                    let decrypted;
10340                    let values = if table.has_encrypted_columns() {
10341                        let mut values = row.values.clone();
10342                        crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
10343                            .map_err(|message| {
10344                            SectionError::Abort("client.decrypt_failed".into(), message)
10345                        })?;
10346                        decrypted = values;
10347                        &decrypted
10348                    } else {
10349                        &row.values
10350                    };
10351                    self.write_base_row(&table.name, values, row.server_version)
10352                        .map_err(|message| {
10353                            SectionError::Abort("sync.invalid_request".into(), message)
10354                        })?;
10355                    if reconcile_pending {
10356                        changed_keys.push(
10357                            owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
10358                                |message| SectionError::Abort("storage.failed".into(), message),
10359                            )?,
10360                        );
10361                    }
10362                    if mirror_visible {
10363                        self.write_row(
10364                            &visible_table(&table.name),
10365                            &table.name,
10366                            values,
10367                            row.server_version,
10368                        )
10369                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10370                    }
10371                    let processed = u64::from(applied) + row_index as u64 + 1;
10372                    if processed.is_multiple_of(1024) {
10373                        self.progress.update(|p| p.rows_processed = processed);
10374                    }
10375                }
10376                if reconcile_pending {
10377                    self.reconcile_imported_rows(&table, &mut changed_keys)
10378                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10379                }
10380                self.apply_outbox_ops(
10381                    self.outbox
10382                        .iter()
10383                        .flat_map(|commit| &commit.ops)
10384                        .filter(|op| op.table == table.name),
10385                );
10386                self.overlay_dirty.set(false);
10387                self.finish_observation("syncular_segment_block", batch)
10388                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
10389            })();
10390            if let Err(error) = outcome {
10391                self.rollback_observation("syncular_segment_block");
10392                self.overlay_dirty.set(was_dirty);
10393                return Err(error);
10394            }
10395            applied += block.len() as u32;
10396            self.progress
10397                .update(|p| p.rows_processed = u64::from(applied));
10398            std::thread::yield_now();
10399        }
10400        self.progress
10401            .update(|p| p.rows_processed = u64::from(applied));
10402        Ok(applied)
10403    }
10404
10405    /// §5.3 sqlite-image application: validate the in-file metadata
10406    /// against the descriptor, validate column names/order against the
10407    /// generated schema, run the §5.6 first-page clear when fresh, then
10408    /// replace-or-upsert every image row with its `_syncular_version`.
10409    /// Mechanics: the image lands in a temp file read through a second
10410    /// rusqlite connection, equivalent to ATTACH + INSERT…SELECT.
10411    /// Each image chunk commits its writes and revision before readers continue.
10412    fn apply_sqlite_segment(
10413        &mut self,
10414        sub_index: usize,
10415        bytes: &[u8],
10416        first_fresh_page: bool,
10417        row_count: i64,
10418        as_of_commit_seq: i64,
10419        scope_digest: &str,
10420    ) -> Result<u32, SectionError> {
10421        let invalid = |detail: &str| {
10422            SectionError::Abort(
10423                "sync.invalid_request".to_owned(),
10424                format!("sqlite segment rejected: {detail} (§5.3)"),
10425            )
10426        };
10427        let (sub_table, effective) = {
10428            let sub = &self.subs[sub_index];
10429            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10430        };
10431        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
10432            SectionError::Abort(
10433                "sync.schema_mismatch".to_owned(),
10434                format!("subscription table {sub_table:?} is not in the client schema"),
10435            )
10436        })?;
10437
10438        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
10439        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
10440        let img = match rusqlite::Connection::open_with_flags(
10441            &path,
10442            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
10443        ) {
10444            Ok(conn) => conn,
10445            Err(_) => {
10446                let _ = std::fs::remove_file(&path);
10447                return Err(invalid("bytes do not open as a SQLite database"));
10448            }
10449        };
10450        let outcome = self.apply_sqlite_image(
10451            &img,
10452            &table,
10453            first_fresh_page,
10454            &effective,
10455            row_count,
10456            as_of_commit_seq,
10457            scope_digest,
10458        );
10459        drop(img);
10460        let _ = std::fs::remove_file(&path);
10461        outcome
10462    }
10463
10464    #[allow(clippy::too_many_arguments)]
10465    fn apply_sqlite_image(
10466        &mut self,
10467        img: &rusqlite::Connection,
10468        table: &crate::schema::TableSchema,
10469        first_fresh_page: bool,
10470        effective: &[(String, Vec<String>)],
10471        row_count: i64,
10472        as_of_commit_seq: i64,
10473        scope_digest: &str,
10474    ) -> Result<u32, SectionError> {
10475        let invalid = |detail: String| {
10476            SectionError::Abort(
10477                "sync.invalid_request".to_owned(),
10478                format!("sqlite segment rejected: {detail} (§5.3)"),
10479            )
10480        };
10481
10482        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
10483        //    one row).
10484        type MetaRow = (i64, String, i64, i64, String, i64, i64);
10485        let meta: MetaRow = img
10486            .query_row(
10487                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
10488                        \"scopeDigest\", \"rowCount\",
10489                        (SELECT count(*) FROM _syncular_segment)
10490                 FROM _syncular_segment",
10491                [],
10492                |row| {
10493                    Ok((
10494                        row.get(0)?,
10495                        row.get(1)?,
10496                        row.get(2)?,
10497                        row.get(3)?,
10498                        row.get(4)?,
10499                        row.get(5)?,
10500                        row.get(6)?,
10501                    ))
10502                },
10503            )
10504            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
10505        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
10506        if meta_count != 1 {
10507            return Err(invalid(format!(
10508                "_syncular_segment must contain exactly one row, found {meta_count}"
10509            )));
10510        }
10511        if format != 1 {
10512            return Err(invalid(format!("format {format}")));
10513        }
10514        if meta_table != table.name {
10515            return Err(invalid(format!("image table {meta_table:?}")));
10516        }
10517        if schema_version != i64::from(self.schema.version) {
10518            return Err(invalid(format!("schemaVersion {schema_version}")));
10519        }
10520        if pin != as_of_commit_seq {
10521            return Err(invalid(format!("asOfCommitSeq {pin}")));
10522        }
10523        if digest != scope_digest {
10524            return Err(invalid("scopeDigest mismatch".to_owned()));
10525        }
10526        if meta_rows != row_count {
10527            return Err(invalid(format!("rowCount {meta_rows}")));
10528        }
10529
10530        // 2. Column names and order vs the generated schema (§5.3 rule 3).
10531        let mut names: Vec<String> = Vec::new();
10532        let mut primary_keys: Vec<String> = Vec::new();
10533        {
10534            let mut stmt = img
10535                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
10536                .map_err(|_| invalid("image data table missing".to_owned()))?;
10537            let mut rows = stmt
10538                .query([])
10539                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
10540            while let Some(row) = rows
10541                .next()
10542                .map_err(|_| invalid("image data table unreadable".to_owned()))?
10543            {
10544                if row.get::<_, i64>(5).map_err(|e| invalid(e.to_string()))? > 0 {
10545                    primary_keys.push(row.get(1).map_err(|e| invalid(e.to_string()))?);
10546                }
10547                names.push(
10548                    row.get::<_, String>(1)
10549                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
10550                );
10551            }
10552        }
10553        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
10554        expected.push("_syncular_version");
10555        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
10556            return Err(SectionError::Abort(
10557                "sync.schema_mismatch".to_owned(),
10558                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
10559            ));
10560        }
10561
10562        if primary_keys != vec![table.primary_key.clone()] {
10563            return Err(invalid(
10564                "image primary key does not match generated schema".into(),
10565            ));
10566        }
10567        let actual_count: i64 = img
10568            .query_row(
10569                &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
10570                [],
10571                |row| row.get(0),
10572            )
10573            .map_err(|e| invalid(e.to_string()))?;
10574        if actual_count != row_count {
10575            return Err(invalid("image row count does not match descriptor".into()));
10576        }
10577        let insert = self.insert_row_sql(&base_table(&table.name), table);
10578        let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
10579        let column_list = names
10580            .iter()
10581            .map(|n| quote_ident(n))
10582            .collect::<Vec<_>>()
10583            .join(", ");
10584        let mut stmt = img
10585            .prepare(&format!(
10586                "SELECT {column_list} FROM {} ORDER BY {}",
10587                quote_ident(&table.name),
10588                quote_ident(&table.primary_key)
10589            ))
10590            .map_err(|e| invalid(e.to_string()))?;
10591        let mut rows = stmt.query([]).map_err(|e| invalid(e.to_string()))?;
10592        let mut applied = 0u32;
10593        loop {
10594            let clear = applied == 0 && first_fresh_page;
10595            let was_dirty = self.overlay_dirty.get();
10596            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
10597                && self
10598                    .outbox
10599                    .iter()
10600                    .flat_map(|commit| &commit.ops)
10601                    .any(|op| op.table == table.name);
10602            let mirror_visible = !reconcile_pending;
10603            self.begin_observation("syncular_image_chunk")
10604                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10605            let result = (|| {
10606                self.rebuild_overlay_if_dirty();
10607                let mut changed_keys = Vec::new();
10608                let mut batch = ChangeAccumulator::default();
10609                if clear {
10610                    if self.scoped_rows_exist(&table.name, effective) {
10611                        batch.table(&table.name);
10612                    }
10613                    self.purge_scope_rows(&table.name, effective)
10614                        .map_err(|()| SectionError::FailClosed)?;
10615                }
10616                let mut processed = 0u32;
10617                {
10618                    let mut ins = self
10619                        .conn
10620                        .prepare_cached(&insert)
10621                        .map_err(|e| invalid(e.to_string()))?;
10622                    let mut mirror = if mirror_visible {
10623                        Some(
10624                            self.conn
10625                                .prepare_cached(&visible_insert)
10626                                .map_err(|e| invalid(e.to_string()))?,
10627                        )
10628                    } else {
10629                        None
10630                    };
10631                    for _ in 0..1024 {
10632                        let Some(row) = rows.next().map_err(|e| invalid(e.to_string()))? else {
10633                            break;
10634                        };
10635                        for (i, column) in table.columns.iter().enumerate() {
10636                            let cell = row.get_ref(i).map_err(|e| invalid(e.to_string()))?;
10637                            let param = image_cell_param(column, cell).map_err(&invalid)?;
10638                            ins.raw_bind_parameter(i + 1, &param)
10639                                .map_err(|e| invalid(e.to_string()))?;
10640                            if let Some(mirror) = &mut mirror {
10641                                mirror
10642                                    .raw_bind_parameter(i + 1, &param)
10643                                    .map_err(|e| invalid(e.to_string()))?;
10644                            }
10645                        }
10646                        let version_index = table.columns.len();
10647                        let version: i64 =
10648                            row.get(version_index).map_err(|e| invalid(e.to_string()))?;
10649                        if version < 1 {
10650                            return Err(invalid("image row version must be positive".into()));
10651                        }
10652                        ins.raw_bind_parameter(version_index + 1, version)
10653                            .map_err(|e| invalid(e.to_string()))?;
10654                        ins.raw_execute().map_err(|e| invalid(e.to_string()))?;
10655                        if let Some(mirror) = &mut mirror {
10656                            mirror
10657                                .raw_bind_parameter(version_index + 1, version)
10658                                .map_err(|e| invalid(e.to_string()))?;
10659                            mirror.raw_execute().map_err(|e| invalid(e.to_string()))?;
10660                        }
10661                        if reconcile_pending {
10662                            changed_keys.push(
10663                                row.get::<_, SqlValue>(table.pk_index)
10664                                    .map_err(|error| invalid(error.to_string()))?,
10665                            );
10666                        }
10667                        processed += 1;
10668                    }
10669                }
10670                if processed > 0 {
10671                    batch.table(&table.name);
10672                    self.overlay_dirty.set(true);
10673                }
10674                if reconcile_pending {
10675                    self.reconcile_imported_rows(table, &mut changed_keys)
10676                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10677                }
10678                self.apply_outbox_ops(
10679                    self.outbox
10680                        .iter()
10681                        .flat_map(|commit| &commit.ops)
10682                        .filter(|op| op.table == table.name),
10683                );
10684                self.overlay_dirty.set(false);
10685                self.finish_observation("syncular_image_chunk", batch)
10686                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10687                Ok(processed)
10688            })();
10689            let processed = match result {
10690                Ok(processed) => processed,
10691                Err(error) => {
10692                    self.rollback_observation("syncular_image_chunk");
10693                    self.overlay_dirty.set(was_dirty);
10694                    return Err(error);
10695                }
10696            };
10697            applied += processed;
10698            self.progress
10699                .update(|p| p.rows_processed = u64::from(applied));
10700            std::thread::yield_now();
10701            if i64::from(applied) == actual_count {
10702                break;
10703            }
10704        }
10705        Ok(applied)
10706    }
10707
10708    /// Restore only imported and pending row identities before FIFO replay. All pending
10709    /// identities participate: a server row can free a unique value for a different edit.
10710    /// The caller owns the chunk transaction and publishes its revision after replay.
10711    fn reconcile_imported_rows(
10712        &self,
10713        table: &TableSchema,
10714        keys: &mut Vec<SqlValue>,
10715    ) -> Result<(), String> {
10716        let visible = visible_table(&table.name);
10717        let base = base_table(&table.name);
10718        let pk = quote_ident(&table.primary_key);
10719        let predicate = row_id_predicate(table);
10720        let mut lookup = self.conn.prepare_cached(&format!(
10721            "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
10722        )).map_err(|error| error.to_string())?;
10723        let mut deleted = HashSet::new();
10724        for op in self
10725            .outbox
10726            .iter()
10727            .flat_map(|commit| &commit.ops)
10728            .filter(|op| op.table == table.name)
10729        {
10730            if op.upsert {
10731                if let Some(values) = &op.values {
10732                    let key = json_to_column_value(
10733                        &table.columns[table.pk_index],
10734                        values.get(&table.primary_key),
10735                    )?;
10736                    keys.push(owned_sql_value(RowParam::Cell(&key))?);
10737                }
10738            } else if deleted.insert(&op.row_id) {
10739                keys.extend(
10740                    lookup
10741                        .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
10742                        .map_err(|error| error.to_string())?
10743                        .collect::<Result<Vec<_>, _>>()
10744                        .map_err(|error| error.to_string())?,
10745                );
10746            }
10747        }
10748        // Delete the complete affected set before restoring any base value, so
10749        // old optimistic unique values cannot collide with the new server rows.
10750        for chunk in keys.chunks(400) {
10751            let holes = vec!["?"; chunk.len()].join(",");
10752            self.conn
10753                .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
10754                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10755                .map_err(|error| error.to_string())?;
10756        }
10757        let updates = table
10758            .columns
10759            .iter()
10760            .map(|column| column.name.as_str())
10761            .chain(std::iter::once("_syncular_version"))
10762            .filter(|name| *name != table.primary_key)
10763            .map(|name| {
10764                let name = quote_ident(name);
10765                format!("{name}=excluded.{name}")
10766            })
10767            .collect::<Vec<_>>()
10768            .join(",");
10769        for chunk in keys.chunks(400) {
10770            let holes = vec!["?"; chunk.len()].join(",");
10771            self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
10772                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10773                .map_err(|error| error.to_string())?;
10774        }
10775        Ok(())
10776    }
10777
10778    // -- application-authorized local purge ---------------------------------------
10779
10780    fn compile_local_data_purge(
10781        &self,
10782        input: &LocalDataPurgeInput,
10783    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
10784        let invalid = |message: String| format!("sync.invalid_request: {message}");
10785        if input.purge_id.is_empty()
10786            || input.purge_id.len() > 128
10787            || !is_local_operation_code_like(&input.purge_id)
10788        {
10789            return Err(invalid(
10790                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
10791            ));
10792        }
10793        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
10794            return Err(invalid(format!(
10795                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
10796            )));
10797        }
10798
10799        let mut deduplicated: BTreeMap<
10800            String,
10801            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
10802        > = BTreeMap::new();
10803        for target in &input.targets {
10804            let table = self.schema.table(&target.table).ok_or_else(|| {
10805                invalid(format!(
10806                    "local purge names unknown table {:?}",
10807                    target.table
10808                ))
10809            })?;
10810            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
10811                return Err(invalid(format!(
10812                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
10813                    target.table
10814                )));
10815            }
10816            let mut selectors = Vec::with_capacity(target.selectors.len());
10817            let mut canonical_selectors = BTreeMap::new();
10818            for (column_name, raw_values) in &target.selectors {
10819                let Some((column_index, column)) = table
10820                    .columns
10821                    .iter()
10822                    .enumerate()
10823                    .find(|(_, column)| column.name == *column_name)
10824                else {
10825                    return Err(invalid(format!(
10826                        "local purge target {:?} names unknown column {:?}",
10827                        target.table, column_name
10828                    )));
10829                };
10830                let encrypted = table
10831                    .encrypted_columns
10832                    .iter()
10833                    .any(|candidate| candidate.index == column_index);
10834                if column.ty != ColumnType::String || encrypted {
10835                    return Err(invalid(format!(
10836                        "local purge selector {:?}.{:?} must be a plaintext string column",
10837                        target.table, column_name
10838                    )));
10839                }
10840                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
10841                    return Err(invalid(format!(
10842                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
10843                        target.table, column_name
10844                    )));
10845                }
10846                let mut values = raw_values.clone();
10847                values.sort();
10848                values.dedup();
10849                if values.iter().any(|value| {
10850                    value.is_empty()
10851                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
10852                        || !is_local_operation_code_like(value)
10853                }) {
10854                    return Err(invalid(format!(
10855                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
10856                    )));
10857                }
10858                selectors.push((column_name.clone(), values.clone()));
10859                canonical_selectors.insert(column_name.clone(), values);
10860            }
10861            let canonical = LocalDataPurgeTarget {
10862                table: target.table.clone(),
10863                selectors: canonical_selectors,
10864            };
10865            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
10866            deduplicated.insert(
10867                key,
10868                (
10869                    CompiledLocalDataPurgeTarget {
10870                        table: target.table.clone(),
10871                        selectors,
10872                    },
10873                    canonical,
10874                ),
10875            );
10876        }
10877        let targets = deduplicated
10878            .values()
10879            .map(|(compiled, _)| compiled.clone())
10880            .collect::<Vec<_>>();
10881        let canonical = deduplicated
10882            .values()
10883            .map(|(_, target)| target.clone())
10884            .collect::<Vec<_>>();
10885        let canonical_plan =
10886            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
10887        Ok((targets, canonical_plan))
10888    }
10889
10890    fn local_purge_base_row_ids(
10891        &self,
10892        targets: &[CompiledLocalDataPurgeTarget],
10893    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
10894        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10895        for target in targets {
10896            let table = self
10897                .schema
10898                .table(&target.table)
10899                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
10900            let mut clauses = Vec::with_capacity(target.selectors.len());
10901            let mut params = Vec::new();
10902            for (column, values) in &target.selectors {
10903                clauses.push(format!(
10904                    "{} IN ({})",
10905                    quote_ident(column),
10906                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
10907                ));
10908                params.extend(values.iter().cloned().map(SqlValue::Text));
10909            }
10910            let sql = format!(
10911                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
10912                quote_ident(&table.primary_key),
10913                base_table(&target.table),
10914                clauses.join(" AND ")
10915            );
10916            let mut statement = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
10917            let rows = statement
10918                .query_map(rusqlite::params_from_iter(params), |row| {
10919                    row.get::<_, String>(0)
10920                })
10921                .map_err(|error| error.to_string())?;
10922            let ids = by_table.entry(target.table.clone()).or_default();
10923            for row in rows {
10924                ids.insert(row.map_err(|error| error.to_string())?);
10925            }
10926        }
10927        Ok(by_table)
10928    }
10929
10930    fn local_purge_values_match(
10931        target: &CompiledLocalDataPurgeTarget,
10932        values: &Map<String, Value>,
10933    ) -> bool {
10934        target.selectors.iter().all(|(column, allowed)| {
10935            values
10936                .get(column)
10937                .and_then(Value::as_str)
10938                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
10939        })
10940    }
10941
10942    /// Apply one host-authorized local security purge. The host MUST gate the
10943    /// corresponding subscriptions first; this primitive owns local SQLite
10944    /// cleanup and never grants/revokes server authority by itself.
10945    pub fn purge_local_data(
10946        &mut self,
10947        input: &LocalDataPurgeInput,
10948    ) -> Result<LocalDataPurgeResult, String> {
10949        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
10950        let meta_key = format!("localPurge:{}", input.purge_id);
10951        let applied_plan = self
10952            .conn
10953            .query_row(
10954                "SELECT value FROM _syncular_meta WHERE key = ?1",
10955                rusqlite::params![meta_key],
10956                |row| row.get::<_, String>(0),
10957            )
10958            .optional()
10959            .map_err(|error| error.to_string())?;
10960        if let Some(applied_plan) = applied_plan {
10961            if applied_plan != canonical_plan {
10962                return Err(format!(
10963                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
10964                    input.purge_id
10965                ));
10966            }
10967            return Ok(LocalDataPurgeResult {
10968                already_applied: true,
10969                purged_rows: 0,
10970                dropped_commits: 0,
10971            });
10972        }
10973
10974        // RFC 0005 D7: the container file cannot be a purge target (its name is
10975        // not in the running schema), so purge drops it as a fixed step, before
10976        // any mirror row is touched and unconditionally.
10977        let _ = self.drop_previous_version();
10978
10979        let prior_outbox = self.outbox.clone();
10980        let prior_rejection_count = self.rejections.len();
10981        let prior_overlay_dirty = self.overlay_dirty.get();
10982        self.begin_observation("syncular_local_purge")?;
10983        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
10984            let row_ids = self.local_purge_base_row_ids(&targets)?;
10985            let doomed = self
10986                .outbox
10987                .iter()
10988                .filter(|commit| {
10989                    commit.ops.iter().any(|operation| {
10990                        let matching_targets = targets
10991                            .iter()
10992                            .filter(|target| target.table == operation.table)
10993                            .collect::<Vec<_>>();
10994                        if matching_targets.is_empty() {
10995                            return false;
10996                        }
10997                        if row_ids
10998                            .get(&operation.table)
10999                            .is_some_and(|ids| ids.contains(&operation.row_id))
11000                        {
11001                            return true;
11002                        }
11003                        operation.values.as_ref().is_some_and(|values| {
11004                            matching_targets
11005                                .iter()
11006                                .any(|target| Self::local_purge_values_match(target, values))
11007                        })
11008                    })
11009                })
11010                .cloned()
11011                .collect::<Vec<_>>();
11012            let doomed_ids = doomed
11013                .iter()
11014                .map(|commit| commit.client_commit_id.clone())
11015                .collect::<BTreeSet<_>>();
11016            let mut batch = ChangeAccumulator::default();
11017            let mut rejections = Vec::new();
11018            for commit in &doomed {
11019                for operation in &commit.ops {
11020                    batch.table(&operation.table);
11021                }
11022                let results = commit
11023                    .ops
11024                    .iter()
11025                    .enumerate()
11026                    .map(|(op_index, operation)| {
11027                        let rejection = RejectionRecord {
11028                            client_commit_id: commit.client_commit_id.clone(),
11029                            op_index: op_index as i32,
11030                            code: "client.local_data_purged".to_owned(),
11031                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
11032                            retryable: false,
11033                            details: None,
11034                            operation: Some(CommitOperation::from(operation)),
11035                        };
11036                        rejections.push(rejection.clone());
11037                        CommitOperationOutcome::Error { rejection }
11038                    })
11039                    .collect::<Vec<_>>();
11040                self.persist_commit_outcome(
11041                    &commit.client_commit_id,
11042                    CommitOutcomeStatus::Rejected,
11043                    &results,
11044                    Some(&commit.ops),
11045                )?;
11046                self.delete_outbox_persisted(&commit.client_commit_id)?;
11047            }
11048            if !doomed.is_empty() {
11049                self.outbox
11050                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
11051                self.rejections.extend(rejections);
11052                self.prune_commit_outcomes()?;
11053                self.overlay_dirty.set(true);
11054                batch.status = true;
11055                batch.rejections = true;
11056                batch.outcomes = true;
11057            }
11058
11059            let mut purged_rows = 0usize;
11060            for (table, ids) in &row_ids {
11061                if ids.is_empty() {
11062                    continue;
11063                }
11064                batch.table(table);
11065                for row_id in ids {
11066                    self.delete_base_row(table, row_id)?;
11067                    purged_rows += 1;
11068                }
11069            }
11070            self.rebuild_overlay_if_dirty();
11071            self.delete_unreferenced_cached_blobs();
11072            self.conn
11073                .execute(
11074                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11075                    rusqlite::params![meta_key, canonical_plan],
11076                )
11077                .map_err(|error| error.to_string())?;
11078            Ok((
11079                batch,
11080                LocalDataPurgeResult {
11081                    already_applied: false,
11082                    purged_rows,
11083                    dropped_commits: doomed.len(),
11084                },
11085            ))
11086        })();
11087        let (batch, result) = match applied {
11088            Ok(value) => value,
11089            Err(error) => {
11090                self.rollback_observation("syncular_local_purge");
11091                self.outbox = prior_outbox;
11092                self.rejections.truncate(prior_rejection_count);
11093                self.overlay_dirty.set(prior_overlay_dirty);
11094                return Err(error);
11095            }
11096        };
11097        if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
11098            self.rollback_observation("syncular_local_purge");
11099            self.outbox = prior_outbox;
11100            self.rejections.truncate(prior_rejection_count);
11101            self.overlay_dirty.set(prior_overlay_dirty);
11102            return Err(error);
11103        }
11104        Ok(result)
11105    }
11106
11107    /// Application-authorized recovery of the replicated projection. Unlike a
11108    /// local security purge, this retains the entire outbox, device identity,
11109    /// lease, outcomes, subscription registrations, and protected bookkeeping.
11110    /// The projection reset, subscription rewind, optimistic replay, and
11111    /// idempotency marker share one savepoint for interruption safety.
11112    pub fn rebootstrap_local_data(
11113        &mut self,
11114        input: &LocalDataRebootstrapInput,
11115    ) -> Result<LocalDataRebootstrapResult, String> {
11116        if input.rebootstrap_id.is_empty()
11117            || input.rebootstrap_id.len() > 128
11118            || !is_local_operation_code_like(&input.rebootstrap_id)
11119        {
11120            return Err(
11121                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
11122                    .to_owned(),
11123            );
11124        }
11125        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
11126        if let Some(persisted) = self.get_meta_strict(&meta_key)? {
11127            let (retained_commits, reset_subscriptions) =
11128                decode_local_rebootstrap_receipt(&persisted)?;
11129            return Ok(LocalDataRebootstrapResult {
11130                already_applied: true,
11131                retained_commits,
11132                reset_subscriptions,
11133            });
11134        }
11135        if self.stopped || self.schema_floor.is_some() {
11136            return Err(
11137                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
11138                    .to_owned(),
11139            );
11140        }
11141
11142        let retained_commits = self.outbox.len();
11143        let reset_subscriptions = self.subs.len();
11144        let prior_subs = self.subs.clone();
11145        let prior_upgrading = self.upgrading;
11146        let prior_stopped = self.stopped;
11147        let prior_schema_floor = self.schema_floor.clone();
11148        let prior_overlay_dirty = self.overlay_dirty.get();
11149        let prior_sync_needed = self.sync_needed;
11150        let prior_sync_intents = self.sync_intent_queue.clone();
11151        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
11152
11153        self.begin_observation("syncular_local_rebootstrap")?;
11154        let mut batch = ChangeAccumulator::default();
11155        let applied = (|| -> Result<(), String> {
11156            self.run_schema_reset_observed(&mut batch, false, false)?;
11157            self.conn
11158                .execute(
11159                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11160                    rusqlite::params![meta_key, receipt],
11161                )
11162                .map_err(|error| error.to_string())?;
11163            self.sync_needed = true;
11164            self.sync_intent_queue.push_back(SyncIntent::Interactive);
11165            batch.status = true;
11166            Ok(())
11167        })();
11168        if let Err(error) = applied {
11169            self.rollback_observation("syncular_local_rebootstrap");
11170            self.subs = prior_subs;
11171            self.upgrading = prior_upgrading;
11172            self.stopped = prior_stopped;
11173            self.schema_floor = prior_schema_floor;
11174            self.overlay_dirty.set(prior_overlay_dirty);
11175            self.sync_needed = prior_sync_needed;
11176            self.sync_intent_queue = prior_sync_intents;
11177            return Err(error);
11178        }
11179        if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
11180            self.rollback_observation("syncular_local_rebootstrap");
11181            self.subs = prior_subs;
11182            self.upgrading = prior_upgrading;
11183            self.stopped = prior_stopped;
11184            self.schema_floor = prior_schema_floor;
11185            self.overlay_dirty.set(prior_overlay_dirty);
11186            self.sync_needed = prior_sync_needed;
11187            self.sync_intent_queue = prior_sync_intents;
11188            return Err(error);
11189        }
11190
11191        Ok(LocalDataRebootstrapResult {
11192            already_applied: false,
11193            retained_commits,
11194            reset_subscriptions,
11195        })
11196    }
11197
11198    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
11199
11200    /// Delete base rows matching the effective scopes; `Err(())` = no local
11201    /// scope-column mapping for a key (the fail-closed case).
11202    fn purge_scope_rows(
11203        &mut self,
11204        table_name: &str,
11205        effective: &[(String, Vec<String>)],
11206    ) -> Result<(), ()> {
11207        if effective.is_empty() {
11208            return Ok(());
11209        }
11210        let table = self.schema.table(table_name).ok_or(())?.clone();
11211        let mut clauses = Vec::new();
11212        let mut params: Vec<SqlValue> = Vec::new();
11213        for (variable, values) in effective {
11214            let column = table.scope_column(variable).ok_or(())?;
11215            let placeholders: Vec<String> = values
11216                .iter()
11217                .map(|v| {
11218                    params.push(SqlValue::Text(v.clone()));
11219                    "?".to_owned()
11220                })
11221                .collect();
11222            clauses.push(format!(
11223                "{} IN ({})",
11224                quote_ident(column),
11225                placeholders.join(", ")
11226            ));
11227        }
11228        let sql = format!(
11229            "DELETE FROM {} WHERE {}",
11230            base_table(table_name),
11231            clauses.join(" AND ")
11232        );
11233        self.overlay_dirty.set(true);
11234        self.conn
11235            .execute(&sql, rusqlite::params_from_iter(&params))
11236            .map_err(|_| ())?;
11237        self.conn
11238            .execute(
11239                &format!(
11240                    "DELETE FROM {} WHERE {}",
11241                    visible_table(table_name),
11242                    clauses.join(" AND ")
11243                ),
11244                rusqlite::params_from_iter(&params),
11245            )
11246            .map_err(|_| ())?;
11247        Ok(())
11248    }
11249
11250    /// §3.3: drop pending commits whose upserts provably land in the
11251    /// revoked effective scopes — whole-commit, never per-operation.
11252    fn drop_doomed_outbox(
11253        &mut self,
11254        table_name: &str,
11255        effective: &[(String, Vec<String>)],
11256    ) -> Result<bool, String> {
11257        if effective.is_empty() {
11258            return Ok(false);
11259        }
11260        let Some(table) = self.schema.table(table_name).cloned() else {
11261            return Ok(false);
11262        };
11263        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
11264        for (variable, values) in effective {
11265            match table.scope_column(variable) {
11266                Some(column) => mappings.push((column, values)),
11267                None => return Ok(false), // not provable without a mapping
11268            }
11269        }
11270        let doomed: Vec<OutboxCommit> = self
11271            .outbox
11272            .iter()
11273            .filter(|commit| {
11274                commit.ops.iter().any(|op| {
11275                    op.upsert
11276                        && op.table == table_name
11277                        && op.values.as_ref().is_some_and(|values| {
11278                            mappings
11279                                .iter()
11280                                .all(|(column, allowed)| match values.get(*column) {
11281                                    Some(Value::String(s)) => allowed.contains(s),
11282                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
11283                                    _ => false,
11284                                })
11285                        })
11286                })
11287            })
11288            .cloned()
11289            .collect();
11290        if doomed.is_empty() {
11291            return Ok(false);
11292        }
11293        let mut rejections = Vec::new();
11294        for commit in &doomed {
11295            let results = commit
11296                .ops
11297                .iter()
11298                .enumerate()
11299                .map(|(op_index, operation)| {
11300                    let rejection = RejectionRecord {
11301                        client_commit_id: commit.client_commit_id.clone(),
11302                        op_index: op_index as i32,
11303                        code: "sync.scope_revoked".to_owned(),
11304                        message: "the commit was dropped because its effective scope was revoked"
11305                            .to_owned(),
11306                        retryable: false,
11307                        details: None,
11308                        operation: Some(CommitOperation::from(operation)),
11309                    };
11310                    rejections.push(rejection.clone());
11311                    CommitOperationOutcome::Error { rejection }
11312                })
11313                .collect::<Vec<_>>();
11314            self.persist_commit_outcome(
11315                &commit.client_commit_id,
11316                CommitOutcomeStatus::Rejected,
11317                &results,
11318                Some(&commit.ops),
11319            )?;
11320            self.delete_outbox_persisted(&commit.client_commit_id)?;
11321        }
11322        self.prune_commit_outcomes()?;
11323        let doomed_ids = doomed
11324            .iter()
11325            .map(|commit| commit.client_commit_id.as_str())
11326            .collect::<BTreeSet<_>>();
11327        self.outbox
11328            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
11329        self.rejections.extend(rejections);
11330        self.overlay_dirty.set(true);
11331        Ok(true)
11332    }
11333
11334    // -- blobs (§5.9) ----------------------------------------------------------------
11335
11336    /// §5.9.7: hash bytes into the content address, cache them, queue the
11337    /// upload (flushed before the next push, B4). Returns the canonical
11338    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
11339    /// column value.
11340    pub fn upload_blob(
11341        &mut self,
11342        bytes: &[u8],
11343        media_type: Option<String>,
11344        name: Option<String>,
11345    ) -> Result<Value, String> {
11346        let blob_id = blob_id_for(bytes);
11347        let now = self.clock_now_ms();
11348        let tx = self.conn.transaction().map_err(|e| e.to_string())?;
11349        tx.execute(
11350            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
11351             ON CONFLICT(blob_id) DO NOTHING",
11352            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
11353        )
11354        .map_err(|e| e.to_string())?;
11355        tx.execute(
11356            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
11357            rusqlite::params![blob_id, media_type, now],
11358        )
11359        .map_err(|e| e.to_string())?;
11360        tx.commit().map_err(|e| e.to_string())?;
11361        self.enforce_blob_cache_cap();
11362        let mut obj = Map::new();
11363        obj.insert("blobId".to_owned(), Value::from(blob_id));
11364        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
11365        if let Some(mt) = media_type {
11366            obj.insert("mediaType".to_owned(), Value::from(mt));
11367        }
11368        if let Some(n) = name {
11369            obj.insert("name".to_owned(), Value::from(n));
11370        }
11371        Ok(Value::Object(obj))
11372    }
11373
11374    /// §5.9.7: resolve an owned blob body without driver encoding. A
11375    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
11376    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
11377    pub fn fetch_blob_bytes(
11378        &mut self,
11379        transport: &mut dyn Transport,
11380        blob_id_or_ref: &str,
11381    ) -> Result<FetchedBlob, (String, String)> {
11382        let simple = |m: String| ("client.failed".to_owned(), m);
11383        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
11384            blob_id_or_ref.to_owned()
11385        } else {
11386            let value: Value = serde_json::from_str(blob_id_or_ref)
11387                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
11388            value
11389                .get("blobId")
11390                .and_then(Value::as_str)
11391                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
11392                .to_owned()
11393        };
11394        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
11395            return Ok(cached);
11396        }
11397        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
11398        // blob.not_found) verbatim so the harness can assert on it. The
11399        // authorized endpoint serves bytes inline OR (always-issue, presign
11400        // configured) a signed url the client fetches directly — no host auth,
11401        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
11402        #[cfg(feature = "bench-internals")]
11403        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
11404        let bytes = match transport
11405            .blob_download(&blob_id)
11406            .map_err(|e| (e.code, e.message))?
11407        {
11408            BlobDownload::Bytes(bytes) => bytes,
11409            BlobDownload::Url {
11410                url,
11411                url_expires_at_ms,
11412            } => {
11413                // §5.9.5: MUST NOT start a fetch at/past expiry.
11414                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
11415                    return Err((
11416                        "sync.segment_expired".to_owned(),
11417                        format!(
11418                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
11419                        ),
11420                    ));
11421                }
11422                transport
11423                    .fetch_blob_url(&url)
11424                    .map_err(|e| (e.code, e.message))?
11425            }
11426        };
11427        #[cfg(feature = "bench-internals")]
11428        drop(download_phase);
11429        #[cfg(feature = "bench-internals")]
11430        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
11431        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
11432        if blob_id_for(&bytes) != blob_id {
11433            return Err(simple(format!(
11434                "blob content address mismatch for {blob_id}"
11435            )));
11436        }
11437        #[cfg(feature = "bench-internals")]
11438        drop(validate_phase);
11439        #[cfg(feature = "bench-internals")]
11440        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
11441        let now = self.clock_now_ms();
11442        self.conn
11443            .execute(
11444                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
11445                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
11446            )
11447            .map_err(|e| simple(e.to_string()))?;
11448        self.conn
11449            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
11450            .map_err(|e| simple(e.to_string()))?;
11451        self.enforce_blob_cache_cap();
11452        #[cfg(feature = "bench-internals")]
11453        drop(insert_phase);
11454        self.get_cached_blob_bytes(&blob_id)
11455            .map_err(simple)?
11456            .ok_or_else(|| simple("blob cache write failed".to_owned()))
11457    }
11458
11459    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
11460        #[cfg(feature = "bench-internals")]
11461        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
11462        let mut stmt = self
11463            .conn
11464            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
11465            .map_err(|e| e.to_string())?;
11466        let mut rows = stmt
11467            .query(rusqlite::params![blob_id])
11468            .map_err(|e| e.to_string())?;
11469        if let Some(row) = rows.next().map_err(|e| e.to_string())? {
11470            let bytes: Vec<u8> = row.get(0).map_err(|e| e.to_string())?;
11471            let byte_length: i64 = row.get(1).map_err(|e| e.to_string())?;
11472            let media_type: Option<String> = row.get(2).map_err(|e| e.to_string())?;
11473            return Ok(Some(FetchedBlob {
11474                blob_id: blob_id.to_owned(),
11475                byte_length,
11476                bytes,
11477                media_type,
11478            }));
11479        }
11480        Ok(None)
11481    }
11482
11483    /// §5.9.7 B4: upload every queued blob before push.
11484    fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
11485        let pending: Vec<(String, Option<String>)> = {
11486            let mut stmt = self
11487                .conn
11488                .prepare(
11489                    "SELECT blob_id, media_type FROM (
11490                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
11491                      UNION ALL
11492                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
11493                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
11494                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
11495                    ) ORDER BY created_at_ms, blob_id",
11496                )
11497                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11498            let rows = stmt
11499                .query_map([], |row| {
11500                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
11501                })
11502                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11503            rows.collect::<Result<_, _>>()
11504                .map_err(|error| match error {
11505                    rusqlite::Error::InvalidColumnType(..)
11506                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
11507                        "sync.local_corrupt",
11508                        "Pending blob upload metadata is invalid",
11509                    ),
11510                    _ => TransportError::new("client.failed", error.to_string()),
11511                })?
11512        };
11513        for (blob_id, media_type) in pending {
11514            let stored: Option<(SqlValue, SqlValue)> = self
11515                .conn
11516                .query_row(
11517                    "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
11518                    rusqlite::params![blob_id],
11519                    |row| Ok((row.get(0)?, row.get(1)?)),
11520                )
11521                .optional()
11522                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11523            let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
11524                return Err(TransportError::new(
11525                    "sync.local_corrupt",
11526                    "Pending blob upload body is missing or corrupt",
11527                ));
11528            };
11529            if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
11530                return Err(TransportError::new(
11531                    "sync.local_corrupt",
11532                    "Pending blob upload body is missing or corrupt",
11533                ));
11534            }
11535            self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
11536            self.conn
11537                .execute(
11538                    "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
11539                    rusqlite::params![blob_id],
11540                )
11541                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11542        }
11543        Ok(())
11544    }
11545
11546    /// §5.9.3: upload one blob, preferring the presigned direct-to-storage
11547    /// grant when the transport supports it, else streaming through the direct
11548    /// host-authenticated endpoint (capability, not fallback). A `Url` grant
11549    /// PUTs direct with no host auth; on a grant PUT failure the client streams
11550    /// through the direct endpoint — a *different, host-authenticated
11551    /// capability*, not a fall-through of the grant's authority.
11552    fn upload_one(
11553        &self,
11554        transport: &mut dyn Transport,
11555        blob_id: &str,
11556        bytes: &[u8],
11557        media_type: Option<&str>,
11558    ) -> Result<(), TransportError> {
11559        match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
11560            BlobUploadGrant::Present => return Ok(()), // idempotent §5.9.3
11561            BlobUploadGrant::Url {
11562                url,
11563                url_expires_at_ms,
11564            } => {
11565                let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
11566                if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
11567                    return Ok(());
11568                }
11569                // Failed/expired grant PUT — stream through the direct endpoint.
11570            }
11571            BlobUploadGrant::None => {
11572                // No presign store — stream through the direct endpoint.
11573            }
11574        }
11575        transport.blob_upload(blob_id, bytes, media_type)
11576    }
11577
11578    fn visible_blob_ids_sql(&self) -> String {
11579        let mut selects = Vec::new();
11580        for table in &self.schema.tables {
11581            for column in table
11582                .columns
11583                .iter()
11584                .filter(|column| column.ty == ColumnType::BlobRef)
11585            {
11586                let identifier = quote_ident(&column.name);
11587                selects.push(format!(
11588                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
11589                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
11590                     AND json_type({identifier}, '$.blobId') = 'text'",
11591                    quote_ident(&table.name),
11592                ));
11593            }
11594        }
11595        if selects.is_empty() {
11596            "SELECT NULL AS blob_id WHERE 0".to_owned()
11597        } else {
11598            selects.join(" UNION ")
11599        }
11600    }
11601
11602    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
11603    /// staged uploads pin bodies directly; no derived refcount is stored.
11604    fn enforce_blob_cache_cap(&self) {
11605        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
11606            return;
11607        };
11608        let mut total: i64 = self
11609            .conn
11610            .query_row(
11611                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
11612                [],
11613                |row| row.get(0),
11614            )
11615            .unwrap_or(0);
11616        if total <= max_bytes {
11617            return;
11618        }
11619        let query = format!(
11620            "SELECT blob_id, byte_length FROM _syncular_blobs
11621             WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
11622               AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
11623               AND blob_id NOT IN ({})
11624             ORDER BY created_at_ms ASC, blob_id ASC",
11625            self.visible_blob_ids_sql(),
11626        );
11627        let candidates: Vec<(String, i64)> = {
11628            let Ok(mut stmt) = self.conn.prepare(&query) else {
11629                return;
11630            };
11631            let Ok(rows) = stmt.query_map([], |row| {
11632                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
11633            }) else {
11634                return;
11635            };
11636            rows.filter_map(Result::ok).collect()
11637        };
11638        for (blob_id, byte_length) in candidates {
11639            if total <= max_bytes {
11640                break;
11641            }
11642            let _ = self.conn.execute(
11643                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
11644                rusqlite::params![blob_id],
11645            );
11646            total -= byte_length;
11647        }
11648    }
11649
11650    fn delete_unreferenced_cached_blobs(&mut self) {
11651        #[cfg(feature = "bench-internals")]
11652        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
11653        if !self.schema_has_blobs() {
11654            return;
11655        }
11656        self.rebuild_overlay_if_dirty();
11657        let _ = self.conn.execute(
11658            &format!(
11659                "DELETE FROM _syncular_blobs
11660                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
11661                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
11662                   AND blob_id NOT IN ({})",
11663                self.visible_blob_ids_sql(),
11664            ),
11665            [],
11666        );
11667    }
11668
11669    // -- local row storage ----------------------------------------------------------
11670
11671    /// The cached per-table primary-key upsert SQL for `full_table` (see
11672    /// the `insert_sql` field: built once, reused per row).
11673    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
11674        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
11675            return sql.clone();
11676        }
11677        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
11678        columns.push(quote_ident("_syncular_version"));
11679        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
11680        let primary_key = quote_ident(&table.primary_key);
11681        let updates = columns
11682            .iter()
11683            .filter(|column| **column != primary_key)
11684            .map(|column| format!("{column}=excluded.{column}"))
11685            .collect::<Vec<_>>()
11686            .join(", ");
11687        let sql = format!(
11688            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
11689            columns.join(", "),
11690            placeholders.join(", ")
11691        );
11692        self.insert_sql
11693            .borrow_mut()
11694            .insert(full_table.to_owned(), sql.clone());
11695        sql
11696    }
11697
11698    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
11699        self.overlay_dirty.set(true);
11700        self.write_row(&base_table(table_name), table_name, row, version)
11701    }
11702
11703    fn write_row(
11704        &self,
11705        full_table: &str,
11706        table_name: &str,
11707        row: &Row,
11708        version: i64,
11709    ) -> Result<(), String> {
11710        #[cfg(feature = "bench-internals")]
11711        let _phase = self.benchmark_phases.start(Phase::RowWrite);
11712        let table = self
11713            .schema
11714            .table(table_name)
11715            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
11716        let sql = self.insert_row_sql(full_table, table);
11717        let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
11718        let params = row
11719            .iter()
11720            .map(RowParam::Cell)
11721            .chain(std::iter::once(RowParam::Version(version)));
11722        stmt.execute(rusqlite::params_from_iter(params))
11723            .map_err(|e| e.to_string())?;
11724        Ok(())
11725    }
11726
11727    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
11728        let table = self
11729            .schema
11730            .table(table_name)
11731            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
11732        self.overlay_dirty.set(true);
11733        let sql = format!(
11734            "DELETE FROM {} WHERE {}",
11735            base_table(table_name),
11736            row_id_predicate(table)
11737        );
11738        let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
11739        stmt.execute(rusqlite::params![row_id])
11740            .map_err(|e| e.to_string())?;
11741        Ok(())
11742    }
11743
11744    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
11745    /// the outbox changed since the last rebuild (the no-op sync round).
11746    fn rebuild_overlay_if_dirty(&mut self) {
11747        if self.overlay_dirty.get() {
11748            self.rebuild_overlay();
11749        }
11750    }
11751
11752    /// §7.1: local reads see outbox state applied optimistically — rebuild
11753    /// every visible table as (base server state) + (pending outbox replay
11754    /// on top). Optimistic rows carry version `-1`.
11755    fn rebuild_overlay(&mut self) {
11756        #[cfg(feature = "bench-internals")]
11757        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
11758        #[cfg(test)]
11759        self.overlay_rebuild_count
11760            .set(self.overlay_rebuild_count.get() + 1);
11761        self.exec("SAVEPOINT syncular_overlay");
11762        for table in self.schema.tables.clone() {
11763            for index in &table.fts_indexes {
11764                let _ = self.drop_fts_triggers(index);
11765            }
11766            let visible = visible_table(&table.name);
11767            let base = base_table(&table.name);
11768            self.exec(&format!("DELETE FROM {visible}"));
11769            self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
11770        }
11771        for commit in &self.outbox {
11772            self.apply_outbox_ops(&commit.ops);
11773        }
11774        for table in self.schema.tables.clone() {
11775            for index in &table.fts_indexes {
11776                let _ = self.rebuild_fts_projection(&table, index);
11777                let _ = self.create_fts_triggers(&table, index);
11778            }
11779        }
11780        self.exec("RELEASE syncular_overlay");
11781        self.overlay_dirty.set(false);
11782    }
11783
11784    /// §7.1 replay: every operation is applied over the current visible
11785    /// state. An operation that no longer applies (a server row now holds its
11786    /// unique value) stays pending and invisible until a later replay admits
11787    /// it or the server answers its push.
11788    fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
11789        #[cfg(feature = "bench-internals")]
11790        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
11791        for op in ops {
11792            #[cfg(feature = "bench-internals")]
11793            if let Some(phase) = &mut phase {
11794                phase.unit();
11795            }
11796            let _ = self.apply_outbox_op(op);
11797        }
11798    }
11799
11800    fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
11801        let Some(table) = self.schema.table(&op.table) else {
11802            return Ok(());
11803        };
11804        let visible = visible_table(&table.name);
11805        if !op.upsert {
11806            let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
11807            self.conn
11808                .prepare_cached(&sql)
11809                .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
11810                .map_err(|e| e.to_string())?;
11811            return Ok(());
11812        }
11813        let Some(values) = op.values.as_ref() else {
11814            return Ok(());
11815        };
11816        // §7.1: the overlay applies the operation's PRESENT columns
11817        // over the current local row. A partial operation over an
11818        // absent local row leaves it absent — the server answers it
11819        // with sync.row_deleted or sync.row_missing (§6.2).
11820        let full = table
11821            .columns
11822            .iter()
11823            .all(|column| values.contains_key(&column.name));
11824        let local = self.visible_row(table, &op.row_id);
11825        if local.is_none() && !full {
11826            return Ok(());
11827        }
11828        let mut row: Row = Vec::with_capacity(table.columns.len());
11829        for (index, column) in table.columns.iter().enumerate() {
11830            let incoming = match values.get(&column.name) {
11831                Some(raw) => json_to_column_value(column, Some(raw)),
11832                None => Ok(local
11833                    .as_ref()
11834                    .and_then(|(values, _)| values.get(index).cloned())
11835                    .flatten()),
11836            };
11837            match incoming {
11838                Ok(value) => row.push(value),
11839                Err(_) => return Ok(()),
11840            }
11841        }
11842        let version = local.as_ref().map_or(-1, |(_, version)| *version);
11843        self.write_row(&visible, &table.name, &row, version)
11844            .map_err(|error| {
11845                if error.starts_with("UNIQUE constraint failed:") {
11846                    "sync.constraint_violation: local write violates a unique constraint".to_owned()
11847                } else {
11848                    error
11849                }
11850            })
11851    }
11852
11853    /// The visible (optimistic) row's values plus version, or `None` when the
11854    /// row is absent from the overlay.
11855    fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
11856        let sql = format!(
11857            "SELECT * FROM {} WHERE {}",
11858            visible_table(&table.name),
11859            row_id_predicate(table)
11860        );
11861        let mut stmt = self.conn.prepare_cached(&sql).ok()?;
11862        let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
11863        let row = rows.next().ok()??;
11864        let mut values = Vec::with_capacity(table.columns.len());
11865        for (index, column) in table.columns.iter().enumerate() {
11866            let raw = row.get_ref(index).ok()?;
11867            let json = sql_ref_to_json(column, raw);
11868            values.push(json_to_column_value(column, Some(&json)).ok()?);
11869        }
11870        let version: i64 = row.get(table.columns.len()).ok()?;
11871        Some((values, version))
11872    }
11873
11874    fn exec(&self, sql: &str) {
11875        let _ = self.conn.execute_batch(sql);
11876    }
11877
11878    // -- realtime (§8) ---------------------------------------------------------------
11879
11880    /// §8.8: the configured realtime policy.
11881    #[must_use]
11882    pub fn realtime_policy(&self) -> RealtimePolicy {
11883        self.realtime_policy
11884    }
11885
11886    /// §8.8: the explicit realtime availability state.
11887    #[must_use]
11888    pub fn realtime_state(&self) -> RealtimeState {
11889        self.realtime_state
11890    }
11891
11892    /// §8.8: set the realtime policy. Refused while the socket is connected:
11893    /// a policy change never silently re-routes an ownership state.
11894    pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
11895        if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
11896            return Err(
11897                "sync.invalid_request: disconnect realtime before changing the realtime policy"
11898                    .to_owned(),
11899            );
11900        }
11901        self.realtime_policy = policy;
11902        match policy {
11903            RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
11904            RealtimePolicy::Required | RealtimePolicy::Optional
11905                if self.realtime_state == RealtimeState::Disabled =>
11906            {
11907                self.set_realtime_state(RealtimeState::Disconnected, None);
11908            }
11909            RealtimePolicy::Required | RealtimePolicy::Optional => {}
11910        }
11911        Ok(())
11912    }
11913
11914    /// Record an availability transition, keeping the reason code bounded and
11915    /// code-like (§7.6) and clearing it where it cannot apply.
11916    fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
11917        self.realtime_state = state;
11918        self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
11919        if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
11920            self.realtime_reason_code = None;
11921            self.realtime_retry_delay_ms = None;
11922        }
11923    }
11924
11925    #[must_use]
11926    fn realtime_connected(&self) -> bool {
11927        self.realtime_state == RealtimeState::Connected
11928    }
11929
11930    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
11931        if self.realtime_policy == RealtimePolicy::Off {
11932            return Err(
11933                "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
11934            );
11935        }
11936        if self.realtime_connected() {
11937            return Ok(());
11938        }
11939        self.realtime_state = RealtimeState::Connecting;
11940        match transport.realtime_connect_for_client(&self.client_id) {
11941            Ok(()) => {
11942                self.set_realtime_state(RealtimeState::Connected, None);
11943                Ok(())
11944            }
11945            Err(error) => {
11946                self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
11947                Err(format!("{}: {}", error.code, error.message))
11948            }
11949        }
11950    }
11951
11952    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
11953        // A deliberately disconnected or policy-disabled client has nothing
11954        // to release; a `lost` socket still does (the host owns the handle).
11955        if self.realtime_state == RealtimeState::Disconnected
11956            || self.realtime_state == RealtimeState::Disabled
11957        {
11958            return;
11959        }
11960        let _ = transport.realtime_close();
11961        self.set_realtime_state(
11962            if self.realtime_policy == RealtimePolicy::Off {
11963                RealtimeState::Disabled
11964            } else {
11965                RealtimeState::Disconnected
11966            },
11967            None,
11968        );
11969        self.presence.clear(); // §8.6.1: presence is per-connection
11970    }
11971
11972    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
11973    /// document for `scope_key`. Requires a live socket; the document is
11974    /// ephemeral (lost on disconnect). Authorization is the connection's
11975    /// registration (§8.6.3) — an unheld key is rejected loudly by the
11976    /// server with `presence.forbidden`.
11977    pub fn set_presence(
11978        &mut self,
11979        transport: &mut dyn Transport,
11980        scope_key: &str,
11981        doc: Option<&Value>,
11982    ) -> Result<(), String> {
11983        if !self.realtime_connected() {
11984            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
11985        }
11986        let text = encode_presence_publish(scope_key, doc);
11987        transport
11988            .realtime_send(&text)
11989            .map_err(|e| format!("{}: {}", e.code, e.message))
11990    }
11991
11992    /// §8.6: the peers currently present on a scope key (ephemeral).
11993    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
11994        self.presence
11995            .get(scope_key)
11996            .map(|peers| peers.values().cloned().collect())
11997            .unwrap_or_default()
11998    }
11999
12000    /// §8.6 apply an inbound presence fanout to the local map.
12001    fn apply_presence(
12002        &mut self,
12003        scope_key: String,
12004        kind: Option<PresenceKind>,
12005        actor_id: Option<String>,
12006        client_id: Option<String>,
12007        doc: Option<Value>,
12008        error: Option<String>,
12009    ) {
12010        // The publisher-directed error variant is out-of-band; nothing to
12011        // record in the peer map.
12012        if error.is_some() {
12013            return;
12014        }
12015        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
12016            return;
12017        };
12018        let peer_key = format!("{actor_id} {client_id}");
12019        match kind {
12020            PresenceKind::Leave => {
12021                if let Some(peers) = self.presence.get_mut(&scope_key) {
12022                    peers.remove(&peer_key);
12023                    if peers.is_empty() {
12024                        self.presence.remove(&scope_key);
12025                    }
12026                }
12027            }
12028            _ => {
12029                let doc = match doc {
12030                    Some(Value::Object(_)) => doc.unwrap(),
12031                    _ => return,
12032                };
12033                self.presence.entry(scope_key).or_default().insert(
12034                    peer_key,
12035                    PresencePeer {
12036                        actor_id,
12037                        client_id,
12038                        doc,
12039                    },
12040                );
12041            }
12042        }
12043    }
12044
12045    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
12046    pub fn on_realtime_text(&mut self, text: &str) {
12047        match parse_control(text) {
12048            Ok(ControlMessage::Hello { requires_sync, .. }) => {
12049                if requires_sync {
12050                    // §8.1: pull before trusting the socket for continuity.
12051                    self.set_sync_needed(true, true);
12052                }
12053            }
12054            Ok(ControlMessage::Presence {
12055                scope_key,
12056                kind,
12057                actor_id,
12058                client_id,
12059                doc,
12060                error,
12061                ..
12062            }) => {
12063                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
12064            }
12065            Ok(ControlMessage::Wake { .. }) => {
12066                // §8.3: any wake-up means "run a pull soon", never data.
12067                self.set_sync_needed(true, true);
12068            }
12069            _ => {}
12070        }
12071    }
12072
12073    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
12074    /// a pull response per section; an unapplied delta is a wake-up.
12075    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
12076        if self.stopped {
12077            return;
12078        }
12079        #[cfg(feature = "bench-internals")]
12080        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
12081        let message = match decode_message(bytes) {
12082            Ok(m) if m.msg_kind == MsgKind::Response => m,
12083            _ => {
12084                self.set_sync_needed(true, true);
12085                return;
12086            }
12087        };
12088        #[cfg(feature = "bench-internals")]
12089        drop(decode_phase);
12090        let mut frames = message.frames.into_iter();
12091        let mut applied_cursor: Option<i64> = None;
12092        let mut any_covered = false;
12093        let mut dropped = false;
12094        while let Some(frame) = frames.next() {
12095            let Frame::SubStart {
12096                id,
12097                status,
12098                effective_scopes,
12099                ..
12100            } = frame
12101            else {
12102                continue;
12103            };
12104            let mut body = Vec::new();
12105            let mut next_cursor: Option<i64> = None;
12106            for inner in frames.by_ref() {
12107                match inner {
12108                    Frame::SubEnd {
12109                        next_cursor: nc, ..
12110                    } => {
12111                        next_cursor = Some(nc);
12112                        break;
12113                    }
12114                    Frame::Unknown { .. } => {}
12115                    other => body.push(other),
12116                }
12117            }
12118            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
12119                dropped = true;
12120                continue;
12121            };
12122            let sub = &self.subs[sub_index];
12123            // §8.2: only locally active, not mid-bootstrap subscriptions
12124            // apply; skipped sections are repaired by the next pull.
12125            if status != SubStatus::Active
12126                || sub.state != SubState::Active
12127                || sub.bootstrap_state.is_some()
12128                || !sub.synced_once
12129            {
12130                dropped = true;
12131                continue;
12132            }
12133            if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
12134                // Idempotent redelivery of an already-covered window.
12135                any_covered = true;
12136                continue;
12137            }
12138            let previous_effective = self.subs[sub_index].effective.clone();
12139            let previous_cursor = self.subs[sub_index].cursor;
12140            self.subs[sub_index].effective = Some(effective_scopes);
12141            let mut failed = false;
12142            for inner in body {
12143                match inner {
12144                    Frame::Commit {
12145                        tables, changes, ..
12146                    } => {
12147                        if self.apply_commit_frame(&tables, &changes).is_err() {
12148                            failed = true;
12149                            break;
12150                        }
12151                    }
12152                    _ => {
12153                        failed = true;
12154                        break;
12155                    }
12156                }
12157            }
12158            let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
12159                self.apply_sub_end(sub_index, cursor, None)
12160                    .ok()
12161                    .map(|()| cursor)
12162            });
12163            let Some(next_cursor) = committed else {
12164                self.subs[sub_index].effective = previous_effective;
12165                self.subs[sub_index].cursor = previous_cursor;
12166                dropped = true;
12167                continue;
12168            };
12169            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
12170        }
12171        if let Some(cursor) = applied_cursor {
12172            // §8.2 ack point: the highest applied SUB_END.nextCursor.
12173            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
12174            let _ = transport.realtime_send(&ack);
12175        } else if !any_covered || dropped {
12176            // §8.2: a delta not applied at all is treated as a wake-up.
12177            self.set_sync_needed(true, true);
12178        }
12179    }
12180
12181    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
12182    /// cursor across active, non-bootstrapping subscriptions that have
12183    /// synced at least once. No such subscription, no ack.
12184    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
12185        if !self.realtime_connected() {
12186            return;
12187        }
12188        let floor = self
12189            .subs
12190            .iter()
12191            .filter(|s| {
12192                s.state == SubState::Active
12193                    && s.bootstrap_state.is_none()
12194                    && s.synced_once
12195                    && s.cursor >= 0
12196            })
12197            .map(|s| s.cursor)
12198            .min();
12199        if let Some(cursor) = floor {
12200            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
12201            let _ = transport.realtime_send(&ack);
12202        }
12203    }
12204}
12205
12206#[cfg(test)]
12207mod previous_version_wiring_tests {
12208    use super::*;
12209    use serde_json::{json, Value};
12210
12211    fn previous_version_schema(version: i32) -> Value {
12212        json!({
12213            "version": version,
12214            "tables": [{
12215                "name": "tasks",
12216                "primaryKey": "id",
12217                "columns": [
12218                    { "name": "id", "type": "string", "nullable": false },
12219                    { "name": "note", "type": "string", "nullable": true }
12220                ],
12221                "scopes": []
12222            }]
12223        })
12224    }
12225
12226    fn previous_version_limits() -> crate::api::ClientLimits {
12227        crate::api::ClientLimits {
12228            previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
12229                enabled: true,
12230                ..Default::default()
12231            }),
12232            ..Default::default()
12233        }
12234    }
12235
12236    /// The configuration the live client carries, for a direct capture call.
12237    fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
12238        previous_version_limits()
12239            .previous_version_context
12240            .expect("configured")
12241    }
12242
12243    /// Capture the container the way a bump would, so a read test starts from a
12244    /// container this client's own state will then decide about.
12245    fn recapture_previous_version(client: &SyncClient) {
12246        let replica_path = client
12247            .previous_version_replica_path()
12248            .expect("file-backed replica");
12249        let outcome = crate::previous_version::capture_previous_version_from_replica(
12250            &client.conn,
12251            &replica_path,
12252            2,
12253            2,
12254            &previous_version_config(),
12255            1,
12256        )
12257        .expect("capture");
12258        assert!(matches!(
12259            outcome,
12260            crate::previous_version::CaptureOutcome::Captured(_)
12261        ));
12262    }
12263
12264    fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
12265        Subscription {
12266            id: "wiring-sub".to_owned(),
12267            table: "tasks".to_owned(),
12268            requested: Vec::new(),
12269            params: None,
12270            cursor,
12271            bootstrap_state: bootstrap_state.map(str::to_owned),
12272            state,
12273            reason_code: None,
12274            effective: None,
12275            synced_once: false,
12276        }
12277    }
12278
12279    #[test]
12280    fn previous_version_read_reasons_come_from_live_client_state() {
12281        use crate::previous_version::{
12282            PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
12283        };
12284        let path = std::env::temp_dir()
12285            .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
12286            .to_string_lossy()
12287            .into_owned();
12288        let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
12289        {
12290            let mut setup = SyncClient::open_path(
12291                "wiring".to_owned(),
12292                &previous_version_schema(1),
12293                previous_version_limits(),
12294                &path,
12295            )
12296            .expect("v1 install");
12297            setup
12298                .mutate(vec![Mutation::Upsert {
12299                    table: "tasks".to_owned(),
12300                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
12301                    base_version: None,
12302                }])
12303                .expect("seed a row");
12304        }
12305        let mut client = SyncClient::open_path(
12306            "wiring".to_owned(),
12307            &previous_version_schema(2),
12308            previous_version_limits(),
12309            &path,
12310        )
12311        .expect("v2 bump captures");
12312        assert!(std::path::Path::new(&container).exists());
12313        let spec = PreviousVersionReadSpec {
12314            table: "tasks".to_owned(),
12315            ..Default::default()
12316        };
12317
12318        // §7.3.5 lease error ⇒ lease-inactive, and the read discards.
12319        recapture_previous_version(&client);
12320        client.lease_state = Some(LeaseState {
12321            lease_id: None,
12322            expires_at_ms: None,
12323            error_code: Some("lease.expired".to_owned()),
12324        });
12325        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12326        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
12327        assert!(!snapshot.available);
12328        assert!(!std::path::Path::new(&container).exists());
12329
12330        // An expired lease uses the client clock (injected, no sleeps).
12331        recapture_previous_version(&client);
12332        client.set_now_ms(1_000);
12333        client.lease_state = Some(LeaseState {
12334            lease_id: Some("lease".to_owned()),
12335            expires_at_ms: Some(1_000),
12336            error_code: None,
12337        });
12338        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12339        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
12340        assert!(!std::path::Path::new(&container).exists());
12341        client.lease_state = None;
12342
12343        // A revoked subscription ⇒ scope-revoked.
12344        recapture_previous_version(&client);
12345        client.subs.push(subscription(SubState::Revoked, 0, None));
12346        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12347        assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
12348        assert!(!std::path::Path::new(&container).exists());
12349        client.subs.clear();
12350
12351        // Coverage completion: every ACTIVE subscription has a cursor and no
12352        // resume token — a still-resuming subscription is not completion.
12353        recapture_previous_version(&client);
12354        client
12355            .subs
12356            .push(subscription(SubState::Active, 0, Some("resume")));
12357        assert!(!client.previous_version_coverage_complete());
12358        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12359        assert!(snapshot.available);
12360        assert_eq!(snapshot.previous_version, Some(2));
12361        assert_eq!(snapshot.rows.len(), 1);
12362
12363        recapture_previous_version(&client);
12364        client.subs[0].bootstrap_state = None;
12365        assert!(client.previous_version_coverage_complete());
12366        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12367        assert_eq!(
12368            snapshot.reason,
12369            Some(PreviousVersionReason::CoverageComplete)
12370        );
12371        assert!(!std::path::Path::new(&container).exists());
12372        client.subs.clear();
12373
12374        // With no active subscription at all, coverage is NOT complete.
12375        assert!(!client.previous_version_coverage_complete());
12376
12377        drop(client);
12378        let _ = std::fs::remove_file(&path);
12379    }
12380
12381    /// RFC 0005 D7 lifetime drops. The read surface already proves the reasons
12382    /// come from live state; this proves the OTHER caller —
12383    /// `previous_version_lifetime_check`, the once-per-sync-round trigger —
12384    /// actually removes the container for lease-inactivity, coverage
12385    /// completion and scope revocation, and leaves it alone while the
12386    /// replacement bootstrap is still resuming.
12387    #[test]
12388    fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
12389        let path = std::env::temp_dir()
12390            .join(format!(
12391                "syncular-prev-lifetime-{}.db",
12392                uuid::Uuid::new_v4()
12393            ))
12394            .to_string_lossy()
12395            .into_owned();
12396        let container = format!(
12397            "{path}{}",
12398            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
12399        );
12400        let exists = || std::path::Path::new(&container).exists();
12401        {
12402            let mut setup = SyncClient::open_path(
12403                "lifetime".to_owned(),
12404                &previous_version_schema(1),
12405                previous_version_limits(),
12406                &path,
12407            )
12408            .expect("v1 install");
12409            setup
12410                .mutate(vec![Mutation::Upsert {
12411                    table: "tasks".to_owned(),
12412                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
12413                    base_version: None,
12414                }])
12415                .expect("seed a row");
12416        }
12417        let mut client = SyncClient::open_path(
12418            "lifetime".to_owned(),
12419            &previous_version_schema(2),
12420            previous_version_limits(),
12421            &path,
12422        )
12423        .expect("v2 bump captures");
12424        // Pin the clock so the 24h TTL never fires in this test (the TTL has
12425        // its own coverage); the capture record's createdAtMs is 1.
12426        client.set_now_ms(1_000);
12427        assert!(exists());
12428
12429        // §7.3.5 lease error ⇒ the lifetime check drops the container.
12430        client.lease_state = Some(LeaseState {
12431            lease_id: None,
12432            expires_at_ms: None,
12433            error_code: Some("lease.expired".to_owned()),
12434        });
12435        client.previous_version_lifetime_check();
12436        assert!(!exists());
12437        client.lease_state = None;
12438
12439        // §7.4.5 coverage completion (every active sub has a cursor and no
12440        // resume token) ⇒ drop.
12441        recapture_previous_version(&client);
12442        client.subs.push(subscription(SubState::Active, 0, None));
12443        client.previous_version_lifetime_check();
12444        assert!(!exists());
12445        client.subs.clear();
12446
12447        // §3.3 scope revocation ⇒ drop.
12448        recapture_previous_version(&client);
12449        client.subs.push(subscription(SubState::Revoked, 0, None));
12450        client.previous_version_lifetime_check();
12451        assert!(!exists());
12452        client.subs.clear();
12453
12454        // Negative control: a still-resuming active subscription is NOT
12455        // coverage completion, so the container survives the same call.
12456        recapture_previous_version(&client);
12457        client
12458            .subs
12459            .push(subscription(SubState::Active, 0, Some("resume")));
12460        client.previous_version_lifetime_check();
12461        assert!(exists());
12462        client.subs.clear();
12463
12464        drop(client);
12465        let _ = std::fs::remove_file(&path);
12466        let _ = std::fs::remove_file(&container);
12467        let _ = std::fs::remove_file(format!("{container}-wal"));
12468        let _ = std::fs::remove_file(format!("{container}-shm"));
12469    }
12470}