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};
44#[cfg(feature = "bench-internals")]
45use crate::bench::{Phase, Recorder};
46use crate::previous_version::{
47    capture_previous_version_from_replica, first_incompatibility,
48    reconcile_previous_version_at_boot, set_local_schema_descriptor,
49    sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
50    PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
51};
52use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
53use crate::transport::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
54use crate::values::{
55    bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
56    encode_sparse_row_json, full_row_values, json_to_column_value, json_to_scope_map,
57    normalize_values_casing, render_row_id_json, scope_map_to_json, sort_scope_map,
58};
59
60/// §4.2 default: rows and SQLite images, both applied in committed chunks.
61/// Bit 3 (signed URLs, §5.4) is
62/// added per transport capability at request-build time.
63const DEFAULT_ACCEPT: u8 = 0b0111;
64const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
65const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
66const ACCEPT_SQLITE: u8 = 1 << 2;
67const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
68const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
69
70/// §7.4.1 persisted local schema-version marker (`_syncular_meta` key).
71pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
72const LOCAL_REVISION_KEY: &str = "localRevision";
73const CLIENT_ID_KEY: &str = "clientId";
74const LEASE_STATE_KEY: &str = "leaseState";
75const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
76const LOG_EPOCH_KEY: &str = "logEpoch";
77/// Persisted fail-closed quarantine marker: present while a security preflight
78/// is pending and `activateSecurity` has yet to run. Storing it in the replica
79/// keeps the gate with the data it protects, so a rebuilt host handle reopening
80/// the same file returns in preflight even though its in-memory state is fresh.
81const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
82const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
83const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
84/// §7.4.4 client-local code: a pending outbox commit cannot re-encode under
85/// the new schema after a bump. Never a wire code (§10.3).
86const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
87/// §5.11 client-local code: an encode at the push seam resolved no usable key
88/// id or named an unknown key. Never a wire code (§10.3).
89const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
90
91#[derive(Debug, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase", deny_unknown_fields)]
93struct PersistedLocalDataRebootstrapReceipt {
94    version: u8,
95    retained_commits: u64,
96    reset_subscriptions: u64,
97}
98
99fn invalid_local_rebootstrap_receipt() -> String {
100    "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
101}
102
103fn encode_local_rebootstrap_receipt(
104    retained_commits: usize,
105    reset_subscriptions: usize,
106) -> Result<String, String> {
107    let retained_commits =
108        u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
109    let reset_subscriptions =
110        u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
111    if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
112        return Err(invalid_local_rebootstrap_receipt());
113    }
114    serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
115        version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
116        retained_commits,
117        reset_subscriptions,
118    })
119    .map_err(|_| invalid_local_rebootstrap_receipt())
120}
121
122fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
123    // Pre-0.15.36 markers proved application but did not retain the receipt.
124    if value == "v1" {
125        return Ok((0, 0));
126    }
127    let receipt: PersistedLocalDataRebootstrapReceipt =
128        serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
129    if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
130        || receipt.retained_commits > MAX_JS_SAFE_INTEGER
131        || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
132    {
133        return Err(invalid_local_rebootstrap_receipt());
134    }
135    Ok((
136        usize::try_from(receipt.retained_commits)
137            .map_err(|_| invalid_local_rebootstrap_receipt())?,
138        usize::try_from(receipt.reset_subscriptions)
139            .map_err(|_| invalid_local_rebootstrap_receipt())?,
140    ))
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144enum SubState {
145    Active,
146    Revoked,
147    Failed,
148}
149
150#[cfg(test)]
151mod observation_tests {
152    use super::*;
153    use crate::native_transport::HostTransport;
154    use crate::values::encode_row_json;
155    use serde_json::json;
156
157    fn client() -> SyncClient {
158        SyncClient::new(
159            "retry-test".to_owned(),
160            &json!({
161                "version": 1,
162                "tables": [{
163                    "name": "tasks",
164                    "primaryKey": "id",
165                    "columns": [
166                        { "name": "id", "type": "string", "nullable": false },
167                        { "name": "project_id", "type": "string", "nullable": false }
168                    ],
169                    "scopes": [{ "pattern": "project:{project_id}" }]
170                }]
171            }),
172            ClientLimits::default(),
173        )
174        .expect("test client")
175    }
176
177    #[cfg(feature = "e2ee")]
178    #[test]
179    fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
180        let schema = json!({
181            "version": 1,
182            "tables": [{
183                "name": "secrets",
184                "primaryKey": "id",
185                "columns": [
186                    { "name": "id", "type": "string", "nullable": false },
187                    { "name": "encryption_key_id", "type": "string", "nullable": true },
188                    { "name": "note", "type": "bytes", "nullable": true,
189                      "encrypted": true, "declaredType": "string" }
190                ],
191                "scopes": []
192            }]
193        });
194        let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
195            .expect("test client");
196        let mut config = crate::values::EncryptionConfig::default();
197        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
198        config
199            .key_id_columns
200            .insert("secrets".to_owned(), "encryption_key_id".to_owned());
201        client.set_encryption(config);
202        // A patch on a locally absent row presents an encrypted column with no
203        // stored key id to fall back to. Authoring succeeds...
204        let commit_id = client
205            .patch(
206                "secrets",
207                "ghost",
208                Map::from_iter([("note".to_owned(), json!("x"))]),
209                None,
210            )
211            .expect("patch records without an author-time encrypt failure");
212        assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
213        // ...and the push seam raises a durable local rejection, not a throw.
214        assert!(client.drop_unencodable_outbox().unwrap());
215        assert!(client.pending_commit_ids().is_empty());
216        let rejections = client.rejections();
217        assert_eq!(rejections.len(), 1);
218        assert_eq!(rejections[0].code, "client.encrypt_failed");
219        assert_eq!(rejections[0].client_commit_id, commit_id);
220        assert_eq!(
221            client
222                .commit_outcome(&commit_id)
223                .unwrap()
224                .map(|outcome| outcome.status),
225            Some(CommitOutcomeStatus::Rejected)
226        );
227    }
228
229    #[test]
230    fn patch_with_a_scope_column_matches_the_stored_local_row() {
231        let mut client = client();
232        client.create_synced_tables().unwrap();
233        client
234            .conn
235            .execute(
236                "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
237                [],
238            )
239            .unwrap();
240
241        // §3.4 rule 5: a value equal to the stored local row is a no-op, so
242        // the scope column leaves the presence set.
243        client
244            .patch(
245                "tasks",
246                "t1",
247                Map::from_iter([
248                    ("project_id".to_owned(), json!("p1")),
249                    ("id".to_owned(), json!("t1")),
250                ]),
251                None,
252            )
253            .expect("an equal scope column is a no-op");
254        let values = client.outbox[0].ops[0]
255            .values
256            .clone()
257            .expect("the patch records values");
258        assert_eq!(values.get("id"), Some(&json!("t1")));
259        assert!(!values.contains_key("project_id"));
260
261        // A differing value stays rejected.
262        let differing = client.patch(
263            "tasks",
264            "t1",
265            Map::from_iter([("project_id".to_owned(), json!("p2"))]),
266            None,
267        );
268        assert!(differing
269            .expect_err("a differing scope column is rejected")
270            .contains("patch cannot write scope column"));
271
272        // An absent local row leaves nothing to prove equality against, so
273        // the patch fails closed.
274        let absent = client.patch(
275            "tasks",
276            "ghost",
277            Map::from_iter([("project_id".to_owned(), json!("p1"))]),
278            None,
279        );
280        assert!(absent
281            .expect_err("an absent local row is rejected")
282            .contains("patch cannot write scope column"));
283        assert_eq!(client.pending_commit_ids().len(), 1);
284    }
285
286    #[test]
287    fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
288        let schema = json!({
289            "version": 1,
290            "tables": [
291                {
292                    "name": "tenants",
293                    "primaryKey": "tenant_id",
294                    "columns": [
295                        { "name": "tenant_id", "type": "string", "nullable": false },
296                        { "name": "body", "type": "string", "nullable": false }
297                    ],
298                    "scopes": [{ "pattern": "tenant:{tenant_id}" }]
299                },
300                {
301                    "name": "buckets",
302                    "primaryKey": "id",
303                    "columns": [
304                        { "name": "id", "type": "string", "nullable": false },
305                        { "name": "bucket", "type": "float", "nullable": false },
306                        { "name": "body", "type": "string", "nullable": false }
307                    ],
308                    "scopes": [{ "pattern": "bucket:{bucket}" }]
309                }
310            ]
311        });
312        let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
313            .expect("test client");
314        client.create_synced_tables().unwrap();
315
316        // A primary key that is also a scope column with no local row: the
317        // sparse payload's key is the row id being patched, so its value is
318        // proven equal without a stored-row read.
319        client
320            .patch(
321                "tenants",
322                "t1",
323                Map::from_iter([
324                    ("tenant_id".to_owned(), json!("t1")),
325                    ("body".to_owned(), json!("created")),
326                ]),
327                None,
328            )
329            .expect("the primary-key scope column is proven equal by construction");
330
331        // A stored REAL scope value coerces from a supplied integer.
332        client
333            .conn
334            .execute(
335                "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
336                [],
337            )
338            .unwrap();
339        client
340            .patch(
341                "buckets",
342                "b1",
343                Map::from_iter([
344                    ("bucket".to_owned(), json!(2)),
345                    ("body".to_owned(), json!("coerced")),
346                ]),
347                None,
348            )
349            .expect("the stored REAL value coerces from a supplied integer");
350        let values = client.outbox[1].ops[0]
351            .values
352            .clone()
353            .expect("the patch records values");
354        assert!(!values.contains_key("bucket"));
355
356        // A differing value stays rejected.
357        let differing = client.patch(
358            "buckets",
359            "b1",
360            Map::from_iter([("bucket".to_owned(), json!(3))]),
361            None,
362        );
363        assert!(differing
364            .expect_err("a differing scope value is rejected")
365            .contains("patch cannot write scope column"));
366    }
367
368    #[cfg(feature = "e2ee")]
369    #[test]
370    fn stored_key_fallback_resolves_an_integer_primary_key() {
371        let schema = json!({
372            "version": 1,
373            "tables": [{
374                "name": "patients",
375                "primaryKey": "id",
376                "columns": [
377                    { "name": "id", "type": "integer", "nullable": false },
378                    { "name": "encryption_key_id", "type": "string", "nullable": true },
379                    { "name": "note", "type": "bytes", "nullable": true,
380                      "encrypted": true, "declaredType": "string" }
381                ],
382                "scopes": []
383            }]
384        });
385        let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
386            .expect("test client");
387        client.create_synced_tables().unwrap();
388        client
389            .conn
390            .execute(
391                "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
392                 VALUES (5, 'k1', NULL, 1)",
393                [],
394            )
395            .unwrap();
396        let mut config = crate::values::EncryptionConfig::default();
397        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
398        config
399            .key_id_columns
400            .insert("patients".to_owned(), "encryption_key_id".to_owned());
401        client.set_encryption(config);
402        let table = client.schema.table("patients").unwrap();
403        let fallback = client
404            .stored_key_fallback(table, "5")
405            .expect("the stored integer key resolves the fallback");
406        assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
407    }
408
409    #[test]
410    fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
411        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
412            {"name":"id","type":"string","nullable":false},
413            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
414        for state in ["absent", "cached", "pinned"] {
415            let path = std::env::temp_dir().join(format!(
416                "syncular-blob-stage-{}.sqlite",
417                uuid::Uuid::new_v4()
418            ));
419            let mut client = SyncClient::open_path(
420                "stage-test".into(),
421                &schema,
422                Default::default(),
423                path.to_str().unwrap(),
424            )
425            .unwrap();
426            let bytes = b"atomic stage";
427            if state != "absent" {
428                client
429                    .upload_blob(bytes, Some("text/plain".into()), None)
430                    .unwrap();
431                if state == "cached" {
432                    client
433                        .conn
434                        .execute("DELETE FROM _syncular_blob_uploads", [])
435                        .unwrap();
436                }
437            }
438            let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
439            client
440                .conn
441                .execute_batch(
442                    "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
443                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
444                 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
445                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
446                )
447                .unwrap();
448            let error = client
449                .upload_blob(bytes, Some("application/octet-stream".into()), None)
450                .unwrap_err();
451            assert!(error.contains("injected stage failure"), "{state}: {error}");
452            assert!(client.conn.is_autocommit());
453            let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
454            drop(client);
455            let mut reopened = SyncClient::open_path(
456                "stage-test".into(),
457                &schema,
458                Default::default(),
459                path.to_str().unwrap(),
460            )
461            .unwrap();
462            let durable = reopened
463                .query("SELECT * FROM _syncular_blobs", &[])
464                .unwrap();
465            reopened
466                .conn
467                .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
468                .unwrap();
469            let retried = reopened.upload_blob(bytes, None, None).unwrap();
470            assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
471            assert_eq!(
472                reopened
473                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
474                    .unwrap(),
475                vec![Map::from_iter([(
476                    "blob_id".into(),
477                    Value::from(blob_id_for(bytes)),
478                )])]
479            );
480            drop(reopened);
481            std::fs::remove_file(&path).unwrap();
482            assert_eq!(durable, bodies, "{state}: reopened body state");
483            assert_eq!(immediate, bodies, "{state}: immediate body state");
484        }
485    }
486
487    #[test]
488    fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
489        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
490            {"name":"id","type":"string","nullable":false},
491            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
492        for fault in [
493            "missing",
494            "body-type",
495            "length",
496            "hash",
497            "pin-metadata",
498            "read",
499            "pin-delete",
500        ] {
501            let path = std::env::temp_dir().join(format!(
502                "syncular-blob-upload-{}.sqlite",
503                uuid::Uuid::new_v4()
504            ));
505            let mut client = SyncClient::open_path(
506                "upload-test".into(),
507                &schema,
508                Default::default(),
509                path.to_str().unwrap(),
510            )
511            .unwrap();
512            let bytes = b"durable upload";
513            let ref_value = client
514                .upload_blob(bytes, Some("text/plain".into()), None)
515                .unwrap();
516            let commit = client
517                .mutate(vec![Mutation::Upsert {
518                    table: "attachments".into(),
519                    values: Map::from_iter([
520                        ("id".into(), Value::from("pending")),
521                        (
522                            "file".into(),
523                            Value::from(format!(
524                                "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
525                                blob_id_for(bytes),
526                                bytes.len()
527                            )),
528                        ),
529                    ]),
530                    base_version: None,
531                }])
532                .unwrap();
533            client
534                .conn
535                .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
536                .unwrap();
537            client.conn.execute_batch(match fault {
538                "missing" => "DELETE FROM _syncular_blobs",
539                "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
540                "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
541                "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
542                "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
543                "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
544                _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
545            }).unwrap();
546            let state = if fault == "read" {
547                Vec::new()
548            } else {
549                client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
550            };
551            let mut transport = CountingRealtimeTransport::default();
552            let error = client.flush_blob_uploads(&mut transport).unwrap_err();
553            assert_eq!(
554                error.code,
555                if matches!(fault, "read" | "pin-delete") {
556                    "client.failed"
557                } else {
558                    "sync.local_corrupt"
559                },
560                "{fault}"
561            );
562            assert_eq!(
563                transport.blob_uploads.len(),
564                usize::from(fault == "pin-delete"),
565                "{fault}: corrupt bytes must never be uploaded"
566            );
567            assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
568            if fault != "read" {
569                assert_eq!(
570                    client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
571                    state
572                );
573            } else {
574                client
575                    .conn
576                    .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
577                    .unwrap();
578            }
579            drop(client);
580            let mut reopened = SyncClient::open_path(
581                "upload-test".into(),
582                &schema,
583                Default::default(),
584                path.to_str().unwrap(),
585            )
586            .unwrap();
587            assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
588            if fault != "read" {
589                assert_eq!(
590                    reopened
591                        .query("SELECT * FROM _syncular_blobs", &[])
592                        .unwrap(),
593                    state
594                );
595            }
596            if fault == "pin-delete" {
597                reopened
598                    .conn
599                    .execute_batch("DROP TRIGGER fail_pin_delete")
600                    .unwrap();
601            }
602            if fault == "pin-metadata" {
603                reopened
604                    .conn
605                    .execute(
606                        "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
607                        [],
608                    )
609                    .unwrap();
610            }
611            reopened
612                .conn
613                .execute_batch(
614                    "DELETE FROM _syncular_blobs;
615                     INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
616                )
617                .unwrap();
618            transport.blob_uploads.clear();
619            reopened.flush_blob_uploads(&mut transport).unwrap();
620            assert_eq!(
621                transport.blob_uploads,
622                [(blob_id_for(bytes), bytes.to_vec())]
623            );
624            assert_eq!(
625                reopened
626                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
627                    .unwrap(),
628                Vec::<Map<String, Value>>::new()
629            );
630            assert_eq!(reopened.pending_commit_ids(), [commit]);
631            assert_eq!(
632                reopened
633                    .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
634                    .unwrap()
635                    .unwrap()
636                    .bytes,
637                bytes
638            );
639            drop(reopened);
640            std::fs::remove_file(path).unwrap();
641        }
642    }
643
644    #[test]
645    fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
646        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
647            {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
648            "scopes":[{"pattern":"project:{project_id}"}]}]});
649        for realtime in [false, true] {
650            for fault in [
651                "none",
652                "row",
653                "revision",
654                "commit",
655                "cursor",
656                "error",
657                "missing-end",
658            ] {
659                if realtime && fault == "missing-end" {
660                    continue;
661                }
662                let path = std::env::temp_dir()
663                    .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
664                let mut client = SyncClient::open_path(
665                    "frame-test".into(),
666                    &schema,
667                    Default::default(),
668                    path.to_str().unwrap(),
669                )
670                .unwrap();
671                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
672                client
673                    .subscribe(
674                        "tasks".into(),
675                        "tasks".into(),
676                        vec![("project_id".into(), vec!["p1".into()])],
677                        None,
678                    )
679                    .unwrap();
680                client.subs[0].cursor = 0;
681                client.subs[0].synced_once = true;
682                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
683                client.persist_sub(&client.subs[0]).unwrap();
684                client.realtime_connected = true;
685                client.drain_change_batches();
686                let mut transport = CountingRealtimeTransport::default();
687                let (_, meta) = client.build_request(false);
688                let mut response = Message {
689                    wire_version: WIRE_VERSION,
690                    msg_kind: MsgKind::Response,
691                    frames: vec![
692                        Frame::RespHeader {
693                            required_schema_version: None,
694                            latest_schema_version: None,
695                            log_epoch: Some("epoch-1".into()),
696                            reset_required: Some(false),
697                        },
698                        Frame::SubStart {
699                            id: "tasks".into(),
700                            status: SubStatus::Active,
701                            reason_code: "".into(),
702                            effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
703                            bootstrap: false,
704                        },
705                    ],
706                };
707                for (index, id) in ["first", "second"].iter().enumerate() {
708                    let values = Map::from_iter([
709                        ("id".into(), Value::from(*id)),
710                        ("project_id".into(), Value::from("p1")),
711                    ]);
712                    response.frames.push(Frame::Commit {
713                        commit_seq: index as i64 + 1,
714                        actor_id: "writer".into(),
715                        created_at_ms: 1,
716                        tables: vec!["tasks".into()],
717                        changes: vec![ssp2::model::Change {
718                            table_index: 0,
719                            row_id: (*id).into(),
720                            op: Op::Upsert,
721                            row_version: Some(index as i64 + 1),
722                            scopes: vec![("project_id".into(), "p1".into())],
723                            row: Some(
724                                encode_row_json(
725                                    client.schema.table("tasks").unwrap(),
726                                    id,
727                                    &values,
728                                    &client.encryption,
729                                )
730                                .unwrap(),
731                            ),
732                        }],
733                    });
734                }
735                response.frames.push(Frame::SubEnd {
736                    next_cursor: 2,
737                    bootstrap_state: None,
738                });
739                let valid = response.clone();
740                match fault {
741                    "row" => {
742                        let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
743                        let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
744                    },
745                    "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(),
746                    "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(),
747                    "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
748                    "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});},
749                    "missing-end" => {response.frames.truncate(3);},
750                    _ => {}
751                }
752                if realtime {
753                    client.on_realtime_binary(&mut transport, &encode_message(&response));
754                } else {
755                    assert_eq!(
756                        matches!(
757                            client.process_response(&mut transport, response, &meta),
758                            SyncOutcome::Ok(_)
759                        ),
760                        fault == "none",
761                        "{fault}"
762                    );
763                }
764                assert!(
765                    client.conn.is_autocommit(),
766                    "no open transaction after {realtime}/{fault}"
767                );
768                let count = if matches!(fault, "none" | "cursor") {
769                    2
770                } else {
771                    1
772                };
773                let expected_revision = count + u64::from(realtime && fault != "none");
774                assert_eq!(
775                    client.local_revision(),
776                    expected_revision,
777                    "{realtime}/{fault}"
778                );
779                let batches = client.drain_change_batches();
780                assert_eq!(
781                    batches.len(),
782                    expected_revision as usize,
783                    "{realtime}/{fault}"
784                );
785                let row_batches: Vec<_> = batches
786                    .iter()
787                    .filter(|batch| !batch.tables.is_empty())
788                    .collect();
789                assert_eq!(row_batches.len(), count as usize);
790                assert_eq!(
791                    row_batches
792                        .iter()
793                        .map(|b| b.revision.clone())
794                        .collect::<Vec<_>>(),
795                    (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
796                );
797                for table in ["tasks", "_syncular_base_tasks"] {
798                    assert_eq!(
799                        client
800                            .conn
801                            .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
802                                .get::<_, u64>(0))
803                            .unwrap(),
804                        count,
805                        "{realtime}/{fault}/{table}"
806                    );
807                }
808                assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
809                assert_eq!(
810                    transport
811                        .messages
812                        .iter()
813                        .filter(|m| m.contains("\"ack\""))
814                        .count(),
815                    usize::from(fault == "none")
816                );
817                if matches!(fault, "revision" | "commit" | "cursor") {
818                    client
819                        .conn
820                        .execute_batch("DROP TRIGGER fail_frame")
821                        .unwrap();
822                }
823                drop(client);
824                let mut reopened = SyncClient::open_path(
825                    "frame-test".into(),
826                    &schema,
827                    Default::default(),
828                    path.to_str().unwrap(),
829                )
830                .unwrap();
831                assert_eq!(reopened.local_revision(), expected_revision);
832                assert_eq!(
833                    reopened
834                        .query("SELECT id FROM tasks ORDER BY id", &[])
835                        .unwrap()
836                        .len(),
837                    count as usize
838                );
839                assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
840                let (_, meta) = reopened.build_request(false);
841                assert!(matches!(
842                    reopened.process_response(&mut transport, valid, &meta),
843                    SyncOutcome::Ok(_)
844                ));
845                assert_eq!(
846                    reopened
847                        .query("SELECT id FROM tasks ORDER BY id", &[])
848                        .unwrap()
849                        .len(),
850                    2
851                );
852                assert_eq!(reopened.subs[0].cursor, 2);
853                drop(reopened);
854                std::fs::remove_file(path).unwrap();
855            }
856        }
857    }
858
859    #[test]
860    fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
861        for status in [SubStatus::Reset, SubStatus::Revoked] {
862            for fault in ["subscription", "revision"] {
863                let mut client = client();
864                client
865                    .subscribe(
866                        "tasks".into(),
867                        "tasks".into(),
868                        vec![("project_id".into(), vec!["p1".into()])],
869                        None,
870                    )
871                    .unwrap();
872                client.subs[0].cursor = 41;
873                client.subs[0].synced_once = true;
874                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
875                client.persist_sub(&client.subs[0]).unwrap();
876                client
877                    .mutate(vec![Mutation::Upsert {
878                        table: "tasks".into(),
879                        values: Map::from_iter([
880                            ("id".into(), Value::from("pending")),
881                            ("project_id".into(), Value::from("p1")),
882                        ]),
883                        base_version: None,
884                    }])
885                    .unwrap();
886                let ids = client.pending_commit_ids();
887                let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
888                let revision = client.local_revision();
889                client.drain_change_batches();
890                let (_, meta) = client.build_request(false);
891                client.conn.execute_batch(if fault == "subscription" {
892                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
893                } else {
894                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
895                }).unwrap();
896                let mut transport = CountingRealtimeTransport::default();
897                let mut report = SyncReport::default();
898                // Reset changes window completeness and therefore needs a revision.
899                if status == SubStatus::Reset {
900                    client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
901                }
902                let result = client.process_section(
903                    &mut transport,
904                    "tasks",
905                    status,
906                    "sync.scope_revoked",
907                    vec![],
908                    vec![],
909                    Some((42, None)),
910                    &meta,
911                    &mut report,
912                );
913                assert!(result.is_err(), "{status:?}/{fault}");
914                assert!(client.conn.is_autocommit());
915                assert_eq!(client.subs[0].cursor, 41);
916                assert_eq!(client.subs[0].state, SubState::Active);
917                assert_eq!(client.pending_commit_ids(), ids);
918                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
919                assert!(client.rejections.is_empty());
920                assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
921                assert_eq!(client.local_revision(), revision);
922                assert!(client.drain_change_batches().is_empty());
923                assert!(report.revoked.is_empty() && report.resets.is_empty());
924            }
925        }
926    }
927
928    #[test]
929    fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
930        let mut client = client();
931        client
932            .subscribe(
933                "tasks".into(),
934                "tasks".into(),
935                vec![("project_id".into(), vec!["p1".into()])],
936                None,
937            )
938            .unwrap();
939        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
940        client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
941            CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
942        let row = |id: &str| ssp2::segment::SegmentRow {
943            server_version: 1,
944            values: vec![
945                Some(ColumnValue::String(id.into())),
946                Some(ColumnValue::String("p1".into())),
947            ],
948        };
949        let segment = RowsSegment {
950            table: "tasks".into(),
951            schema_version: 1,
952            columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
953            blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
954        };
955        assert!(client.apply_segment(0, &segment, true).is_err());
956        assert!(client.conn.is_autocommit());
957        assert_eq!(
958            client
959                .query("SELECT id FROM tasks ORDER BY id", &[])
960                .unwrap(),
961            vec![Map::from_iter([("id".into(), Value::from("first"))])]
962        );
963        assert_eq!(client.local_revision(), 1);
964        assert_eq!(client.drain_change_batches().len(), 1);
965        assert_eq!(client.subs[0].cursor, -1);
966        client
967            .conn
968            .execute_batch("DROP TRIGGER fail_block")
969            .unwrap();
970        assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
971        assert_eq!(client.local_revision(), 3);
972        assert_eq!(client.drain_change_batches().len(), 2);
973    }
974
975    #[test]
976    fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
977        assert_eq!(
978            client().diagnostics_storage().blob_cache_bytes_approx,
979            Some(0)
980        );
981        let client = SyncClient::new(
982            "diagnostic-storage".to_owned(),
983            &json!({
984                "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
985                    "columns": [{"name": "id", "type": "string", "nullable": false},
986                        {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
987            }),
988            ClientLimits {
989                blob_cache_max_bytes: Some(5),
990                ..Default::default()
991            },
992        )
993        .unwrap();
994        let empty = client.diagnostics_storage();
995        assert_eq!(empty.status, "healthy");
996        assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
997        assert_eq!(empty.retained_outcome_entries, Some(0));
998        assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
999        assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1000        client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1001            INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1002                VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1003            INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1004                VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1005        let written = client.diagnostics_storage();
1006        assert_eq!(written.status, "pressure");
1007        assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1008        assert_eq!(written.retained_outcome_entries, Some(2));
1009        assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1010        assert_eq!(written.blob_cache_bytes_approx, Some(8));
1011        assert_eq!(
1012            written.pressure_reason_code.as_deref(),
1013            Some("client.blob_cache_over_limit")
1014        );
1015        client
1016            .conn
1017            .execute_batch(
1018                "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1019            DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1020            )
1021            .unwrap();
1022        let staged = client.diagnostics_storage();
1023        assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1024        assert_eq!(staged.retained_outcome_entries, Some(0));
1025        assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1026        assert_eq!(staged.status, "healthy");
1027        client.conn.execute_batch("ROLLBACK").unwrap();
1028        assert_eq!(client.diagnostics_storage(), written);
1029        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1030        let unreadable = client.diagnostics_storage();
1031        assert_eq!(unreadable.status, "unreadable");
1032        assert!(unreadable.database_bytes_approx.is_none());
1033        assert!(unreadable.pending_outbox_bytes_approx.is_none());
1034        assert!(unreadable.retained_outcome_entries.is_none());
1035        assert!(unreadable.retained_outcome_bytes_approx.is_none());
1036        assert!(unreadable.blob_cache_bytes_approx.is_none());
1037        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1038        let restored = client.diagnostics_storage();
1039        assert_eq!(restored.status, written.status);
1040        assert_eq!(
1041            restored.retained_outcome_bytes_approx,
1042            written.retained_outcome_bytes_approx
1043        );
1044        client.conn.set_prepared_statement_cache_capacity(0);
1045        assert_eq!(client.diagnostics_storage(), restored);
1046    }
1047
1048    #[test]
1049    fn background_retry_deadlines_back_off_and_reset() {
1050        let mut client = client();
1051        client.schedule_background_retry();
1052        assert!(matches!(
1053            client.drain_sync_intents().as_slice(),
1054            [SyncIntent::Background { delay_ms: 250 }]
1055        ));
1056        client.schedule_background_retry();
1057        assert!(matches!(
1058            client.drain_sync_intents().as_slice(),
1059            [SyncIntent::Background { delay_ms: 500 }]
1060        ));
1061        client.reset_background_retry();
1062        client.schedule_background_retry();
1063        assert!(matches!(
1064            client.drain_sync_intents().as_slice(),
1065            [SyncIntent::Background { delay_ms: 250 }]
1066        ));
1067    }
1068
1069    #[test]
1070    fn log_epoch_reset_requests_an_immediate_follow_up_round() {
1071        let mut client = client();
1072        client
1073            .subscribe(
1074                "epoch-tasks".to_owned(),
1075                "tasks".to_owned(),
1076                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1077                None,
1078            )
1079            .expect("subscribe");
1080        client.drain_sync_intents();
1081
1082        client
1083            .run_log_epoch_reset("epoch-2")
1084            .expect("reset partition log epoch");
1085
1086        assert!(matches!(
1087            client.drain_sync_intents().as_slice(),
1088            [SyncIntent::Interactive]
1089        ));
1090    }
1091
1092    #[derive(Default)]
1093    struct CountingRealtimeTransport {
1094        connects: usize,
1095        closes: usize,
1096        messages: Vec<String>,
1097        blob_uploads: Vec<(String, Vec<u8>)>,
1098        blob_download: Option<Vec<u8>>,
1099    }
1100
1101    impl Transport for CountingRealtimeTransport {
1102        fn blob_upload(
1103            &mut self,
1104            blob_id: &str,
1105            bytes: &[u8],
1106            _media_type: Option<&str>,
1107        ) -> Result<(), TransportError> {
1108            self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1109            Ok(())
1110        }
1111
1112        fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1113            self.blob_download
1114                .clone()
1115                .map(BlobDownload::Bytes)
1116                .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1117        }
1118
1119        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1120            Err(TransportError::new("sync.transport_failed", "offline"))
1121        }
1122
1123        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1124            Err(TransportError::new("sync.transport_failed", "offline"))
1125        }
1126
1127        fn download_segment(
1128            &mut self,
1129            _request: &SegmentRequest,
1130            _on_progress: &mut dyn FnMut(u64),
1131        ) -> Result<Vec<u8>, TransportError> {
1132            Err(TransportError::new("sync.transport_failed", "offline"))
1133        }
1134
1135        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1136            self.connects += 1;
1137            Ok(())
1138        }
1139
1140        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1141            self.messages.push(text.to_owned());
1142            Ok(())
1143        }
1144
1145        fn realtime_close(&mut self) -> Result<(), TransportError> {
1146            self.closes += 1;
1147            Ok(())
1148        }
1149    }
1150
1151    #[test]
1152    fn blob_results_own_downloaded_bytes() {
1153        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1154            {"name":"id","type":"string","nullable":false},
1155            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1156        let bytes = b"owned typed result".to_vec();
1157        let blob_id = blob_id_for(&bytes);
1158        let mut transport = CountingRealtimeTransport {
1159            blob_download: Some(bytes.clone()),
1160            ..Default::default()
1161        };
1162        let result = {
1163            let mut client =
1164                SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1165                    .unwrap();
1166            let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1167            assert_eq!(result.blob_id, blob_id);
1168            assert_eq!(result.byte_length, bytes.len() as i64);
1169            assert_eq!(result.bytes, bytes);
1170            assert_eq!(result.media_type, None);
1171            assert_eq!(
1172                client
1173                    .conn
1174                    .query_row(
1175                        "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1176                        rusqlite::params![blob_id],
1177                        |row| row.get::<_, i64>(0),
1178                    )
1179                    .unwrap(),
1180                0
1181            );
1182            result
1183        };
1184        assert_eq!(result.bytes, bytes);
1185
1186        let mut client =
1187            SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1188        assert_eq!(
1189            client
1190                .fetch_blob_bytes(&mut transport, "not json")
1191                .unwrap_err(),
1192            (
1193                "client.failed".to_owned(),
1194                "blob ref is not JSON".to_owned()
1195            )
1196        );
1197    }
1198
1199    #[test]
1200    fn visible_blob_references_survive_cache_trimming() {
1201        let schema = json!({"version":1,"tables":[
1202            {"name":"attachments","primaryKey":"id","columns":[
1203                {"name":"id","type":"string","nullable":false},
1204                {"name":"body","type":"blob_ref","nullable":true},
1205                {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
1206            {"name":"avatars","primaryKey":"id","columns":[
1207                {"name":"id","type":"string","nullable":false},
1208                {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
1209        ]});
1210        let bytes = b"referenced above cap".to_vec();
1211        let blob_id = blob_id_for(&bytes);
1212        let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
1213        let mut client = SyncClient::new(
1214            "visible-blob-refs".to_owned(),
1215            &schema,
1216            ClientLimits {
1217                blob_cache_max_bytes: Some(1),
1218                ..Default::default()
1219            },
1220        )
1221        .unwrap();
1222        client.conn.execute(
1223            "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
1224            rusqlite::params![reference, reference, reference, reference],
1225        ).unwrap();
1226        client
1227            .conn
1228            .execute(
1229                "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
1230                rusqlite::params![reference],
1231            )
1232            .unwrap();
1233        client.conn.execute(
1234            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
1235            [],
1236        ).unwrap();
1237        let mut transport = CountingRealtimeTransport {
1238            blob_download: Some(bytes.clone()),
1239            ..Default::default()
1240        };
1241
1242        assert_eq!(
1243            client
1244                .fetch_blob_bytes(&mut transport, &blob_id)
1245                .unwrap()
1246                .bytes,
1247            bytes
1248        );
1249        let rows = client
1250            .conn
1251            .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1252            .unwrap()
1253            .query_map([], |row| row.get::<_, String>(0))
1254            .unwrap()
1255            .collect::<Result<Vec<_>, _>>()
1256            .unwrap();
1257        assert_eq!(rows, vec![blob_id]);
1258    }
1259
1260    #[test]
1261    fn realtime_connection_ownership_is_idempotent() {
1262        let mut client = client();
1263        let mut transport = CountingRealtimeTransport {
1264            connects: 0,
1265            closes: 0,
1266            ..Default::default()
1267        };
1268        client
1269            .connect_realtime(&mut transport)
1270            .expect("first connect");
1271        client
1272            .connect_realtime(&mut transport)
1273            .expect("idempotent connect");
1274        assert_eq!(transport.connects, 1);
1275        client.disconnect_realtime(&mut transport);
1276        client.disconnect_realtime(&mut transport);
1277        assert_eq!(transport.closes, 1);
1278        client
1279            .connect_realtime(&mut transport)
1280            .expect("deliberate reconnect");
1281        assert_eq!(transport.connects, 2);
1282    }
1283
1284    #[test]
1285    fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1286        let mut client = client();
1287        client
1288            .subscribe(
1289                "repair-tasks".to_owned(),
1290                "tasks".to_owned(),
1291                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1292                None,
1293            )
1294            .expect("subscribe");
1295        {
1296            let sub = client
1297                .subs
1298                .iter_mut()
1299                .find(|sub| sub.id == "repair-tasks")
1300                .expect("subscription");
1301            sub.cursor = 42;
1302            sub.synced_once = true;
1303            let persisted = sub.clone();
1304            client.persist_sub(&persisted).unwrap();
1305        }
1306        for table in ["_syncular_base_tasks", "tasks"] {
1307            client
1308                .conn
1309                .execute(
1310                    &format!(
1311                        "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1312                    ),
1313                    rusqlite::params!["server-row", "p1"],
1314                )
1315                .expect("seed server row");
1316        }
1317        let pending = client
1318            .mutate(vec![Mutation::Upsert {
1319                table: "tasks".to_owned(),
1320                values: Map::from_iter([
1321                    ("id".to_owned(), Value::from("offline-row")),
1322                    ("project_id".to_owned(), Value::from("p1")),
1323                ]),
1324                base_version: None,
1325            }])
1326            .expect("queue offline work");
1327        client.drain_change_batches();
1328        client.drain_sync_intents();
1329
1330        assert_eq!(
1331            client
1332                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1333                    rebootstrap_id: "support-case-001".to_owned(),
1334                })
1335                .expect("rebootstrap"),
1336            LocalDataRebootstrapResult {
1337                already_applied: false,
1338                retained_commits: 1,
1339                reset_subscriptions: 1,
1340            }
1341        );
1342        let visible_ids = client
1343            .conn
1344            .prepare("SELECT id FROM tasks ORDER BY id")
1345            .expect("prepare visible ids")
1346            .query_map([], |row| row.get::<_, String>(0))
1347            .expect("query visible ids")
1348            .collect::<Result<Vec<_>, _>>()
1349            .expect("collect visible ids");
1350        assert_eq!(visible_ids, vec!["offline-row"]);
1351        assert_eq!(client.pending_commit_ids(), vec![pending]);
1352        assert_eq!(
1353            client
1354                .subscription_state("repair-tasks")
1355                .expect("subscription")
1356                .cursor,
1357            -1
1358        );
1359        assert!(client.upgrading());
1360        assert!(client.sync_needed());
1361        assert!(matches!(
1362            client.drain_sync_intents().as_slice(),
1363            [SyncIntent::Interactive]
1364        ));
1365        assert_eq!(client.drain_change_batches().len(), 1);
1366
1367        assert_eq!(
1368            client
1369                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1370                    rebootstrap_id: "support-case-001".to_owned(),
1371                })
1372                .expect("idempotent retry"),
1373            LocalDataRebootstrapResult {
1374                already_applied: true,
1375                retained_commits: 1,
1376                reset_subscriptions: 1,
1377            }
1378        );
1379        assert!(client.drain_change_batches().is_empty());
1380    }
1381
1382    #[test]
1383    fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1384        let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1385        assert_eq!(
1386            decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1387            (3, 4)
1388        );
1389        assert_eq!(
1390            decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1391            (0, 0)
1392        );
1393        for malformed in [
1394            "",
1395            "{}",
1396            r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1397            r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1398            r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1399        ] {
1400            assert_eq!(
1401                decode_local_rebootstrap_receipt(malformed)
1402                    .expect_err("malformed receipt must fail"),
1403                "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1404            );
1405        }
1406    }
1407
1408    #[test]
1409    fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1410        let path = std::env::temp_dir().join(format!(
1411            "syncular-rebootstrap-receipt-{}.db",
1412            uuid::Uuid::new_v4()
1413        ));
1414        let schema = json!({
1415            "version": 1,
1416            "tables": [{
1417                "name": "tasks",
1418                "primaryKey": "id",
1419                "columns": [
1420                    { "name": "id", "type": "string", "nullable": false },
1421                    { "name": "project_id", "type": "string", "nullable": false }
1422                ],
1423                "scopes": [{ "pattern": "project:{project_id}" }]
1424            }]
1425        });
1426        let path_string = path.to_str().expect("UTF-8 temp path");
1427
1428        {
1429            let mut first = SyncClient::open_path(
1430                "repair-restart-client".to_owned(),
1431                &schema,
1432                ClientLimits::default(),
1433                path_string,
1434            )
1435            .expect("first open");
1436            first
1437                .subscribe(
1438                    "repair-tasks".to_owned(),
1439                    "tasks".to_owned(),
1440                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1441                    None,
1442                )
1443                .expect("subscribe");
1444            first
1445                .mutate(vec![Mutation::Upsert {
1446                    table: "tasks".to_owned(),
1447                    values: Map::from_iter([
1448                        ("id".to_owned(), Value::from("offline-row")),
1449                        ("project_id".to_owned(), Value::from("p1")),
1450                    ]),
1451                    base_version: None,
1452                }])
1453                .expect("queue offline work");
1454            assert_eq!(
1455                first
1456                    .rebootstrap_local_data(&LocalDataRebootstrapInput {
1457                        rebootstrap_id: "restart-receipt".to_owned(),
1458                    })
1459                    .expect("first rebootstrap"),
1460                LocalDataRebootstrapResult {
1461                    already_applied: false,
1462                    retained_commits: 1,
1463                    reset_subscriptions: 1,
1464                }
1465            );
1466        }
1467
1468        let mut reopened = SyncClient::open_path(
1469            "repair-restart-client".to_owned(),
1470            &schema,
1471            ClientLimits::default(),
1472            path_string,
1473        )
1474        .expect("reopen");
1475        reopened.drain_change_batches();
1476        reopened.drain_sync_intents();
1477        assert_eq!(
1478            reopened
1479                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1480                    rebootstrap_id: "restart-receipt".to_owned(),
1481                })
1482                .expect("receipt replay"),
1483            LocalDataRebootstrapResult {
1484                already_applied: true,
1485                retained_commits: 1,
1486                reset_subscriptions: 1,
1487            }
1488        );
1489        assert!(reopened.drain_change_batches().is_empty());
1490        assert!(reopened.drain_sync_intents().is_empty());
1491        drop(reopened);
1492        std::fs::remove_file(path).expect("remove temp database");
1493    }
1494
1495    #[test]
1496    fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1497        let mut malformed = client();
1498        malformed
1499            .subscribe(
1500                "repair-tasks".to_owned(),
1501                "tasks".to_owned(),
1502                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1503                None,
1504            )
1505            .expect("subscribe");
1506        malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1507        malformed.drain_change_batches();
1508        malformed.drain_sync_intents();
1509        let malformed_error = malformed
1510            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1511                rebootstrap_id: "malformed".to_owned(),
1512            })
1513            .expect_err("malformed receipt must fail closed");
1514        assert_eq!(
1515            malformed_error,
1516            "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1517        );
1518        assert!(!malformed.upgrading());
1519        assert_eq!(
1520            malformed
1521                .subscription_state("repair-tasks")
1522                .expect("unchanged subscription")
1523                .cursor,
1524            -1
1525        );
1526        assert!(malformed.drain_change_batches().is_empty());
1527        assert!(malformed.drain_sync_intents().is_empty());
1528
1529        let mut unreadable = client();
1530        unreadable
1531            .conn
1532            .execute(
1533                "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1534                rusqlite::params!["server-row", "p1"],
1535            )
1536            .expect("seed visible row");
1537        unreadable
1538            .conn
1539            .execute("DROP TABLE _syncular_meta", [])
1540            .expect("break marker storage");
1541        let unreadable_error = unreadable
1542            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1543                rebootstrap_id: "unreadable".to_owned(),
1544            })
1545            .expect_err("unreadable marker storage must fail closed");
1546        assert_eq!(
1547            unreadable_error,
1548            "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1549        );
1550        let visible_rows = unreadable
1551            .conn
1552            .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1553            .expect("visible projection remains");
1554        assert_eq!(visible_rows, 1);
1555        assert!(!unreadable.upgrading());
1556        assert!(unreadable.drain_change_batches().is_empty());
1557        assert!(unreadable.drain_sync_intents().is_empty());
1558    }
1559
1560    #[test]
1561    fn local_rebootstrap_cannot_bypass_schema_floor() {
1562        let mut client = client();
1563        client.set_schema_floor(Some(SchemaFloor {
1564            required_schema_version: Some(2),
1565            latest_schema_version: Some(2),
1566        }));
1567        let error = client
1568            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1569                rebootstrap_id: "blocked-floor".to_owned(),
1570            })
1571            .expect_err("schema floor must block repair");
1572        assert!(error.contains("cannot bypass an active schema-floor stop"));
1573    }
1574
1575    #[test]
1576    fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
1577        let mut client = client();
1578        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1579        const COMMIT_COUNT: usize = 32;
1580
1581        for index in 0..COMMIT_COUNT {
1582            client
1583                .mutate(vec![Mutation::Upsert {
1584                    table: "tasks".to_owned(),
1585                    values: Map::from_iter([
1586                        ("id".to_owned(), Value::from(format!("task-{index}"))),
1587                        ("project_id".to_owned(), Value::from("project-1")),
1588                    ]),
1589                    base_version: None,
1590                }])
1591                .expect("queue commit");
1592        }
1593
1594        let (_, request_meta) = client.build_request(false);
1595        assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
1596        let mut frames = vec![Frame::RespHeader {
1597            required_schema_version: None,
1598            latest_schema_version: None,
1599            log_epoch: Some("epoch-1".to_owned()),
1600            reset_required: Some(false),
1601        }];
1602        frames.extend(request_meta.pushed_ids.iter().enumerate().map(
1603            |(index, client_commit_id)| Frame::PushResult {
1604                client_commit_id: client_commit_id.clone(),
1605                status: PushStatus::Applied,
1606                commit_seq: Some(index as i64 + 1),
1607                results: vec![OpResult::Applied { op_index: 0 }],
1608            },
1609        ));
1610        let response = Message {
1611            wire_version: WIRE_VERSION,
1612            msg_kind: MsgKind::Response,
1613            frames,
1614        };
1615        let mut transport =
1616            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
1617
1618        client.overlay_rebuild_count.set(0);
1619        client.outcome_prune_count.set(0);
1620        let outcome = client.process_response(&mut transport, response, &request_meta);
1621        assert!(matches!(outcome, SyncOutcome::Ok(_)));
1622        assert!(client.pending_commit_ids().is_empty());
1623        assert_eq!(
1624            client.overlay_rebuild_count.get(),
1625            1,
1626            "one response must reconcile its acknowledged commits with one overlay rebuild"
1627        );
1628        assert_eq!(
1629            client.outcome_prune_count.get(),
1630            1,
1631            "one response must enforce outcome retention once"
1632        );
1633    }
1634
1635    #[test]
1636    fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
1637        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
1638            "columns": [
1639                {"name":"id", "type":"string", "nullable":false},
1640                {"name":"project_id", "type":"string", "nullable":false},
1641                {"name":"title", "type":"string", "nullable":false}
1642            ], "scopes": [{"pattern":"project:{project_id}"}],
1643            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1644            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
1645        }] });
1646        let mut incremental =
1647            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1648        let mut reference =
1649            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1650        let tables = vec!["tasks".to_owned()];
1651        for index in 0..32 {
1652            let id = format!("task-{}", index % 8);
1653            let values = Map::from_iter([
1654                ("id".to_owned(), Value::from(id.clone())),
1655                (
1656                    "project_id".to_owned(),
1657                    Value::from(format!("p{}", index % 2)),
1658                ),
1659                ("title".to_owned(), Value::from(format!("needle{index}"))),
1660            ]);
1661            let payload = encode_row_json(
1662                incremental.schema.table("tasks").unwrap(),
1663                &id,
1664                &values,
1665                &incremental.encryption,
1666            )
1667            .unwrap();
1668            let change = ssp2::model::Change {
1669                table_index: 0,
1670                row_id: id,
1671                op: if index % 5 == 0 {
1672                    Op::Delete
1673                } else {
1674                    Op::Upsert
1675                },
1676                row_version: Some(index + 1),
1677                scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
1678                row: Some(payload),
1679            };
1680            reference.overlay_dirty.set(true);
1681            for instance in [&mut incremental, &mut reference] {
1682                instance.begin_observation("mirror_test").unwrap();
1683                let mut batch = ChangeAccumulator::default();
1684                instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
1685                instance
1686                    .apply_commit_changes(&tables, std::slice::from_ref(&change))
1687                    .unwrap();
1688                instance.rebuild_overlay_if_dirty();
1689                instance.finish_observation("mirror_test", batch).unwrap();
1690            }
1691            for sql in [
1692                "SELECT * FROM tasks ORDER BY id",
1693                "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
1694            ] {
1695                assert_eq!(
1696                    incremental.query(sql, &[]).unwrap(),
1697                    reference.query(sql, &[]).unwrap()
1698                );
1699            }
1700            assert_eq!(incremental.local_revision(), reference.local_revision());
1701            assert_eq!(
1702                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1703                serde_json::to_value(reference.drain_change_batches()).unwrap()
1704            );
1705        }
1706        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
1707        assert_eq!(reference.overlay_rebuild_count.get(), 32);
1708    }
1709
1710    #[test]
1711    fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
1712        let cases = [
1713            (
1714                "string",
1715                vec![
1716                    json!("1"),
1717                    json!("01"),
1718                    json!(""),
1719                    json!("nul\u{0}id"),
1720                    json!("é"),
1721                ],
1722            ),
1723            (
1724                "integer",
1725                vec![
1726                    json!(i64::MIN),
1727                    json!(-1),
1728                    json!(0),
1729                    json!(1),
1730                    json!(i64::MAX),
1731                ],
1732            ),
1733            ("boolean", vec![json!(false), json!(true)]),
1734            ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
1735        ];
1736        for (kind, ids) in cases {
1737            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1738                "columns":[{"name":"id","type":kind,"nullable":false},
1739                    {"name":"project_id","type":"string","nullable":false}],
1740                "scopes":[{"pattern":"project:{project_id}"}]}]});
1741            let mut client =
1742                SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
1743            for (index, id) in ids.iter().enumerate() {
1744                let table = client.schema.table("tasks").unwrap();
1745                client
1746                    .write_base_row(
1747                        "tasks",
1748                        &vec![
1749                            json_to_column_value(&table.columns[0], Some(id)).unwrap(),
1750                            Some(ColumnValue::String(format!("p{index}"))),
1751                        ],
1752                        1,
1753                    )
1754                    .unwrap();
1755            }
1756            client.rebuild_overlay_if_dirty();
1757            for base in [true, false] {
1758                let full = if base {
1759                    base_table("tasks")
1760                } else {
1761                    visible_table("tasks")
1762                };
1763                let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
1764                for row_id in [
1765                    "1",
1766                    "01",
1767                    "1.0",
1768                    "1.0000000000000002",
1769                    "+1",
1770                    "1e0",
1771                    " 1",
1772                    "-1",
1773                    "0",
1774                    "9223372036854775807",
1775                    "-9223372036854775808",
1776                    "true",
1777                    "false",
1778                    "",
1779                    "nul\u{0}id",
1780                    "é",
1781                    "null",
1782                    "{\"a\":1}",
1783                ] {
1784                    let old_sql =
1785                        format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
1786                    let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
1787                    let old = client
1788                        .conn
1789                        .prepare(&old_sql)
1790                        .unwrap()
1791                        .query_map([row_id], |row| row.get::<_, String>(0))
1792                        .unwrap()
1793                        .collect::<Result<Vec<_>, _>>()
1794                        .unwrap();
1795                    let new = client
1796                        .conn
1797                        .prepare_cached(&new_sql)
1798                        .unwrap()
1799                        .query_map([row_id], |row| row.get::<_, String>(0))
1800                        .unwrap()
1801                        .collect::<Result<Vec<_>, _>>()
1802                        .unwrap();
1803                    assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
1804                    let mut batch = ChangeAccumulator::default();
1805                    assert_eq!(
1806                        client.record_row_scopes(&mut batch, "tasks", row_id, base),
1807                        !old.is_empty()
1808                    );
1809                    let expected = old.first().map(|scope| {
1810                        BTreeMap::from([(
1811                            "tasks".to_owned(),
1812                            Some(BTreeSet::from([format!("project:{scope}")])),
1813                        )])
1814                    });
1815                    assert_eq!(batch.tables, expected.unwrap_or_default());
1816                    client
1817                        .conn
1818                        .execute_batch("SAVEPOINT lookup_delete")
1819                        .unwrap();
1820                    if base {
1821                        client.delete_base_row("tasks", row_id).unwrap();
1822                    } else {
1823                        client.apply_outbox_ops(&[OutboxOp {
1824                            upsert: false,
1825                            table: "tasks".into(),
1826                            row_id: row_id.into(),
1827                            base_version: None,
1828                            values: None,
1829                        }]);
1830                    }
1831                    let remaining: i64 = client
1832                        .conn
1833                        .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
1834                            row.get(0)
1835                        })
1836                        .unwrap();
1837                    assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
1838                    client
1839                        .conn
1840                        .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
1841                        .unwrap();
1842                    client.overlay_dirty.set(false);
1843                }
1844                for (predicate, expected) in [
1845                    ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
1846                    (predicate, "SEARCH"),
1847                ] {
1848                    let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
1849                    let details = client
1850                        .conn
1851                        .prepare(&sql)
1852                        .unwrap()
1853                        .query_map(["1"], |row| row.get::<_, String>(3))
1854                        .unwrap()
1855                        .collect::<Result<Vec<_>, _>>()
1856                        .unwrap();
1857                    assert!(
1858                        details.iter().any(|detail| detail.contains(expected)),
1859                        "{kind}: {details:?}"
1860                    );
1861                }
1862            }
1863        }
1864    }
1865
1866    #[test]
1867    fn primary_keys_reject_every_ineligible_column_type() {
1868        // §2.4: a `rowId` is a string, so a primary key type whose string form
1869        // differs per renderer, or cannot be reproduced by local storage
1870        // comparison, is a schema error rather than a silent lookup miss.
1871        for ty in ["float", "bytes", "crdt", "blob_ref"] {
1872            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1873                "columns":[{"name":"id","type":ty,"nullable":false},
1874                    {"name":"project_id","type":"string","nullable":false}],
1875                "scopes":[{"pattern":"project:{project_id}"}]}]});
1876            let error =
1877                match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
1878                    Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
1879                    Err(error) => error,
1880                };
1881            assert!(
1882                error.contains("has an ineligible column type (§2.4)"),
1883                "{ty}: {error}"
1884            );
1885        }
1886        for ty in ["string", "integer", "boolean", "json"] {
1887            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1888                "columns":[{"name":"id","type":ty,"nullable":false},
1889                    {"name":"project_id","type":"string","nullable":false}],
1890                "scopes":[{"pattern":"project:{project_id}"}]}]});
1891            SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
1892                .unwrap_or_else(|error| panic!("{ty}: {error}"));
1893        }
1894    }
1895
1896    #[test]
1897    fn pending_row_reconciliation_matches_full_fifo_replay() {
1898        for unique in [false, true] {
1899            let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
1900                "name": name, "primaryKey": "id", "columns": [
1901                    {"name":"id", "type":"string", "nullable":false},
1902                    {"name":"project_id", "type":"string", "nullable":false},
1903                    {"name":"title", "type":"string", "nullable":false}],
1904                "scopes": [{"pattern":"project:{project_id}"}],
1905                "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
1906                "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
1907            })))});
1908            let mut incremental =
1909                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
1910            let mut reference =
1911                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
1912            let tables = vec!["tasks".to_owned(), "notes".to_owned()];
1913            for instance in [&mut incremental, &mut reference] {
1914                for table in &tables {
1915                    for index in 0..12 {
1916                        instance
1917                            .write_base_row(
1918                                table,
1919                                &vec![
1920                                    Some(ColumnValue::String(format!("row{index}"))),
1921                                    Some(ColumnValue::String("p0".into())),
1922                                    Some(ColumnValue::String(format!("base{index}"))),
1923                                ],
1924                                1,
1925                            )
1926                            .unwrap();
1927                    }
1928                }
1929                instance.rebuild_overlay_if_dirty();
1930            }
1931            for index in 0..80 {
1932                let mutations = (0..3)
1933                    .map(|offset| {
1934                        let table = tables[(index + offset) % 2].clone();
1935                        let id = format!("row{}", (index + offset) % 16);
1936                        if (index + offset) % 7 == 0 {
1937                            Mutation::Delete {
1938                                table,
1939                                row_id: id,
1940                                base_version: None,
1941                            }
1942                        } else {
1943                            Mutation::Upsert {
1944                                table,
1945                                values: Map::from_iter([
1946                                    ("id".into(), Value::from(id)),
1947                                    ("project_id".into(), Value::from(format!("p{}", index % 3))),
1948                                    (
1949                                        "title".into(),
1950                                        Value::from(format!("needle{}", (index + offset) % 16)),
1951                                    ),
1952                                ]),
1953                                base_version: None,
1954                            }
1955                        }
1956                    })
1957                    .collect::<Vec<_>>();
1958                for instance in [&mut incremental, &mut reference] {
1959                    instance.mutate(mutations.clone()).unwrap();
1960                }
1961                // Most frames touch only the table without unique constraints.
1962                // Mixed frames must retain full replay for both tables.
1963                let changes = (0..3)
1964                    .map(|offset| {
1965                        let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
1966                        let id = format!("row{}", (index + offset) % 16);
1967                        let values = Map::from_iter([
1968                            ("id".into(), Value::from(id.clone())),
1969                            (
1970                                "project_id".into(),
1971                                Value::from(format!("p{}", (index + 1) % 3)),
1972                            ),
1973                            (
1974                                "title".into(),
1975                                Value::from(format!("remote{index}_{offset}")),
1976                            ),
1977                        ]);
1978                        ssp2::model::Change {
1979                            table_index: table_index as u16,
1980                            row_id: id.clone(),
1981                            op: if (index + offset) % 5 == 0 {
1982                                Op::Delete
1983                            } else {
1984                                Op::Upsert
1985                            },
1986                            row_version: Some(index as i64 + 2),
1987                            scopes: vec![],
1988                            row: Some(
1989                                encode_row_json(
1990                                    incremental.schema.table(&tables[table_index]).unwrap(),
1991                                    &id,
1992                                    &values,
1993                                    &incremental.encryption,
1994                                )
1995                                .unwrap(),
1996                            ),
1997                        }
1998                    })
1999                    .collect::<Vec<_>>();
2000                reference.overlay_dirty.set(true);
2001                incremental.apply_commit_frame(&tables, &changes).unwrap();
2002                reference.apply_commit_frame(&tables, &changes).unwrap();
2003                for table in &tables {
2004                    for sql in [format!("SELECT * FROM {table} ORDER BY id"),
2005                        format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
2006                        format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
2007                        format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
2008                        assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
2009                    }
2010                }
2011                assert_eq!(incremental.local_revision(), reference.local_revision());
2012                assert_eq!(
2013                    serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2014                    serde_json::to_value(reference.drain_change_batches()).unwrap()
2015                );
2016            }
2017            assert_eq!(
2018                incremental.overlay_rebuild_count.get(),
2019                if unique { 81 } else { 21 }
2020            );
2021            assert_eq!(reference.overlay_rebuild_count.get(), 81);
2022        }
2023    }
2024
2025    #[test]
2026    fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
2027        let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
2028            "columns":[{"name":"id", "type":"string", "nullable":false},
2029                {"name":"title", "type":"string", "nullable":false}],
2030            "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
2031        let mut client =
2032            SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
2033        for (id, title) in [("a", "original"), ("b", "occupied")] {
2034            client
2035                .write_base_row(
2036                    "tasks",
2037                    &vec![
2038                        Some(ColumnValue::String(id.into())),
2039                        Some(ColumnValue::String(title.into())),
2040                    ],
2041                    1,
2042                )
2043                .unwrap();
2044        }
2045        client.rebuild_overlay_if_dirty();
2046        let pending = client
2047            .mutate(vec![Mutation::Upsert {
2048                table: "tasks".into(),
2049                values: Map::from_iter([
2050                    ("id".into(), Value::from("a")),
2051                    ("title".into(), Value::from("available")),
2052                ]),
2053                base_version: None,
2054            }])
2055            .unwrap();
2056        assert_eq!(
2057            client
2058                .conn
2059                .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2060                    .get::<_, String>(
2061                    0
2062                ))
2063                .unwrap(),
2064            "available"
2065        );
2066        for (title, expected) in [("available", "original"), ("occupied", "available")] {
2067            let change = ssp2::model::Change {
2068                table_index: 0,
2069                row_id: "b".into(),
2070                op: Op::Upsert,
2071                row_version: Some(2),
2072                scopes: vec![],
2073                row: Some(
2074                    encode_row_json(
2075                        client.schema.table("tasks").unwrap(),
2076                        "b",
2077                        &Map::from_iter([
2078                            ("id".into(), Value::from("b")),
2079                            ("title".into(), Value::from(title)),
2080                        ]),
2081                        &client.encryption,
2082                    )
2083                    .unwrap(),
2084                ),
2085            };
2086            client
2087                .apply_commit_frame(&["tasks".into()], &[change])
2088                .unwrap();
2089            assert_eq!(
2090                client
2091                    .conn
2092                    .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2093                        .get::<_, String>(
2094                        0
2095                    ))
2096                    .unwrap(),
2097                expected
2098            );
2099            assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
2100        }
2101        assert_eq!(client.overlay_rebuild_count.get(), 3);
2102    }
2103
2104    #[test]
2105    fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
2106        for failure in ["visible", "revision", "commit"] {
2107            let mut client = client();
2108            let id = client
2109                .mutate(vec![Mutation::Upsert {
2110                    table: "tasks".into(),
2111                    values: Map::from_iter([
2112                        ("id".into(), Value::from("t1")),
2113                        ("project_id".into(), Value::from("pending")),
2114                    ]),
2115                    base_version: None,
2116                }])
2117                .unwrap();
2118            client.drain_change_batches();
2119            let revision = client.local_revision();
2120            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
2121            let trigger = match failure {
2122                "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
2123                "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"),
2124                _ => "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(),
2125            };
2126            client.conn.execute_batch(&trigger).unwrap();
2127            let change = ssp2::model::Change {
2128                table_index: 0,
2129                row_id: "t1".into(),
2130                op: Op::Upsert,
2131                row_version: Some(2),
2132                scopes: vec![],
2133                row: Some(
2134                    encode_row_json(
2135                        client.schema.table("tasks").unwrap(),
2136                        "t1",
2137                        &Map::from_iter([
2138                            ("id".into(), Value::from("t1")),
2139                            ("project_id".into(), Value::from("remote")),
2140                        ]),
2141                        &client.encryption,
2142                    )
2143                    .unwrap(),
2144                ),
2145            };
2146            assert!(client
2147                .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change))
2148                .is_err());
2149            assert!(client.conn.is_autocommit());
2150            assert!(!client.overlay_dirty.get());
2151            assert_eq!(client.local_revision(), revision);
2152            assert!(client.drain_change_batches().is_empty());
2153            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
2154            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
2155            assert!(client
2156                .query("SELECT * FROM _syncular_base_tasks", &[])
2157                .unwrap()
2158                .is_empty());
2159            client
2160                .conn
2161                .execute_batch("DROP TRIGGER fail_pending")
2162                .unwrap();
2163            client
2164                .apply_commit_frame(&["tasks".into()], &[change])
2165                .unwrap();
2166            assert_eq!(client.local_revision(), revision + 1);
2167            assert_eq!(client.pending_commit_ids(), vec![id]);
2168            // The overlay keeps the pending write visible; the row now carries
2169            // the server row's version it is layered on (§7.1).
2170            let mut expected = before[0].clone();
2171            expected.insert("_syncular_version".to_owned(), Value::from(2));
2172            assert_eq!(
2173                client.query("SELECT * FROM tasks", &[]).unwrap(),
2174                vec![expected]
2175            );
2176            assert_eq!(
2177                client
2178                    .conn
2179                    .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
2180                        .get::<_, String>(0))
2181                    .unwrap(),
2182                "remote"
2183            );
2184        }
2185    }
2186
2187    #[test]
2188    fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
2189        let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
2190            "columns": [
2191                {"name":"id", "type":"string", "nullable":false},
2192                {"name":"project_id", "type":"string", "nullable":false},
2193                {"name":"title", "type":"string", "nullable":false}],
2194            "scopes": [{"pattern":"project:{project_id}"}],
2195            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2196            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
2197        let mut incremental =
2198            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2199        let mut reference =
2200            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2201        for instance in [&mut incremental, &mut reference] {
2202            for index in 0..16 {
2203                instance
2204                    .write_base_row(
2205                        "tasks",
2206                        &vec![
2207                            Some(ColumnValue::String(format!("task{index}"))),
2208                            Some(ColumnValue::String("p0".to_owned())),
2209                            Some(ColumnValue::String(format!("base{index}"))),
2210                        ],
2211                        1,
2212                    )
2213                    .unwrap();
2214            }
2215            instance.rebuild_overlay_if_dirty();
2216            instance.overlay_rebuild_count.set(0);
2217        }
2218        for index in 0..100 {
2219            let mutations = (0..1 + index % 4)
2220                .map(|offset| {
2221                    let id = format!("task{}", (index + offset) % 24);
2222                    if (index + offset) % 7 == 0 {
2223                        Mutation::Delete {
2224                            table: "tasks".to_owned(),
2225                            row_id: id,
2226                            base_version: None,
2227                        }
2228                    } else {
2229                        Mutation::Upsert {
2230                            table: "tasks".to_owned(),
2231                            values: Map::from_iter([
2232                                ("id".to_owned(), Value::from(id)),
2233                                (
2234                                    "project_id".to_owned(),
2235                                    Value::from(format!("p{}", index % 2)),
2236                                ),
2237                                (
2238                                    "title".to_owned(),
2239                                    Value::from(format!("needle{}", (index + offset) % 24)),
2240                                ),
2241                            ]),
2242                            base_version: None,
2243                        }
2244                    }
2245                })
2246                .collect::<Vec<_>>();
2247            reference.overlay_dirty.set(true);
2248            incremental.mutate(mutations.clone()).unwrap();
2249            reference.mutate(mutations).unwrap();
2250            for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2251                "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
2252                assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
2253            }
2254            assert_eq!(incremental.local_revision(), reference.local_revision());
2255            assert_eq!(
2256                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2257                serde_json::to_value(reference.drain_change_batches()).unwrap()
2258            );
2259        }
2260        assert_eq!(incremental.outbox.len(), 100);
2261        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2262        assert_eq!(reference.overlay_rebuild_count.get(), 100);
2263    }
2264
2265    #[test]
2266    fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
2267        for fail_at in ["outbox", "revision"] {
2268            let mut client = client();
2269            let values = Map::from_iter([
2270                ("id".to_owned(), Value::from("t1")),
2271                ("project_id".to_owned(), Value::from("p1")),
2272            ]);
2273            let mutation = Mutation::Upsert {
2274                table: "tasks".to_owned(),
2275                values,
2276                base_version: None,
2277            };
2278            let trigger = if fail_at == "outbox" {
2279                "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
2280            } else {
2281                format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
2282            };
2283            client.conn.execute_batch(&trigger).unwrap();
2284            let revision = client.local_revision();
2285            assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
2286            assert_eq!(client.local_revision(), revision);
2287            assert!(client.outbox.is_empty());
2288            assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2289            assert_eq!(
2290                client
2291                    .conn
2292                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
2293                        .get::<_, i64>(0))
2294                    .unwrap(),
2295                0
2296            );
2297            assert!(client.drain_change_batches().is_empty());
2298            assert!(!client.overlay_dirty.get());
2299            client
2300                .conn
2301                .execute_batch("DROP TRIGGER fail_append")
2302                .unwrap();
2303            client.mutate(vec![mutation]).unwrap();
2304            assert_eq!(client.outbox.len(), 1);
2305            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
2306        }
2307    }
2308
2309    #[test]
2310    fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
2311        let mut client = client();
2312        let tables = vec!["tasks".to_owned(), "missing".to_owned()];
2313        let values = Map::from_iter([
2314            ("id".to_owned(), Value::from("t1")),
2315            ("project_id".to_owned(), Value::from("p1")),
2316        ]);
2317        let payload = encode_row_json(
2318            client.schema.table("tasks").unwrap(),
2319            "t1",
2320            &values,
2321            &client.encryption,
2322        )
2323        .unwrap();
2324        let first = ssp2::model::Change {
2325            table_index: 0,
2326            row_id: "t1".to_owned(),
2327            op: Op::Upsert,
2328            row_version: Some(1),
2329            scopes: vec![("project_id".to_owned(), "p1".to_owned())],
2330            row: Some(payload),
2331        };
2332        let mut invalid = first.clone();
2333        invalid.table_index = 1;
2334        let revision = client.local_revision();
2335        client.begin_observation("failed_mirror").unwrap();
2336        assert!(client
2337            .apply_commit_changes(&tables, &[first, invalid])
2338            .is_err());
2339        client.rollback_observation("failed_mirror");
2340        client.rebuild_overlay_if_dirty();
2341        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2342        assert_eq!(
2343            client
2344                .conn
2345                .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
2346                    .get::<_, i64>(
2347                    0
2348                ))
2349                .unwrap(),
2350            0
2351        );
2352        assert_eq!(client.local_revision(), revision);
2353        assert!(client.drain_change_batches().is_empty());
2354    }
2355
2356    #[test]
2357    fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
2358        for failure in ["journal", "revision", "commit", "later-frame"] {
2359            let mut client = client();
2360            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2361            for id in ["first", "second"] {
2362                client
2363                    .mutate(vec![Mutation::Upsert {
2364                        table: "tasks".to_owned(),
2365                        values: Map::from_iter([
2366                            ("id".to_owned(), Value::from(id)),
2367                            ("project_id".to_owned(), Value::from("p1")),
2368                        ]),
2369                        base_version: None,
2370                    }])
2371                    .unwrap();
2372            }
2373            let (_, meta) = client.build_request(false);
2374            // A malicious or stale response must not drain an unsent local commit.
2375            client
2376                .mutate(vec![Mutation::Upsert {
2377                    table: "tasks".to_owned(),
2378                    values: Map::from_iter([
2379                        ("id".to_owned(), Value::from("later")),
2380                        ("project_id".to_owned(), Value::from("p1")),
2381                    ]),
2382                    base_version: None,
2383                }])
2384                .unwrap();
2385            let ids = client.pending_commit_ids();
2386            let rows = client
2387                .query("SELECT * FROM tasks ORDER BY id", &[])
2388                .unwrap();
2389            let revision = client.local_revision();
2390            client.drain_change_batches();
2391            if failure != "later-frame" {
2392                client.conn.execute_batch(match failure {
2393                    "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",
2394                    "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",
2395                    _ => "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",
2396                }).unwrap();
2397            }
2398            let mut response = Message {
2399                wire_version: WIRE_VERSION,
2400                msg_kind: MsgKind::Response,
2401                frames: vec![Frame::RespHeader {
2402                    required_schema_version: None,
2403                    latest_schema_version: None,
2404                    log_epoch: Some("epoch-1".to_owned()),
2405                    reset_required: Some(false),
2406                }],
2407            };
2408            for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
2409                response.frames.push(Frame::PushResult {
2410                    client_commit_id: id.to_owned(),
2411                    status: PushStatus::Applied,
2412                    commit_seq: Some(1),
2413                    results: vec![OpResult::Applied { op_index: 0 }],
2414                });
2415            }
2416            if failure == "later-frame" {
2417                response.frames.push(Frame::Error {
2418                    code: "sync.invalid_request".to_owned(),
2419                    message: "later error".to_owned(),
2420                    category: "protocol".to_owned(),
2421                    retryable: false,
2422                    recommended_action: "retry".to_owned(),
2423                    details: None,
2424                });
2425            }
2426            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2427            let outcome = client.process_response(&mut transport, response.clone(), &meta);
2428            assert!(
2429                matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
2430            );
2431            if failure == "later-frame" {
2432                assert_eq!(client.pending_commit_ids(), ids[2..]);
2433                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2434                assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
2435                let batches = client.drain_change_batches();
2436                assert_eq!(batches.len(), 1);
2437                assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
2438                assert_eq!(client.local_revision(), revision + 1);
2439                response.frames.pop();
2440            } else {
2441                assert_eq!(client.pending_commit_ids(), ids);
2442                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2443                assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
2444                assert_eq!(client.local_revision(), revision);
2445                assert!(client.drain_change_batches().is_empty());
2446                assert_eq!(
2447                    client
2448                        .query("SELECT * FROM tasks ORDER BY id", &[])
2449                        .unwrap(),
2450                    rows
2451                );
2452                client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
2453            }
2454            for frame in &mut response.frames {
2455                if let Frame::PushResult { status, .. } = frame {
2456                    *status = PushStatus::Cached;
2457                }
2458            }
2459            let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
2460            else {
2461                panic!("retry failed");
2462            };
2463            assert_eq!(
2464                report.applied,
2465                if failure == "later-frame" {
2466                    vec![]
2467                } else {
2468                    ids[..2].to_vec()
2469                }
2470            );
2471            assert_eq!(client.pending_commit_ids(), ids[2..]);
2472            let batches = client.drain_change_batches();
2473            assert_eq!(batches.len(), usize::from(failure != "later-frame"));
2474            if let Some(batch) = batches.first() {
2475                assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
2476            }
2477            assert_eq!(client.local_revision(), revision + 1);
2478        }
2479    }
2480
2481    #[test]
2482    fn failed_push_result_preserves_memory_disk_and_retry_identity() {
2483        for failure in ["revision", "journal", "commit"] {
2484            for status in ["applied", "cached", "rejected", "conflict"] {
2485                let mut client = client();
2486                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2487                for id in ["first", "later"] {
2488                    client
2489                        .mutate(vec![Mutation::Upsert {
2490                            table: "tasks".to_owned(),
2491                            values: Map::from_iter([
2492                                ("id".to_owned(), Value::from(id)),
2493                                ("project_id".to_owned(), Value::from("p1")),
2494                            ]),
2495                            base_version: None,
2496                        }])
2497                        .unwrap();
2498                }
2499                let (_, meta) = client.build_request(false);
2500                let ids = client.pending_commit_ids();
2501                let rows = client
2502                    .query("SELECT * FROM tasks ORDER BY id", &[])
2503                    .unwrap();
2504                let revision = client.local_revision();
2505                client.drain_change_batches();
2506                let (push_status, results) = match status {
2507                    "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
2508                    "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
2509                    "conflict" => (
2510                        PushStatus::Rejected,
2511                        vec![OpResult::Conflict {
2512                            op_index: 0,
2513                            code: "sync.version_conflict".to_owned(),
2514                            message: "conflict".to_owned(),
2515                            server_version: 1,
2516                            server_row: encode_row_json(
2517                                client.schema.table("tasks").unwrap(),
2518                                "first",
2519                                &Map::from_iter([
2520                                    ("id".to_owned(), Value::from("first")),
2521                                    ("project_id".to_owned(), Value::from("p1")),
2522                                ]),
2523                                &client.encryption,
2524                            )
2525                            .unwrap(),
2526                            conflict_columns: Vec::new(),
2527                        }],
2528                    ),
2529                    _ => (
2530                        PushStatus::Rejected,
2531                        vec![OpResult::Error {
2532                            op_index: 0,
2533                            code: "sync.validation_failed".to_owned(),
2534                            message: "rejected".to_owned(),
2535                            retryable: false,
2536                        }],
2537                    ),
2538                };
2539                let response = Message {
2540                    wire_version: WIRE_VERSION,
2541                    msg_kind: MsgKind::Response,
2542                    frames: vec![
2543                        Frame::RespHeader {
2544                            required_schema_version: None,
2545                            latest_schema_version: None,
2546                            log_epoch: Some("epoch-1".to_owned()),
2547                            reset_required: Some(false),
2548                        },
2549                        Frame::PushResult {
2550                            client_commit_id: ids[0].clone(),
2551                            status: push_status,
2552                            commit_seq: Some(1),
2553                            results,
2554                        },
2555                    ],
2556                };
2557                client.conn.execute_batch(match failure {
2558                    "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
2559                    "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
2560                    _ => "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",
2561                }).unwrap();
2562                let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2563                let outcome = client.process_response(&mut transport, response.clone(), &meta);
2564                assert_eq!(
2565                    client.pending_commit_ids(),
2566                    ids,
2567                    "{failure}/{status}: pending identity"
2568                );
2569                assert_eq!(
2570                    client.local_revision(),
2571                    revision,
2572                    "{failure}/{status}: revision"
2573                );
2574                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2575                assert!(client.conflicts().is_empty());
2576                assert!(client.rejections().is_empty());
2577                assert!(client.drain_change_batches().is_empty());
2578                assert_eq!(
2579                    client
2580                        .query("SELECT * FROM tasks ORDER BY id", &[])
2581                        .unwrap(),
2582                    rows
2583                );
2584                assert!(
2585                    matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
2586                    "{failure}/{status}: failed round"
2587                );
2588                client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
2589                assert!(matches!(
2590                    client.process_response(&mut transport, response, &meta),
2591                    SyncOutcome::Ok(_)
2592                ));
2593                assert_eq!(client.pending_commit_ids(), ids[1..]);
2594                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2595                assert_eq!(client.drain_change_batches().len(), 1);
2596            }
2597        }
2598    }
2599
2600    #[test]
2601    fn mixed_push_results_reconcile_and_prune_once_per_response() {
2602        let mut client = client();
2603        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2604        for index in 0..4 {
2605            client
2606                .mutate(vec![Mutation::Upsert {
2607                    table: "tasks".to_owned(),
2608                    values: Map::from_iter([
2609                        ("id".to_owned(), Value::from(format!("task-{index}"))),
2610                        ("project_id".to_owned(), Value::from("project-1")),
2611                    ]),
2612                    base_version: None,
2613                }])
2614                .expect("queue commit");
2615        }
2616
2617        let (_, request_meta) = client.build_request(false);
2618        let ids = &request_meta.pushed_ids;
2619        assert_eq!(ids.len(), 4);
2620        let response = Message {
2621            wire_version: WIRE_VERSION,
2622            msg_kind: MsgKind::Response,
2623            frames: vec![
2624                Frame::RespHeader {
2625                    required_schema_version: None,
2626                    latest_schema_version: None,
2627                    log_epoch: Some("epoch-1".to_owned()),
2628                    reset_required: Some(false),
2629                },
2630                Frame::PushResult {
2631                    client_commit_id: ids[0].clone(),
2632                    status: PushStatus::Applied,
2633                    commit_seq: Some(1),
2634                    results: vec![OpResult::Applied { op_index: 0 }],
2635                },
2636                Frame::PushResult {
2637                    client_commit_id: ids[1].clone(),
2638                    status: PushStatus::Cached,
2639                    commit_seq: Some(2),
2640                    results: vec![OpResult::Applied { op_index: 0 }],
2641                },
2642                Frame::PushResult {
2643                    client_commit_id: ids[2].clone(),
2644                    status: PushStatus::Rejected,
2645                    commit_seq: None,
2646                    results: vec![OpResult::Error {
2647                        op_index: 0,
2648                        code: "sync.validation_failed".to_owned(),
2649                        message: "rejected".to_owned(),
2650                        retryable: false,
2651                    }],
2652                },
2653                Frame::PushResult {
2654                    client_commit_id: ids[3].clone(),
2655                    status: PushStatus::Rejected,
2656                    commit_seq: None,
2657                    results: vec![OpResult::Error {
2658                        op_index: 0,
2659                        code: "sync.idempotency_cache_miss".to_owned(),
2660                        message: "retry".to_owned(),
2661                        retryable: true,
2662                    }],
2663                },
2664            ],
2665        };
2666        let mut transport =
2667            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
2668
2669        client.overlay_rebuild_count.set(0);
2670        client.outcome_prune_count.set(0);
2671        let outcome = client.process_response(&mut transport, response, &request_meta);
2672        let SyncOutcome::Ok(report) = outcome else {
2673            panic!("mixed push-result response failed");
2674        };
2675
2676        assert_eq!(report.applied, ids[..2]);
2677        assert_eq!(report.rejected, ids[2..3]);
2678        assert_eq!(report.retryable, ids[3..4]);
2679        assert_eq!(client.pending_commit_ids(), ids[3..4]);
2680        assert_eq!(
2681            client
2682                .query("SELECT id FROM tasks ORDER BY id", &[])
2683                .expect("query visible overlay"),
2684            vec![Map::from_iter([("id".to_owned(), Value::from("task-3"))])]
2685        );
2686        assert_eq!(client.overlay_rebuild_count.get(), 1);
2687        assert_eq!(client.outcome_prune_count.get(), 1);
2688    }
2689
2690    #[test]
2691    fn secondary_unique_collision_preserves_existing_synced_row() {
2692        let client = SyncClient::new(
2693            "unique-upsert-test".to_owned(),
2694            &json!({
2695                "version": 1,
2696                "tables": [{
2697                    "name": "tasks",
2698                    "primaryKey": "id",
2699                    "columns": [
2700                        { "name": "id", "type": "string", "nullable": false },
2701                        { "name": "project_id", "type": "string", "nullable": false },
2702                        { "name": "title", "type": "string", "nullable": false }
2703                    ],
2704                    "scopes": [{ "pattern": "project:{project_id}" }],
2705                    "indexes": [{
2706                        "name": "tasks_by_project_title",
2707                        "columns": ["project_id", "title"],
2708                        "unique": true
2709                    }]
2710                }]
2711            }),
2712            ClientLimits::default(),
2713        )
2714        .expect("test client");
2715        let table = client.schema.table("tasks").expect("tasks table");
2716        let sql = client.insert_row_sql(&base_table("tasks"), table);
2717
2718        client
2719            .conn
2720            .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
2721            .expect("insert first row");
2722        client
2723            .conn
2724            .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
2725            .expect("update same primary key");
2726        client
2727            .conn
2728            .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
2729            .expect("insert second row");
2730        assert!(client
2731            .conn
2732            .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
2733            .is_err());
2734
2735        let rows = client
2736            .conn
2737            .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
2738            .expect("prepare rows")
2739            .query_map([], |row| {
2740                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2741            })
2742            .expect("query rows")
2743            .collect::<Result<Vec<_>, _>>()
2744            .expect("collect rows");
2745        assert_eq!(
2746            rows,
2747            vec![
2748                ("t1".to_owned(), "updated".to_owned()),
2749                ("t2".to_owned(), "original".to_owned())
2750            ]
2751        );
2752    }
2753
2754    #[test]
2755    fn reopening_active_subscriptions_emits_a_catch_up_intent() {
2756        let path = std::env::temp_dir().join(format!(
2757            "syncular-startup-intent-{}.db",
2758            uuid::Uuid::new_v4()
2759        ));
2760        let schema = json!({
2761            "version": 1,
2762            "tables": [{
2763                "name": "tasks",
2764                "primaryKey": "id",
2765                "columns": [
2766                    { "name": "id", "type": "string", "nullable": false },
2767                    { "name": "project_id", "type": "string", "nullable": false }
2768                ],
2769                "scopes": [{ "pattern": "project:{project_id}" }]
2770            }]
2771        });
2772
2773        {
2774            let mut first = SyncClient::open_path_with_identity(
2775                None,
2776                &schema,
2777                ClientLimits::default(),
2778                path.to_str().expect("UTF-8 temp path"),
2779            )
2780            .expect("first open");
2781            first
2782                .set_window(
2783                    &WindowBase {
2784                        table: "tasks".to_owned(),
2785                        variable: "project_id".to_owned(),
2786                        fixed_scopes: Vec::new(),
2787                        params: None,
2788                    },
2789                    &["persisted".to_owned()],
2790                )
2791                .expect("persist window");
2792        }
2793
2794        let mut reopened = SyncClient::open_path_with_identity(
2795            None,
2796            &schema,
2797            ClientLimits::default(),
2798            path.to_str().expect("UTF-8 temp path"),
2799        )
2800        .expect("reopen");
2801        assert!(reopened.sync_needed());
2802        assert!(matches!(
2803            reopened.drain_sync_intents().as_slice(),
2804            [SyncIntent::Interactive]
2805        ));
2806        drop(reopened);
2807        std::fs::remove_file(path).expect("remove temp database");
2808    }
2809
2810    #[test]
2811    fn reopening_preserves_immutable_subscription_identity_and_progress() {
2812        let path = std::env::temp_dir().join(format!(
2813            "syncular-subscription-identity-{}.db",
2814            uuid::Uuid::new_v4()
2815        ));
2816        let schema = json!({
2817            "version": 1,
2818            "tables": [
2819                {
2820                    "name": "tasks",
2821                    "primaryKey": "id",
2822                    "columns": [
2823                        { "name": "id", "type": "string", "nullable": false },
2824                        { "name": "project_id", "type": "string", "nullable": false }
2825                    ],
2826                    "scopes": [{ "pattern": "project:{project_id}" }]
2827                },
2828                {
2829                    "name": "docs",
2830                    "primaryKey": "id",
2831                    "columns": [
2832                        { "name": "id", "type": "string", "nullable": false },
2833                        { "name": "org_id", "type": "string", "nullable": false },
2834                        { "name": "project_id", "type": "string", "nullable": false }
2835                    ],
2836                    "scopes": [
2837                        { "pattern": "org:{org_id}" },
2838                        { "pattern": "project:{projectId}", "column": "project_id" }
2839                    ]
2840                }
2841            ]
2842        });
2843
2844        {
2845            let mut first = SyncClient::open_path_with_identity(
2846                None,
2847                &schema,
2848                ClientLimits::default(),
2849                path.to_str().expect("UTF-8 temp path"),
2850            )
2851            .expect("first open");
2852            first
2853                .subscribe(
2854                    "stable-subscription".to_owned(),
2855                    "tasks".to_owned(),
2856                    vec![(
2857                        "project_id".to_owned(),
2858                        vec!["p2".to_owned(), "p1".to_owned()],
2859                    )],
2860                    Some(r#"{"view":"v1"}"#.to_owned()),
2861                )
2862                .expect("persist subscription");
2863            let persisted = {
2864                let subscription = first
2865                    .subs
2866                    .iter_mut()
2867                    .find(|subscription| subscription.id == "stable-subscription")
2868                    .expect("subscription");
2869                subscription.cursor = 41;
2870                subscription.bootstrap_state = Some("resume-token".to_owned());
2871                subscription.effective = Some(vec![(
2872                    "project_id".to_owned(),
2873                    vec!["p1".to_owned(), "p2".to_owned()],
2874                )]);
2875                subscription.synced_once = true;
2876                subscription.clone()
2877            };
2878            first.persist_sub(&persisted).unwrap();
2879        }
2880
2881        let mut reopened = SyncClient::open_path_with_identity(
2882            None,
2883            &schema,
2884            ClientLimits::default(),
2885            path.to_str().expect("UTF-8 temp path"),
2886        )
2887        .expect("reopen");
2888        let progress = reopened
2889            .subscription_state("stable-subscription")
2890            .expect("persisted state");
2891        assert_eq!(progress.cursor, 41);
2892        assert!(progress.has_resume_token);
2893
2894        reopened
2895            .subscribe(
2896                "stable-subscription".to_owned(),
2897                "tasks".to_owned(),
2898                vec![(
2899                    "project_id".to_owned(),
2900                    vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
2901                )],
2902                Some(r#"{"view":"v1"}"#.to_owned()),
2903            )
2904            .expect("canonical intent is idempotent");
2905        assert_eq!(
2906            reopened
2907                .subscription_state("stable-subscription")
2908                .expect("unchanged state")
2909                .cursor,
2910            progress.cursor
2911        );
2912
2913        for (table, scopes, params) in [
2914            (
2915                "tasks",
2916                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2917                Some(r#"{"view":"v1"}"#.to_owned()),
2918            ),
2919            (
2920                "tasks",
2921                vec![(
2922                    "project_id".to_owned(),
2923                    vec!["p2".to_owned(), "p1".to_owned()],
2924                )],
2925                Some(r#"{"view":"v2"}"#.to_owned()),
2926            ),
2927            (
2928                "docs",
2929                vec![
2930                    ("org_id".to_owned(), vec!["o1".to_owned()]),
2931                    ("projectId".to_owned(), vec!["p1".to_owned()]),
2932                ],
2933                Some(r#"{"view":"v1"}"#.to_owned()),
2934            ),
2935        ] {
2936            let error = reopened
2937                .subscribe(
2938                    "stable-subscription".to_owned(),
2939                    table.to_owned(),
2940                    scopes,
2941                    params,
2942                )
2943                .expect_err("identity rebind must fail");
2944            assert!(error.starts_with("client.subscription_intent_mismatch:"));
2945            assert_eq!(
2946                reopened
2947                    .subscription_state("stable-subscription")
2948                    .expect("unchanged state")
2949                    .cursor,
2950                progress.cursor
2951            );
2952        }
2953
2954        drop(reopened);
2955        std::fs::remove_file(path).expect("remove temp database");
2956    }
2957
2958    #[test]
2959    fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
2960        let path = std::env::temp_dir().join(format!(
2961            "syncular-satisfied-schema-floor-{}.db",
2962            uuid::Uuid::new_v4()
2963        ));
2964        let schema = json!({
2965            "version": 23,
2966            "tables": [{
2967                "name": "tasks",
2968                "primaryKey": "id",
2969                "columns": [
2970                    { "name": "id", "type": "string", "nullable": false },
2971                    { "name": "project_id", "type": "string", "nullable": false }
2972                ],
2973                "scopes": [{ "pattern": "project:{project_id}" }]
2974            }]
2975        });
2976
2977        {
2978            let mut first = SyncClient::open_path_with_identity(
2979                None,
2980                &schema,
2981                ClientLimits::default(),
2982                path.to_str().expect("UTF-8 temp path"),
2983            )
2984            .expect("first open");
2985            first
2986                .subscribe(
2987                    "tasks".to_owned(),
2988                    "tasks".to_owned(),
2989                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2990                    None,
2991                )
2992                .expect("persist subscription");
2993            first.set_schema_floor(Some(SchemaFloor {
2994                required_schema_version: Some(22),
2995                latest_schema_version: Some(22),
2996            }));
2997        }
2998
2999        let mut reopened = SyncClient::open_path_with_identity(
3000            None,
3001            &schema,
3002            ClientLimits::default(),
3003            path.to_str().expect("UTF-8 temp path"),
3004        )
3005        .expect("reopen");
3006        assert!(reopened.schema_floor().is_none());
3007        assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
3008        assert!(reopened.sync_needed());
3009        assert!(matches!(
3010            reopened.drain_sync_intents().as_slice(),
3011            [SyncIntent::Interactive]
3012        ));
3013        drop(reopened);
3014        std::fs::remove_file(path).expect("remove temp database");
3015    }
3016
3017    #[test]
3018    fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
3019        let path = std::env::temp_dir().join(format!(
3020            "syncular-unsatisfied-schema-floor-{}.db",
3021            uuid::Uuid::new_v4()
3022        ));
3023        let schema = json!({
3024            "version": 1,
3025            "tables": [{
3026                "name": "tasks",
3027                "primaryKey": "id",
3028                "columns": [
3029                    { "name": "id", "type": "string", "nullable": false },
3030                    { "name": "project_id", "type": "string", "nullable": false }
3031                ],
3032                "scopes": [{ "pattern": "project:{project_id}" }]
3033            }]
3034        });
3035
3036        {
3037            let mut first = SyncClient::open_path_with_identity(
3038                None,
3039                &schema,
3040                ClientLimits::default(),
3041                path.to_str().expect("UTF-8 temp path"),
3042            )
3043            .expect("first open");
3044            first
3045                .subscribe(
3046                    "tasks".to_owned(),
3047                    "tasks".to_owned(),
3048                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3049                    None,
3050                )
3051                .expect("persist subscription");
3052            first.set_schema_floor(Some(SchemaFloor {
3053                required_schema_version: Some(2),
3054                latest_schema_version: Some(2),
3055            }));
3056        }
3057
3058        let reopened = SyncClient::open_path_with_identity(
3059            None,
3060            &schema,
3061            ClientLimits::default(),
3062            path.to_str().expect("UTF-8 temp path"),
3063        )
3064        .expect("reopen");
3065        assert_eq!(
3066            reopened.schema_floor(),
3067            Some(&SchemaFloor {
3068                required_schema_version: Some(2),
3069                latest_schema_version: Some(2),
3070            })
3071        );
3072        assert!(!reopened.sync_needed());
3073        drop(reopened);
3074        std::fs::remove_file(path).expect("remove temp database");
3075    }
3076
3077    #[test]
3078    fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
3079        let path = std::env::temp_dir().join(format!(
3080            "syncular-indexed-column-bump-{}.db",
3081            uuid::Uuid::new_v4()
3082        ));
3083        let old_schema = json!({
3084            "version": 1,
3085            "tables": [
3086                {
3087                    "name": "tasks",
3088                    "primaryKey": "id",
3089                    "columns": [
3090                        { "name": "id", "type": "string", "nullable": false },
3091                        { "name": "project_id", "type": "string", "nullable": false }
3092                    ],
3093                    "scopes": [{ "pattern": "project:{project_id}" }]
3094                },
3095                {
3096                    "name": "legacy",
3097                    "primaryKey": "id",
3098                    "columns": [
3099                        { "name": "id", "type": "string", "nullable": false },
3100                        { "name": "project_id", "type": "string", "nullable": false }
3101                    ],
3102                    "scopes": [{ "pattern": "project:{project_id}" }]
3103                }
3104            ]
3105        });
3106        {
3107            let mut first = SyncClient::open_path_with_identity(
3108                None,
3109                &old_schema,
3110                ClientLimits::default(),
3111                path.to_str().expect("UTF-8 temp path"),
3112            )
3113            .expect("open old schema");
3114            first
3115                .subscribe(
3116                    "legacy-sub".to_owned(),
3117                    "legacy".to_owned(),
3118                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3119                    None,
3120                )
3121                .expect("persist legacy subscription");
3122        }
3123
3124        let new_schema = json!({
3125            "version": 2,
3126            "tables": [{
3127                "name": "tasks",
3128                "primaryKey": "id",
3129                "columns": [
3130                    { "name": "id", "type": "string", "nullable": false },
3131                    { "name": "project_id", "type": "string", "nullable": false },
3132                    { "name": "facility_membership_id", "type": "string", "nullable": true }
3133                ],
3134                "scopes": [{ "pattern": "project:{project_id}" }],
3135                "indexes": [{
3136                    "name": "tasks_by_membership",
3137                    "columns": ["project_id", "facility_membership_id"],
3138                    "unique": false
3139                }]
3140            }]
3141        });
3142        let upgraded = SyncClient::open_path_with_identity(
3143            None,
3144            &new_schema,
3145            ClientLimits::default(),
3146            path.to_str().expect("UTF-8 temp path"),
3147        )
3148        .expect("open upgraded schema");
3149        let columns = upgraded
3150            .conn
3151            .prepare("PRAGMA table_info(tasks)")
3152            .expect("prepare columns")
3153            .query_map([], |row| row.get::<_, String>(1))
3154            .expect("query columns")
3155            .collect::<Result<Vec<_>, _>>()
3156            .expect("collect columns");
3157        assert!(columns.contains(&"facility_membership_id".to_owned()));
3158        let index_count: i64 = upgraded
3159            .conn
3160            .query_row(
3161                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
3162                [],
3163                |row| row.get(0),
3164            )
3165            .expect("query index");
3166        assert_eq!(index_count, 1);
3167        assert!(upgraded.subscription_state("legacy-sub").is_none());
3168        drop(upgraded);
3169        std::fs::remove_file(path).expect("remove temp database");
3170    }
3171
3172    #[test]
3173    fn migrates_pre_envelope_outcome_journal_additively() {
3174        let path = std::env::temp_dir().join(format!(
3175            "syncular-outcome-migration-{}.db",
3176            uuid::Uuid::new_v4()
3177        ));
3178        let conn = Connection::open(&path).expect("open legacy database");
3179        conn.execute_batch(
3180            "CREATE TABLE _syncular_commit_outcomes (
3181               seq INTEGER PRIMARY KEY AUTOINCREMENT,
3182               client_commit_id TEXT NOT NULL UNIQUE,
3183               status TEXT NOT NULL,
3184               recorded_at_ms INTEGER NOT NULL,
3185               results_json TEXT NOT NULL,
3186               resolution TEXT NOT NULL DEFAULT 'active',
3187               resolved_at_ms INTEGER,
3188               replacement_client_commit_id TEXT);",
3189        )
3190        .expect("create legacy outcome journal");
3191        drop(conn);
3192        let schema = json!({
3193            "version": 1,
3194            "tables": [{
3195                "name": "tasks",
3196                "primaryKey": "id",
3197                "columns": [
3198                    { "name": "id", "type": "string", "nullable": false },
3199                    { "name": "project_id", "type": "string", "nullable": false }
3200                ],
3201                "scopes": [{ "pattern": "project:{project_id}" }]
3202            }]
3203        });
3204        let client = SyncClient::open_path(
3205            "migration-native".to_owned(),
3206            &schema,
3207            ClientLimits::default(),
3208            path.to_str().expect("UTF-8 temp path"),
3209        )
3210        .expect("migrate database");
3211        let has_operations = client
3212            .conn
3213            .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
3214            .expect("prepare table info")
3215            .query_map([], |row| row.get::<_, String>(1))
3216            .expect("query table info")
3217            .filter_map(Result::ok)
3218            .any(|column| column == "operations_json");
3219        assert!(has_operations);
3220        drop(client);
3221        std::fs::remove_file(path).expect("remove temp database");
3222    }
3223
3224    #[test]
3225    fn durable_conflict_outcome_and_resolution_survive_reopen() {
3226        let path = std::env::temp_dir().join(format!(
3227            "syncular-durable-outcome-{}.db",
3228            uuid::Uuid::new_v4()
3229        ));
3230        let schema = json!({
3231            "version": 1,
3232            "tables": [{
3233                "name": "tasks",
3234                "primaryKey": "id",
3235                "columns": [
3236                    { "name": "id", "type": "string", "nullable": false },
3237                    { "name": "project_id", "type": "string", "nullable": false }
3238                ],
3239                "scopes": [{ "pattern": "project:{project_id}" }]
3240            }]
3241        });
3242        let conflict = ConflictRecord {
3243            client_commit_id: "losing-commit".to_owned(),
3244            op_index: 0,
3245            table: "tasks".to_owned(),
3246            row_id: "t1".to_owned(),
3247            code: "sync.version_conflict".to_owned(),
3248            message: "stale base version".to_owned(),
3249            server_version: 2,
3250            server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
3251            conflict_columns: vec!["project_id".to_owned()],
3252            operation: Some(CommitOperation {
3253                table: "tasks".to_owned(),
3254                row_id: "t1".to_owned(),
3255                op: "upsert".to_owned(),
3256                base_version: Some(1),
3257                values: None,
3258            }),
3259        };
3260        let failed_operations = vec![
3261            OutboxOp {
3262                upsert: true,
3263                table: "tasks".to_owned(),
3264                row_id: "t1".to_owned(),
3265                base_version: Some(1),
3266                values: None,
3267            },
3268            OutboxOp {
3269                upsert: true,
3270                table: "tasks".to_owned(),
3271                row_id: "status-event-1".to_owned(),
3272                base_version: Some(0),
3273                values: None,
3274            },
3275        ];
3276
3277        {
3278            let mut first = SyncClient::open_path(
3279                "durable-native".to_owned(),
3280                &schema,
3281                ClientLimits::default(),
3282                path.to_str().expect("UTF-8 temp path"),
3283            )
3284            .expect("first open");
3285            first
3286                .begin_observation("test_outcome")
3287                .expect("begin outcome");
3288            first
3289                .persist_commit_outcome(
3290                    "losing-commit",
3291                    CommitOutcomeStatus::Conflict,
3292                    &[CommitOperationOutcome::Conflict {
3293                        conflict: conflict.clone(),
3294                    }],
3295                    Some(&failed_operations),
3296                )
3297                .expect("persist outcome");
3298            first.conflicts.push(conflict);
3299            first
3300                .finish_observation(
3301                    "test_outcome",
3302                    ChangeAccumulator {
3303                        conflicts: true,
3304                        outcomes: true,
3305                        ..ChangeAccumulator::default()
3306                    },
3307                )
3308                .expect("commit outcome");
3309        }
3310
3311        {
3312            let mut reopened = SyncClient::open_path(
3313                "durable-native".to_owned(),
3314                &schema,
3315                ClientLimits::default(),
3316                path.to_str().expect("UTF-8 temp path"),
3317            )
3318            .expect("reopen");
3319            assert_eq!(reopened.conflicts().len(), 1);
3320            assert_eq!(
3321                reopened.conflicts()[0].conflict_columns,
3322                vec!["project_id".to_owned()],
3323                "conflictColumns survives the journal round trip"
3324            );
3325            let outcome = reopened
3326                .commit_outcome("losing-commit")
3327                .expect("read outcome")
3328                .expect("outcome");
3329            assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
3330            let operations = outcome.operations.expect("aggregate envelope");
3331            assert_eq!(operations.len(), 2);
3332            assert_eq!(operations[1].row_id, "status-event-1");
3333            let resolved = reopened
3334                .resolve_commit_outcome(ResolveCommitOutcomeInput {
3335                    client_commit_id: "losing-commit".to_owned(),
3336                    resolution: CommitOutcomeResolution::ResolvedKeepServer,
3337                    replacement_client_commit_id: None,
3338                })
3339                .expect("resolve");
3340            assert_eq!(
3341                resolved.resolution,
3342                CommitOutcomeResolution::ResolvedKeepServer
3343            );
3344            assert!(reopened.conflicts().is_empty());
3345        }
3346
3347        let reopened = SyncClient::open_path(
3348            "durable-native".to_owned(),
3349            &schema,
3350            ClientLimits::default(),
3351            path.to_str().expect("UTF-8 temp path"),
3352        )
3353        .expect("second reopen");
3354        assert!(reopened.conflicts().is_empty());
3355        assert_eq!(
3356            reopened
3357                .commit_outcome("losing-commit")
3358                .expect("read outcome")
3359                .expect("outcome")
3360                .resolution,
3361            CommitOutcomeResolution::ResolvedKeepServer
3362        );
3363        drop(reopened);
3364        std::fs::remove_file(path).expect("remove temp database");
3365    }
3366
3367    #[test]
3368    fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
3369        let path =
3370            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
3371        let schema = json!({
3372            "version": 1,
3373            "tables": [{
3374                "name": "tasks",
3375                "primaryKey": "id",
3376                "columns": [
3377                    { "name": "id", "type": "string", "nullable": false },
3378                    { "name": "project_id", "type": "string", "nullable": false }
3379                ],
3380                "scopes": [{ "pattern": "project:{project_id}" }]
3381            }]
3382        });
3383        let mut client = SyncClient::open_path_with_identity(
3384            Some("sidecar-client".to_owned()),
3385            &schema,
3386            ClientLimits::default(),
3387            path.to_str().expect("UTF-8 temp path"),
3388        )
3389        .expect("open owner");
3390        let base = WindowBase {
3391            table: "tasks".to_owned(),
3392            variable: "project_id".to_owned(),
3393            fixed_scopes: Vec::new(),
3394            params: None,
3395        };
3396        client
3397            .set_window(&base, &["one".to_owned()])
3398            .expect("set window");
3399        client
3400            .mutate(vec![Mutation::Upsert {
3401                table: "tasks".to_owned(),
3402                values: Map::from_iter([
3403                    ("id".to_owned(), Value::from("t1")),
3404                    ("project_id".to_owned(), Value::from("one")),
3405                ]),
3406                base_version: None,
3407            }])
3408            .expect("local mutate");
3409
3410        let coverage = [WindowCoverage {
3411            base,
3412            units: vec!["one".to_owned(), "missing".to_owned()],
3413        }];
3414        let owner = client
3415            .query_snapshot(
3416                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3417                &[],
3418                &coverage,
3419                None,
3420            )
3421            .expect("owner snapshot");
3422        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
3423        let sidecar = reader
3424            .query_snapshot(
3425                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3426                &[],
3427                &coverage,
3428            )
3429            .expect("sidecar snapshot");
3430
3431        assert_eq!(sidecar.revision, owner.revision);
3432        assert_eq!(sidecar.rows, owner.rows);
3433        assert_eq!(
3434            serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
3435            serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
3436        );
3437        assert_eq!(sidecar.revision, "2");
3438        assert_eq!(sidecar.rows[0]["id"], "t1");
3439        assert_eq!(sidecar.rows[0]["server_version"], -1);
3440        assert!(!sidecar.coverage.complete);
3441        assert_eq!(sidecar.coverage.pending.len(), 1);
3442        assert_eq!(sidecar.coverage.missing.len(), 1);
3443
3444        drop(reader);
3445        drop(client);
3446        std::fs::remove_file(path).expect("remove temp database");
3447    }
3448
3449    #[test]
3450    fn owned_query_failures_classify_order_bound_and_clear() {
3451        let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
3452            rusqlite::ffi::Error::new(266),
3453            Some("private SQLite prose".to_owned()),
3454        ));
3455        assert_eq!(io.code, Some("client.storage_io"));
3456        assert_eq!(io.sqlite_code, Some(266));
3457        assert_eq!(io.message, "local SQLite storage I/O failed");
3458        let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
3459            rusqlite::ffi::Error::new(26),
3460            None,
3461        ));
3462        assert_eq!(corrupt.code, Some("client.storage_corrupt"));
3463
3464        let mut client = client();
3465        let generic = QueryReadFailure::from("private query prose".to_owned());
3466        client.record_query_read(
3467            QueryOwner {
3468                id: "first",
3469                tables: &["tasks", "tasks"],
3470            },
3471            Some(&generic),
3472        );
3473        client.record_query_read(
3474            QueryOwner {
3475                id: "second",
3476                tables: &["tasks"],
3477            },
3478            Some(&generic),
3479        );
3480        client.record_query_read(
3481            QueryOwner {
3482                id: "first",
3483                tables: &["docs"],
3484            },
3485            Some(&io),
3486        );
3487        let failures = &client
3488            .diagnostics_snapshot(&Default::default())
3489            .expect("diagnostics")
3490            .query_failures;
3491        assert_eq!(
3492            failures
3493                .iter()
3494                .map(|entry| entry.id.as_str())
3495                .collect::<Vec<_>>(),
3496            vec!["second", "first"]
3497        );
3498        assert_eq!(failures[1].tables, vec!["docs"]);
3499        assert_eq!(failures[1].code, "client.storage_io");
3500
3501        for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
3502            client.record_query_read(
3503                QueryOwner {
3504                    id: &format!("query-{index}"),
3505                    tables: &["tasks"],
3506                },
3507                Some(&generic),
3508            );
3509        }
3510        let failures = &client
3511            .diagnostics_snapshot(&Default::default())
3512            .expect("bounded diagnostics")
3513            .query_failures;
3514        assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
3515        assert_eq!(failures.last().expect("last failure").id, "query-256");
3516
3517        client
3518            .query_snapshot(
3519                "SELECT id FROM tasks",
3520                &[],
3521                &[],
3522                Some(QueryOwner {
3523                    id: "query-256",
3524                    tables: &["tasks"],
3525                }),
3526            )
3527            .expect("successful owned read");
3528        assert!(client
3529            .diagnostics_snapshot(&Default::default())
3530            .expect("cleared diagnostics")
3531            .query_failures
3532            .iter()
3533            .all(|entry| entry.id != "query-256"));
3534    }
3535
3536    #[test]
3537    fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
3538        let mut client = client();
3539        let image = Connection::open_in_memory().unwrap();
3540        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
3541            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3542            INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
3543            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3544            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
3545        let table = client.schema.table("tasks").unwrap().clone();
3546        let scopes = vec![("project_id".into(), vec!["p1".into()])];
3547        assert!(client
3548            .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
3549            .is_err());
3550        assert!(client.conn.is_autocommit());
3551        assert_eq!(
3552            client
3553                .query("SELECT count(*) AS n FROM tasks", &[])
3554                .unwrap()[0]["n"],
3555            1024
3556        );
3557        assert_eq!(client.drain_change_batches().len(), 1);
3558        image
3559            .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
3560            .unwrap();
3561        assert!(matches!(
3562            client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
3563            Ok(2050)
3564        ));
3565        assert_eq!(
3566            client
3567                .query("SELECT count(*) AS n FROM tasks", &[])
3568                .unwrap()[0]["n"],
3569            2050
3570        );
3571        assert_eq!(client.drain_change_batches().len(), 3);
3572    }
3573
3574    #[test]
3575    fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
3576        let mut client = client();
3577        let base = WindowBase {
3578            table: "tasks".into(),
3579            variable: "project_id".into(),
3580            fixed_scopes: Vec::new(),
3581            params: None,
3582        };
3583        client
3584            .set_window(&base, &["p1".into(), "p2".into()])
3585            .unwrap();
3586        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3587            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3588            INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
3589            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3590            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3591        client.drain_change_batches();
3592        assert!(client.set_window(&base, &["p2".into()]).is_err());
3593        assert!(client.conn.is_autocommit());
3594        assert_eq!(
3595            client
3596                .query("SELECT count(*) AS n FROM tasks", &[])
3597                .unwrap()[0]["n"],
3598            1027
3599        );
3600        assert_eq!(client.window_state(&base).units, vec!["p2"]);
3601        assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
3602        assert_eq!(client.drain_change_batches().len(), 1);
3603        client
3604            .conn
3605            .execute_batch("DROP TRIGGER interrupt_evict")
3606            .unwrap();
3607        client.drain_pending_evictions().unwrap();
3608        assert!(client.load_pending_evictions().unwrap().is_empty());
3609        assert_eq!(
3610            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3611            "held"
3612        );
3613        assert_eq!(client.drain_change_batches().len(), 2);
3614    }
3615
3616    #[test]
3617    fn eviction_marker_failures_roll_back_window_changes() {
3618        let mut client = client();
3619        let base = WindowBase {
3620            table: "tasks".into(),
3621            variable: "project_id".into(),
3622            fixed_scopes: Vec::new(),
3623            params: None,
3624        };
3625        client.set_window(&base, &["p1".into()]).unwrap();
3626        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3627            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3628            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3629            CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3630        let revision = client.local_revision();
3631        assert!(client.set_window(&base, &[]).is_err());
3632        assert!(client.conn.is_autocommit());
3633        assert_eq!(client.local_revision(), revision);
3634        assert_eq!(
3635            client
3636                .query("SELECT count(*) AS n FROM tasks", &[])
3637                .unwrap()[0]["n"],
3638            2050
3639        );
3640        assert_eq!(client.window_state(&base).units, vec!["p1"]);
3641        client.conn.execute_batch("DROP TRIGGER fail_marker;
3642            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3643        assert!(client.set_window(&base, &[]).is_err());
3644        client.conn.execute_batch("DROP TRIGGER interrupt_evict;
3645            CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3646        assert!(client.drain_pending_evictions().is_err());
3647        assert!(client.conn.is_autocommit());
3648        assert_eq!(
3649            client
3650                .query("SELECT count(*) AS n FROM tasks", &[])
3651                .unwrap()[0]["n"],
3652            2
3653        );
3654        assert!(client.set_window(&base, &["p1".into()]).is_err());
3655        assert!(client.conn.is_autocommit());
3656        assert!(client.window_state(&base).units.is_empty());
3657        assert!(client.subs.is_empty());
3658        assert_eq!(
3659            client
3660                .query(
3661                    "SELECT count(*) AS n FROM _syncular_window_pending_evict",
3662                    &[]
3663                )
3664                .unwrap()[0]["n"],
3665            1
3666        );
3667        client
3668            .conn
3669            .execute_batch("DROP TRIGGER fail_marker")
3670            .unwrap();
3671        client.set_window(&base, &["p1".into()]).unwrap();
3672        assert_eq!(client.window_state(&base).units, vec!["p1"]);
3673    }
3674
3675    #[test]
3676    fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
3677        let mut client = client();
3678        client
3679            .conn
3680            .execute(
3681                "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
3682                [],
3683            )
3684            .unwrap();
3685        assert_eq!(
3686            client.drain_pending_evictions().unwrap_err(),
3687            "sync.local_corrupt: invalid pending eviction scopes"
3688        );
3689        assert!(client.conn.is_autocommit());
3690        assert!(client.enqueue_startup_sync_if_needed().is_err());
3691    }
3692
3693    #[test]
3694    fn image_chunks_reject_null_primary_keys() {
3695        let mut client = client();
3696        let image = Connection::open_in_memory().unwrap();
3697        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3698            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
3699            INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
3700            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3701            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
3702        let table = client.schema.table("tasks").unwrap().clone();
3703        assert!(client
3704            .apply_sqlite_image(
3705                &image,
3706                &table,
3707                true,
3708                &[("project_id".into(), vec!["p1".into()])],
3709                1025,
3710                7,
3711                "digest"
3712            )
3713            .is_err());
3714        assert!(client.conn.is_autocommit());
3715        assert_eq!(
3716            client
3717                .query("SELECT count(*) AS n FROM tasks", &[])
3718                .unwrap()[0]["n"],
3719            0
3720        );
3721    }
3722
3723    #[test]
3724    fn import_chunks_with_unique_constraints_match_full_pending_replay() {
3725        for images in [false, true] {
3726            let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
3727                "name":name,"primaryKey":"id","columns":[
3728                    {"name":"id","type":"string","nullable":false},
3729                    {"name":"project_id","type":"string","nullable":false},
3730                    {"name":"title","type":"string","nullable":false}],
3731                "scopes":[{"pattern":"project:{project_id}"}],
3732                "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
3733                "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
3734            })))});
3735            let mut actual =
3736                SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
3737            let mut reference =
3738                SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
3739            let scopes = vec![("project_id".into(), vec!["p1".into()])];
3740            for client in [&mut actual, &mut reference] {
3741                client
3742                    .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
3743                    .unwrap();
3744                client.subs[0].effective = Some(scopes.clone());
3745                for (table, id, title) in [
3746                    ("tasks", "a", "original"),
3747                    ("tasks", "b", "occupied"),
3748                    ("tasks", "c", "deleted"),
3749                    ("notes", "n", "untouched"),
3750                ] {
3751                    client
3752                        .write_base_row(
3753                            table,
3754                            &vec![
3755                                Some(ColumnValue::String(id.into())),
3756                                Some(ColumnValue::String("p1".into())),
3757                                Some(ColumnValue::String(title.into())),
3758                            ],
3759                            1,
3760                        )
3761                        .unwrap();
3762                }
3763                client.rebuild_overlay_if_dirty();
3764                client
3765                    .mutate(vec![
3766                        Mutation::Upsert {
3767                            table: "tasks".into(),
3768                            values: Map::from_iter([
3769                                ("id".into(), Value::from("a")),
3770                                ("project_id".into(), Value::from("p1")),
3771                                ("title".into(), Value::from("available")),
3772                            ]),
3773                            base_version: None,
3774                        },
3775                        Mutation::Delete {
3776                            table: "tasks".into(),
3777                            row_id: "c".into(),
3778                            base_version: None,
3779                        },
3780                        Mutation::Upsert {
3781                            table: "tasks".into(),
3782                            values: Map::from_iter([
3783                                ("id".into(), Value::from("local")),
3784                                ("project_id".into(), Value::from("p1")),
3785                                ("title".into(), Value::from("local value")),
3786                            ]),
3787                            base_version: None,
3788                        },
3789                        Mutation::Upsert {
3790                            table: "notes".into(),
3791                            values: Map::from_iter([
3792                                ("id".into(), Value::from("n")),
3793                                ("project_id".into(), Value::from("p1")),
3794                                ("title".into(), Value::from("pending note")),
3795                            ]),
3796                            base_version: None,
3797                        },
3798                    ])
3799                    .unwrap();
3800            }
3801            let pending = actual.pending_commit_ids();
3802            actual.overlay_rebuild_count.set(0);
3803            for (id, title) in [
3804                ("b", "available"),
3805                ("b", "occupied"),
3806                ("a", "server original"),
3807                ("d", "local value"),
3808                ("d", "released"),
3809                ("c", "redelivered"),
3810            ] {
3811                let table = actual.schema.table("tasks").unwrap().clone();
3812                let values = vec![
3813                    Some(ColumnValue::String(id.into())),
3814                    Some(ColumnValue::String("p1".into())),
3815                    Some(ColumnValue::String(title.into())),
3816                ];
3817                if images {
3818                    let image = Connection::open_in_memory().unwrap();
3819                    image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3820                        CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3821                        INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
3822                    image
3823                        .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
3824                        .unwrap();
3825                    assert!(matches!(
3826                        actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
3827                        Ok(1)
3828                    ));
3829                } else {
3830                    let segment = RowsSegment {
3831                        table: "tasks".into(),
3832                        schema_version: 1,
3833                        columns: table.wire_columns.clone(),
3834                        blocks: vec![vec![ssp2::segment::SegmentRow {
3835                            server_version: 2,
3836                            values: values.clone(),
3837                        }]],
3838                    };
3839                    assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
3840                }
3841                reference.write_base_row("tasks", &values, 2).unwrap();
3842                reference.rebuild_overlay();
3843                for table in ["tasks", "notes"] {
3844                    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")] {
3845                        assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
3846                    }
3847                }
3848                assert_eq!(actual.pending_commit_ids(), pending);
3849            }
3850            assert_eq!(
3851                actual.overlay_rebuild_count.get(),
3852                0,
3853                "each chunk must leave unrelated tables alone"
3854            );
3855            let before = actual
3856                .query("SELECT * FROM tasks ORDER BY id", &[])
3857                .unwrap();
3858            let fts_before = actual
3859                .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
3860                .unwrap();
3861            let revision = actual.local_revision();
3862            actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
3863            let table = actual.schema.table("tasks").unwrap().clone();
3864            let segment = RowsSegment {
3865                table: "tasks".into(),
3866                schema_version: 1,
3867                columns: table.wire_columns.clone(),
3868                blocks: vec![vec![ssp2::segment::SegmentRow {
3869                    server_version: 3,
3870                    values: vec![
3871                        Some(ColumnValue::String("b".into())),
3872                        Some(ColumnValue::String("p1".into())),
3873                        Some(ColumnValue::String("fresh".into())),
3874                    ],
3875                }]],
3876            };
3877            assert!(actual.apply_segment(0, &segment, false).is_err());
3878            assert!(actual.conn.is_autocommit());
3879            assert_eq!(
3880                actual
3881                    .query("SELECT * FROM tasks ORDER BY id", &[])
3882                    .unwrap(),
3883                before
3884            );
3885            assert_eq!(
3886                actual
3887                    .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
3888                    .unwrap(),
3889                fts_before
3890            );
3891            assert_eq!(actual.local_revision(), revision);
3892            assert_eq!(actual.pending_commit_ids(), pending);
3893        }
3894    }
3895
3896    #[test]
3897    fn import_reconciliation_preserves_typed_pending_primary_keys() {
3898        for (kind, a, b) in [
3899            ("string", json!("a"), json!("b")),
3900            ("integer", json!(1), json!(2)),
3901            ("boolean", json!(true), json!(false)),
3902            ("json", json!("1"), json!("2")),
3903        ] {
3904            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}]}]});
3905            for images in [false, true] {
3906                let mut client =
3907                    SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
3908                let table = client.schema.table("tasks").unwrap().clone();
3909                client
3910                    .subscribe("tasks".into(), "tasks".into(), vec![], None)
3911                    .unwrap();
3912                for (id, title) in [(&a, "original"), (&b, "occupied")] {
3913                    client
3914                        .write_base_row(
3915                            "tasks",
3916                            &vec![
3917                                json_to_column_value(&table.columns[0], Some(id)).unwrap(),
3918                                Some(ColumnValue::String(title.into())),
3919                            ],
3920                            1,
3921                        )
3922                        .unwrap();
3923                }
3924                client.rebuild_overlay_if_dirty();
3925                client
3926                    .mutate(vec![Mutation::Upsert {
3927                        table: "tasks".into(),
3928                        values: Map::from_iter([
3929                            ("id".into(), a.clone()),
3930                            ("title".into(), json!("available")),
3931                        ]),
3932                        base_version: None,
3933                    }])
3934                    .unwrap();
3935                client.overlay_rebuild_count.set(0);
3936                for (title, expected) in [("available", "original"), ("occupied", "available")] {
3937                    let values = vec![
3938                        json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
3939                        Some(ColumnValue::String(title.into())),
3940                    ];
3941                    if images {
3942                        let image = Connection::open_in_memory().unwrap();
3943                        image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3944                            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3945                            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
3946                        image
3947                            .execute(
3948                                "INSERT INTO tasks VALUES (?1,?2,2)",
3949                                rusqlite::params![RowParam::Cell(&values[0]), title],
3950                            )
3951                            .unwrap();
3952                        assert!(
3953                            matches!(
3954                                client.apply_sqlite_image(
3955                                    &image,
3956                                    &table,
3957                                    false,
3958                                    &[],
3959                                    1,
3960                                    7,
3961                                    "digest"
3962                                ),
3963                                Ok(1)
3964                            ),
3965                            "{kind}, images={images}"
3966                        );
3967                    } else {
3968                        let segment = RowsSegment {
3969                            table: "tasks".into(),
3970                            schema_version: 1,
3971                            columns: table.wire_columns.clone(),
3972                            blocks: vec![vec![ssp2::segment::SegmentRow {
3973                                server_version: 2,
3974                                values,
3975                            }]],
3976                        };
3977                        assert!(
3978                            matches!(client.apply_segment(0, &segment, false), Ok(1)),
3979                            "{kind}"
3980                        );
3981                    }
3982                    let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
3983                    let actual: String = client
3984                        .conn
3985                        .query_row(
3986                            "SELECT title FROM tasks WHERE id=?1",
3987                            [RowParam::Cell(&key)],
3988                            |row| row.get(0),
3989                        )
3990                        .unwrap();
3991                    assert_eq!(actual, expected, "{kind}, images={images}");
3992                }
3993                assert_eq!(client.overlay_rebuild_count.get(), 0);
3994            }
3995        }
3996    }
3997
3998    fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
3999        let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4000            "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
4001            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
4002            "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4003        })))});
4004        let mut client =
4005            SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
4006        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();
4007        client.rebuild_overlay();
4008        client
4009            .mutate(vec![Mutation::Upsert {
4010                table: "tasks".into(),
4011                values: Map::from_iter([
4012                    ("id".into(), json!("offline")),
4013                    ("project_id".into(), json!("p1")),
4014                    ("title".into(), json!("pending edit")),
4015                ]),
4016                base_version: None,
4017            }])
4018            .unwrap();
4019        let image = Connection::open_in_memory().unwrap();
4020        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4021            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
4022        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();
4023        image
4024            .execute(
4025                "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
4026                [rows],
4027            )
4028            .unwrap();
4029        client.overlay_rebuild_count.set(0);
4030        let table = client.schema.table("tasks").unwrap().clone();
4031        (client, image, table)
4032    }
4033
4034    #[test]
4035    fn large_unique_import_copies_no_unrelated_tables() {
4036        let (mut client, image, table) = unique_import_fixture(10240);
4037        let pending = client.pending_commit_ids();
4038        client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
4039            CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
4040        assert!(matches!(
4041            client.apply_sqlite_image(
4042                &image,
4043                &table,
4044                true,
4045                &[("project_id".into(), vec!["p1".into()])],
4046                10240,
4047                7,
4048                "digest"
4049            ),
4050            Ok(10240)
4051        ));
4052        assert_eq!(client.overlay_rebuild_count.get(), 0);
4053        assert_eq!(client.pending_commit_ids(), pending);
4054        assert_eq!(
4055            client
4056                .query("SELECT count(*) AS n FROM tasks", &[])
4057                .unwrap()[0]["n"],
4058            10241
4059        );
4060        assert_eq!(
4061            client
4062                .query("SELECT count(*) AS n FROM tasks_fts", &[])
4063                .unwrap()[0]["n"],
4064            10241
4065        );
4066        assert_eq!(
4067            client
4068                .query("SELECT count(*) AS n FROM notes_fts", &[])
4069                .unwrap()[0]["n"],
4070            8192
4071        );
4072    }
4073
4074    #[test]
4075    #[ignore = "manual scaling benchmark; see bench/README.md"]
4076    fn benchmark_unique_image_import() {
4077        let mut samples = Vec::new();
4078        for rows in [10240, 51200, 102400] {
4079            for trial in 0..4 {
4080                let (mut client, image, table) = unique_import_fixture(rows);
4081                let start = std::time::Instant::now();
4082                assert!(
4083                    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)
4084                );
4085                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
4086                assert_eq!(
4087                    client
4088                        .query("SELECT count(*) AS n FROM tasks", &[])
4089                        .unwrap()[0]["n"],
4090                    rows + 1
4091                );
4092                assert_eq!(
4093                    client
4094                        .query("SELECT count(*) AS n FROM tasks_fts", &[])
4095                        .unwrap()[0]["n"],
4096                    rows + 1
4097                );
4098                assert_eq!(
4099                    client
4100                        .query("SELECT count(*) AS n FROM notes_fts", &[])
4101                        .unwrap()[0]["n"],
4102                    8192
4103                );
4104                assert_eq!(client.pending_commit_ids().len(), 1);
4105                if trial > 0 {
4106                    samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
4107                }
4108            }
4109        }
4110        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});
4111        let output = serde_json::to_string_pretty(&artifact).unwrap();
4112        if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
4113            use std::io::Write;
4114            let mut file = std::fs::OpenOptions::new()
4115                .write(true)
4116                .create_new(true)
4117                .open(path)
4118                .unwrap();
4119            file.write_all(output.as_bytes()).unwrap();
4120        } else {
4121            println!("{output}");
4122        }
4123    }
4124
4125    #[test]
4126    fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
4127        let schema = json!({
4128            "version": 1,
4129            "tables": [{
4130                "name": "catalogue_codes",
4131                "primaryKey": "id",
4132                "columns": [
4133                    { "name": "id", "type": "string", "nullable": false },
4134                    { "name": "release_id", "type": "string", "nullable": false },
4135                    { "name": "code", "type": "string", "nullable": false },
4136                    { "name": "title", "type": "string", "nullable": false }
4137                ],
4138                "scopes": [{ "pattern": "release:{release_id}" }],
4139                "ftsIndexes": [{
4140                    "name": "catalogue_codes_fts",
4141                    "columns": ["code", "title"],
4142                    "tokenize": "unicode61 remove_diacritics 2"
4143                }]
4144            }]
4145        });
4146        let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
4147            .expect("FTS5 client");
4148        let insert_trigger: String = client
4149            .conn
4150            .query_row(
4151                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
4152                [],
4153                |row| row.get(0),
4154            )
4155            .expect("insert trigger");
4156        let replace_guard: String = client
4157            .conn
4158            .query_row(
4159                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
4160                [],
4161                |row| row.get(0),
4162            )
4163            .expect("replace guard");
4164        assert!(!insert_trigger.contains("DELETE FROM"));
4165        assert!(replace_guard.contains("BEFORE INSERT"));
4166        assert!(replace_guard.contains("WHEN EXISTS"));
4167        let search = |client: &SyncClient, query: &str| {
4168            client
4169                .query(
4170                    "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",
4171                    &[Value::from(query)],
4172                )
4173                .expect("FTS query")
4174        };
4175
4176        client
4177            .mutate(vec![Mutation::Upsert {
4178                table: "catalogue_codes".to_owned(),
4179                values: Map::from_iter([
4180                    ("id".to_owned(), Value::from("c1")),
4181                    ("release_id".to_owned(), Value::from("r1")),
4182                    ("code".to_owned(), Value::from("A01")),
4183                    ("title".to_owned(), Value::from("Cholera")),
4184                ]),
4185                base_version: None,
4186            }])
4187            .expect("insert code");
4188        assert_eq!(search(&client, "cholera").len(), 1);
4189        let prior = client
4190            .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4191            .unwrap();
4192        client
4193            .conn
4194            .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
4195            .unwrap();
4196        client.create_synced_tables().unwrap();
4197        assert_eq!(
4198            client
4199                .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4200                .unwrap(),
4201            prior
4202        );
4203        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();
4204        let rendered = serde_json::to_string(&plan).unwrap();
4205        assert!(rendered.contains("COVERING INDEX"));
4206        assert!(rendered.contains("INDEX 0:="));
4207        client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
4208        assert_eq!(search(&client, "cholera").len(), 1);
4209
4210        client
4211            .mutate(vec![Mutation::Upsert {
4212                table: "catalogue_codes".to_owned(),
4213                values: Map::from_iter([
4214                    ("id".to_owned(), Value::from("c1")),
4215                    ("release_id".to_owned(), Value::from("r1")),
4216                    ("code".to_owned(), Value::from("A01")),
4217                    ("title".to_owned(), Value::from("Enteric infection")),
4218                ]),
4219                base_version: None,
4220            }])
4221            .expect("update code");
4222        assert!(search(&client, "cholera").is_empty());
4223        assert_eq!(search(&client, "enteric").len(), 1);
4224
4225        client
4226            .mutate(vec![Mutation::Delete {
4227                table: "catalogue_codes".to_owned(),
4228                row_id: "c1".to_owned(),
4229                base_version: None,
4230            }])
4231            .expect("delete code");
4232        assert!(search(&client, "enteric").is_empty());
4233    }
4234
4235    #[test]
4236    fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
4237        let schema = json!({
4238            "version": 1,
4239            "tables": [{
4240                "name": "patient_notes",
4241                "primaryKey": "id",
4242                "columns": [
4243                    { "name": "id", "type": "string", "nullable": false },
4244                    { "name": "practice_id", "type": "string", "nullable": false },
4245                    { "name": "encryption_key_id", "type": "string", "nullable": false },
4246                    { "name": "title", "type": "string", "nullable": false }
4247                ],
4248                "scopes": [{ "pattern": "practice:{practice_id}" }],
4249                "ftsIndexes": [{
4250                    "name": "patient_notes_fts",
4251                    "columns": ["title"],
4252                    "tokenize": "unicode61 remove_diacritics 2"
4253                }]
4254            }]
4255        });
4256        let mut client = SyncClient::new(
4257            "local-purge-test".to_owned(),
4258            &schema,
4259            ClientLimits::default(),
4260        )
4261        .expect("local purge client");
4262        client
4263            .conn
4264            .execute(
4265                "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
4266                 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
4267                 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
4268                [],
4269            )
4270            .expect("seed base rows");
4271        client.overlay_dirty.set(true);
4272        client.rebuild_overlay_if_dirty();
4273
4274        let note = |id: &str, key_id: &str, title: &str| {
4275            Map::from_iter([
4276                ("id".to_owned(), Value::from(id)),
4277                ("practice_id".to_owned(), Value::from("practice-1")),
4278                ("encryption_key_id".to_owned(), Value::from(key_id)),
4279                ("title".to_owned(), Value::from(title)),
4280            ])
4281        };
4282        let doomed = client
4283            .mutate(vec![
4284                Mutation::Upsert {
4285                    table: "patient_notes".to_owned(),
4286                    values: note("target", "key-revoked", "Target changed"),
4287                    base_version: None,
4288                },
4289                Mutation::Upsert {
4290                    table: "patient_notes".to_owned(),
4291                    values: note("unrelated", "key-held", "Unrelated changed"),
4292                    base_version: None,
4293                },
4294            ])
4295            .expect("doomed commit");
4296        let kept = client
4297            .mutate(vec![Mutation::Upsert {
4298                table: "patient_notes".to_owned(),
4299                values: note("kept", "key-held", "Kept optimistic"),
4300                base_version: None,
4301            }])
4302            .expect("kept commit");
4303        client.drain_change_batches();
4304
4305        let input = LocalDataPurgeInput {
4306            purge_id: "purge-001".to_owned(),
4307            targets: vec![LocalDataPurgeTarget {
4308                table: "patient_notes".to_owned(),
4309                selectors: BTreeMap::from([(
4310                    "encryption_key_id".to_owned(),
4311                    vec!["key-revoked".to_owned()],
4312                )]),
4313            }],
4314        };
4315        assert_eq!(
4316            client.purge_local_data(&input).expect("apply purge"),
4317            LocalDataPurgeResult {
4318                already_applied: false,
4319                purged_rows: 1,
4320                dropped_commits: 1,
4321            }
4322        );
4323        let rows = client
4324            .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
4325            .expect("visible rows");
4326        assert_eq!(rows.len(), 2);
4327        assert_eq!(rows[0]["id"], "kept");
4328        assert_eq!(rows[1]["id"], "unrelated");
4329        assert_eq!(rows[1]["title"], "Unrelated original");
4330        let fts = client
4331            .query(
4332                "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'",
4333                &[],
4334            )
4335            .expect("fts query");
4336        assert!(fts.is_empty());
4337        assert_eq!(client.outbox.len(), 1);
4338        assert_eq!(client.outbox[0].client_commit_id, kept);
4339        let outcome = client
4340            .commit_outcome(&doomed)
4341            .expect("read doomed outcome")
4342            .expect("doomed outcome");
4343        assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
4344        match &outcome.results[0] {
4345            CommitOperationOutcome::Error { rejection } => {
4346                assert_eq!(rejection.code, "client.local_data_purged");
4347                assert_eq!(rejection.client_commit_id, doomed);
4348            }
4349            other => panic!("expected local purge rejection, got {other:?}"),
4350        }
4351        assert_eq!(client.drain_change_batches().len(), 1);
4352        assert_eq!(
4353            client.purge_local_data(&input).expect("retry purge"),
4354            LocalDataPurgeResult {
4355                already_applied: true,
4356                purged_rows: 0,
4357                dropped_commits: 0,
4358            }
4359        );
4360        let conflicting = LocalDataPurgeInput {
4361            purge_id: input.purge_id.clone(),
4362            targets: vec![LocalDataPurgeTarget {
4363                table: "patient_notes".to_owned(),
4364                selectors: BTreeMap::from([(
4365                    "encryption_key_id".to_owned(),
4366                    vec!["key-held".to_owned()],
4367                )]),
4368            }],
4369        };
4370        assert!(client
4371            .purge_local_data(&conflicting)
4372            .expect_err("id collision must fail")
4373            .contains("already used with a different plan"));
4374    }
4375}
4376
4377impl SubState {
4378    fn name(self) -> &'static str {
4379        match self {
4380            SubState::Active => "active",
4381            SubState::Revoked => "revoked",
4382            SubState::Failed => "failed",
4383        }
4384    }
4385
4386    fn parse(value: &str) -> Self {
4387        match value {
4388            "revoked" => Self::Revoked,
4389            "failed" => Self::Failed,
4390            _ => Self::Active,
4391        }
4392    }
4393}
4394
4395#[derive(Debug, Clone)]
4396struct Subscription {
4397    id: String,
4398    table: String,
4399    requested: Vec<(String, Vec<String>)>,
4400    params: Option<String>,
4401    cursor: i64,
4402    /// §4.7 resume token, round-tripped opaquely.
4403    bootstrap_state: Option<String>,
4404    state: SubState,
4405    reason_code: Option<String>,
4406    /// Last effective scopes echoed while active (§3.3: persisted for the
4407    /// purge contract; each active echo replaces it).
4408    effective: Option<Vec<(String, Vec<String>)>>,
4409    synced_once: bool,
4410}
4411
4412#[derive(Debug, Clone)]
4413struct OutboxOp {
4414    upsert: bool,
4415    table: String,
4416    row_id: String,
4417    base_version: Option<i64>,
4418    /// Schema-agnostic local form (§0): driver JSON values, encoded with
4419    /// the current codec at send time. A key's presence IS the §6.1 sparse
4420    /// presence set — both the overlay and the wire payload derive from it.
4421    values: Option<Map<String, Value>>,
4422}
4423
4424impl From<&OutboxOp> for CommitOperation {
4425    fn from(operation: &OutboxOp) -> Self {
4426        Self {
4427            table: operation.table.clone(),
4428            row_id: operation.row_id.clone(),
4429            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
4430            base_version: operation.base_version,
4431            values: operation.values.clone(),
4432        }
4433    }
4434}
4435
4436#[derive(Debug, Clone)]
4437struct OutboxCommit {
4438    client_commit_id: String,
4439    ops: Vec<OutboxOp>,
4440}
4441
4442/// One commit's `(table, value keys)` per upsert operation — the classification
4443/// shape the §7.4.4 send-time drop and the RFC 0005 D6 audit share, so the two
4444/// can never disagree about which commits cannot re-encode.
4445fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
4446    commit
4447        .ops
4448        .iter()
4449        .filter(|operation| operation.upsert)
4450        .map(|operation| {
4451            (
4452                operation.table.as_str(),
4453                operation
4454                    .values
4455                    .as_ref()
4456                    .map(|values| values.keys().map(String::as_str).collect())
4457                    .unwrap_or_default(),
4458            )
4459        })
4460        .collect()
4461}
4462
4463#[derive(Debug, Clone)]
4464struct CompiledLocalDataPurgeTarget {
4465    table: String,
4466    selectors: Vec<(String, Vec<String>)>,
4467}
4468
4469struct StoredCommitOutcomeRow {
4470    sequence: i64,
4471    client_commit_id: String,
4472    status: String,
4473    recorded_at_ms: i64,
4474    results_json: String,
4475    operations_json: Option<String>,
4476    resolution: String,
4477    resolved_at_ms: Option<i64>,
4478    replacement_client_commit_id: Option<String>,
4479}
4480
4481/// Section outcome distinguishing the §5.6 subscription-local fail-closed
4482/// path from a round-aborting failure (§1.4 rule 5).
4483enum SectionError {
4484    FailClosed,
4485    Abort(String, String),
4486}
4487
4488struct RequestMeta {
4489    pushed_ids: Vec<String>,
4490    /// Subscription id → the request carried `cursor < 0` and no resume
4491    /// token (§5.6 first-page detection: a *fresh* bootstrap).
4492    fresh: Vec<(String, bool)>,
4493    accept: u8,
4494    /// §6.1 splitBatch: outbox commits held back from THIS request because
4495    /// the running operation count reached the push cap — the next round
4496    /// pushes them (`sync_needed` stays set while any remain).
4497    deferred_commits: usize,
4498}
4499
4500#[derive(Default)]
4501struct ChangeAccumulator {
4502    /// None means table-wide; Some is the exact scoped domain.
4503    tables: BTreeMap<String, Option<BTreeSet<String>>>,
4504    windows: BTreeMap<(String, String), BTreeSet<String>>,
4505    status: bool,
4506    conflicts: bool,
4507    rejections: bool,
4508    outcomes: bool,
4509}
4510
4511impl ChangeAccumulator {
4512    fn table(&mut self, table: &str) {
4513        self.tables.insert(table.to_owned(), None);
4514    }
4515
4516    fn scope(&mut self, table: &str, key: String) {
4517        match self.tables.get_mut(table) {
4518            Some(None) => {}
4519            Some(Some(keys)) => {
4520                keys.insert(key);
4521            }
4522            None => {
4523                self.tables
4524                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
4525            }
4526        }
4527    }
4528
4529    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
4530        self.windows
4531            .entry((base_key.to_owned(), table.to_owned()))
4532            .or_default()
4533            .insert(unit.to_owned());
4534    }
4535
4536    fn touched(&self) -> bool {
4537        !self.tables.is_empty()
4538            || !self.windows.is_empty()
4539            || self.status
4540            || self.conflicts
4541            || self.rejections
4542            || self.outcomes
4543    }
4544}
4545
4546/// §6.1: the server caps total operations per request (reference default
4547/// 500) and rejects the whole batch with `sync.too_many_operations`; the
4548/// client "splits and retries". Splitting happens at build time: commits are
4549/// included IN ORDER until the operation budget is spent, the rest wait for
4550/// the next round.
4551const PUSH_OPS_PER_REQUEST: usize = 500;
4552const MAX_LOCAL_PURGE_TARGETS: usize = 64;
4553const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
4554const MAX_LOCAL_PURGE_VALUES: usize = 128;
4555const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
4556pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
4557
4558pub struct SyncClient {
4559    #[cfg(feature = "bench-internals")]
4560    benchmark_phases: Recorder,
4561    conn: Connection,
4562    schema: ClientSchema,
4563    client_id: String,
4564    limits: ClientLimits,
4565    subs: Vec<Subscription>,
4566    outbox: Vec<OutboxCommit>,
4567    conflicts: Vec<ConflictRecord>,
4568    rejections: Vec<RejectionRecord>,
4569    schema_floor: Option<SchemaFloor>,
4570    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
4571    lease_state: Option<LeaseState>,
4572    /// §1.6: the schema-floor response stops syncing until an upgrade.
4573    stopped: bool,
4574    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
4575    upgrading: bool,
4576    /// §8.4 coalesced sync-needed signal.
4577    sync_needed: bool,
4578    realtime_connected: bool,
4579    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
4580    presence: HashMap<String, HashMap<String, PresencePeer>>,
4581    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
4582    /// host may pin it (conformance runs on a virtual clock).
4583    now_ms: Option<i64>,
4584    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
4585    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
4586    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
4587    encryption: crate::values::EncryptionConfig,
4588    /// Fail-closed host bootstrap gate. While set, command hosts permit only
4589    /// status/lifecycle inspection and an exact authorized local purge.
4590    security_preflight: bool,
4591    /// RFC 0005 D2/D8: the previous-version capture config. `None` (default)
4592    /// means the feature is off; the descriptor write and the container orphan
4593    /// sweep still happen.
4594    previous_version: Option<PreviousVersionContextConfig>,
4595    /// Per-table primary-key upsert SQL, built once per (full table name) —
4596    /// the row write path runs per row during bootstrap (§5.6), so the SQL
4597    /// string (and, via `prepare_cached`, its compiled statement) is reused
4598    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
4599    /// schema reset (the column lists may have changed).
4600    insert_sql: RefCell<HashMap<String, String>>,
4601    /// §7.1 rebuild gate: true whenever the base tables or the outbox have
4602    /// diverged from the visible overlay since the last rebuild. Lets a
4603    /// no-op sync round skip the full base→visible copy.
4604    overlay_dirty: Cell<bool>,
4605    /// Test-only structural performance signal: response processing must not
4606    /// turn a batch of acknowledgements into one full overlay rebuild each.
4607    #[cfg(test)]
4608    overlay_rebuild_count: Cell<usize>,
4609    /// Test-only structural performance signal: outcome retention is enforced
4610    /// once per response rather than once per acknowledgement.
4611    #[cfg(test)]
4612    outcome_prune_count: Cell<usize>,
4613    /// Exact observer-transaction output drained by command/FFI hosts.
4614    progress: ProgressObserver,
4615    change_queue: VecDeque<ClientChangeBatch>,
4616    sync_intent_queue: VecDeque<SyncIntent>,
4617    /// Explicit exponential retry policy for transient transport failures.
4618    retry_delay_ms: u64,
4619    last_round: Option<DiagnosticLastRound>,
4620    last_change: Option<DiagnosticLastChange>,
4621    /// §7.6: latest failed owned snapshot read per owner id, oldest first.
4622    query_failures: Vec<DiagnosticQueryFailure>,
4623}
4624
4625pub(crate) fn quote_ident(name: &str) -> String {
4626    format!("\"{}\"", name.replace('"', "\"\""))
4627}
4628/// §7.4.1 `_syncular_meta` helpers. Free functions so RFC 0005's
4629/// previous-version module reads and writes the SAME records as the client
4630/// instead of growing a parallel meta layer.
4631pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
4632    conn.query_row(
4633        "SELECT value FROM _syncular_meta WHERE key = ?1",
4634        rusqlite::params![key],
4635        |row| row.get::<_, String>(0),
4636    )
4637    .ok()
4638}
4639
4640pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
4641    let _ = conn.execute(
4642        "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
4643        rusqlite::params![key, value],
4644    );
4645}
4646
4647pub(crate) fn meta_delete(conn: &Connection, key: &str) {
4648    let _ = conn.execute(
4649        "DELETE FROM _syncular_meta WHERE key = ?1",
4650        rusqlite::params![key],
4651    );
4652}
4653
4654/// Seek by the existing key while retaining the exact legacy text match.
4655fn row_id_predicate(table: &TableSchema) -> String {
4656    let key = quote_ident(&table.primary_key);
4657    let text_match = format!("CAST({key} AS TEXT) = ?1");
4658    match table.columns[table.pk_index].ty {
4659        ColumnType::String | ColumnType::Json => {
4660            format!("{key} = ?1 AND {text_match}")
4661        }
4662        // Native columns have no declared SQLite affinity. Match the stored
4663        // integer bind type explicitly. Unary + removes expression affinity so
4664        // SQLite can seek the typeless key; the text check preserves exact IDs.
4665        ColumnType::Integer | ColumnType::Boolean => {
4666            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
4667        }
4668        // REAL-to-text conversion can round distinct values to the same text.
4669        // Narrowing those matches by numeric equality would change behavior.
4670        _ => text_match,
4671    }
4672}
4673
4674fn is_local_operation_code_like(value: &str) -> bool {
4675    let bytes = value.as_bytes();
4676    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
4677        && bytes
4678            .iter()
4679            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
4680}
4681
4682fn base_table(name: &str) -> String {
4683    quote_ident(&format!("_syncular_base_{name}"))
4684}
4685
4686/// §4.8: a stable, server-opaque key for a window base — table + variable +
4687/// canonical fixed scopes. Two `set_window` calls with the same base
4688/// address the same registry rows.
4689/// §4.8 deferred eviction record: (sub id, table, effective scope map).
4690type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
4691
4692fn window_base_key(base: &WindowBase) -> String {
4693    format!(
4694        "{}\0{}\0{}",
4695        base.table,
4696        base.variable,
4697        canonical_scope_json(&base.fixed_scopes)
4698    )
4699}
4700
4701/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
4702fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
4703    let mut scopes = base.fixed_scopes.clone();
4704    scopes.retain(|(k, _)| k != &base.variable);
4705    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
4706    scopes
4707}
4708
4709/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
4710/// echoed not interpreted by the server, so the exact hash is client
4711/// convention; SHA-256 matches the SPEC's worked example.
4712fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
4713    let canonical = canonical_scope_json(&unit_scopes(base, unit));
4714    let digest = Sha256::digest(canonical.as_bytes());
4715    let hex = bytes_to_hex(&digest);
4716    format!("w:{}:{}", base.table, &hex[..16])
4717}
4718
4719fn visible_table(name: &str) -> String {
4720    quote_ident(name)
4721}
4722
4723const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
4724
4725/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
4726/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
4727fn is_synced_table_name(name: &str) -> bool {
4728    if name.starts_with("sqlite_") {
4729        return false;
4730    }
4731    if name.starts_with("_syncular_base_") {
4732        return true; // the base half of a synced table pair
4733    }
4734    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
4735    !name.starts_with("_syncular_")
4736}
4737
4738/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
4739fn blob_id_for(bytes: &[u8]) -> String {
4740    let digest = Sha256::digest(bytes);
4741    format!("sha256:{}", bytes_to_hex(&digest))
4742}
4743
4744/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
4745/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
4746/// per row on the §5.6 bootstrap path), scalars bind owned.
4747enum RowParam<'a> {
4748    Cell(&'a Option<ColumnValue>),
4749    Version(i64),
4750}
4751
4752impl rusqlite::ToSql for RowParam<'_> {
4753    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
4754        Ok(match self {
4755            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
4756            RowParam::Cell(cell) => match cell {
4757                None => ToSqlOutput::Owned(SqlValue::Null),
4758                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
4759                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
4760                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
4761                Some(ColumnValue::Boolean(b)) => {
4762                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
4763                }
4764                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
4765                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
4766                }
4767                // §5.10: crdt bytes store as BLOB, like bytes.
4768                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
4769                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
4770                }
4771            },
4772        })
4773    }
4774}
4775
4776// Retain a typed primary key across the current import chunk without copying row payloads.
4777fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
4778    match param.to_sql().map_err(|error| error.to_string())? {
4779        ToSqlOutput::Owned(value) => Ok(value),
4780        ToSqlOutput::Borrowed(value) => {
4781            SqlValue::try_from(value).map_err(|error| error.to_string())
4782        }
4783        _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
4784    }
4785}
4786
4787/// §5.3 image cell → bind parameter, strict per the declared column type
4788/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
4789/// nullable). Mismatches are image-producer violations. Returns the cell
4790/// borrowed when the stored representation already matches what the row
4791/// codec would write, or the normalized scalar (boolean → 0/1, float from
4792/// INTEGER → REAL) otherwise — the local write is byte-identical to the
4793/// old convert-then-insert path without allocating per cell.
4794fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
4795    use ssp2::segment::ColumnType;
4796    let mismatch = || {
4797        Err(format!(
4798            "image column {:?} holds a value of the wrong type",
4799            column.name
4800        ))
4801    };
4802    match value {
4803        ValueRef::Null => {
4804            if !column.nullable {
4805                return Err(format!(
4806                    "image column {:?} is NULL but not nullable",
4807                    column.name
4808                ));
4809            }
4810            Ok(ToSqlOutput::Owned(SqlValue::Null))
4811        }
4812        ValueRef::Integer(i) => match column.ty {
4813            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
4814            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
4815            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
4816            _ => mismatch(),
4817        },
4818        ValueRef::Real(_) => match column.ty {
4819            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
4820            _ => mismatch(),
4821        },
4822        ValueRef::Text(t) => {
4823            std::str::from_utf8(t)
4824                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
4825            match column.ty {
4826                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
4827                    Ok(ToSqlOutput::Borrowed(value))
4828                }
4829                _ => mismatch(),
4830            }
4831        }
4832        ValueRef::Blob(_) => match column.ty {
4833            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
4834            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
4835            _ => mismatch(),
4836        },
4837    }
4838}
4839
4840pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
4841    use rusqlite::types::ValueRef;
4842    match value {
4843        ValueRef::Null => Value::Null,
4844        ValueRef::Integer(i) => match column.ty {
4845            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
4846            ssp2::segment::ColumnType::Float => {
4847                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
4848            }
4849            _ => Value::from(i),
4850        },
4851        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
4852        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
4853        ValueRef::Blob(b) => {
4854            let mut map = Map::new();
4855            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
4856            Value::Object(map)
4857        }
4858    }
4859}
4860
4861/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
4862/// Objects are accepted in lossless `{"$bytes": hex}` and
4863/// `{"$bigint": decimal}` envelope forms.
4864fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
4865    Ok(match value {
4866        Value::Null => SqlValue::Null,
4867        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
4868        Value::Number(n) => {
4869            if let Some(i) = n.as_i64() {
4870                SqlValue::Integer(i)
4871            } else if let Some(f) = n.as_f64() {
4872                SqlValue::Real(f)
4873            } else {
4874                return Err(format!("query param number {n} is out of range"));
4875            }
4876        }
4877        Value::String(s) => SqlValue::Text(s.clone()),
4878        Value::Object(_) => {
4879            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
4880                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
4881            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
4882                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
4883                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
4884                })?)
4885            } else {
4886                return Err(
4887                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
4888                        .to_owned(),
4889                );
4890            }
4891        }
4892        Value::Array(_) => return Err("query array params are not supported".to_owned()),
4893    })
4894}
4895
4896/// Map a rusqlite value with no schema column to consult (arbitrary query
4897/// output): integers/reals/text pass through by stored affinity, blobs ride
4898/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
4899/// schema column type to recover booleans/floats/json.
4900fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
4901    use rusqlite::types::ValueRef;
4902    match value {
4903        ValueRef::Null => Value::Null,
4904        ValueRef::Integer(i) => {
4905            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
4906            // ordinary UI-sized integers ergonomic and envelope only values
4907            // beyond JavaScript's safe range.
4908            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
4909            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
4910                Value::from(i)
4911            } else {
4912                let mut map = Map::new();
4913                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
4914                Value::Object(map)
4915            }
4916        }
4917        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
4918        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
4919        ValueRef::Blob(b) => {
4920            let mut map = Map::new();
4921            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
4922            Value::Object(map)
4923        }
4924    }
4925}
4926
4927/// §7.5: SQLite failures classify by result code into [`QueryReadFailure`].
4928fn query_connection(
4929    conn: &Connection,
4930    sql: &str,
4931    params: &[QueryValue],
4932) -> Result<Vec<QueryRow>, QueryReadFailure> {
4933    crate::query_guard::assert_read_only_query(sql)?;
4934    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
4935    let bound: Vec<SqlValue> = params
4936        .iter()
4937        .map(json_param_to_sql)
4938        .collect::<Result<_, _>>()?;
4939    let mut stmt = conn.prepare(&lowered_sql)?;
4940    let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
4941    let bound_refs: Vec<&dyn rusqlite::ToSql> =
4942        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
4943    let mut sql_rows = stmt.query(bound_refs.as_slice())?;
4944    let mut out = Vec::new();
4945    while let Some(row) = sql_rows.next()? {
4946        let mut record = Map::new();
4947        for (i, name) in column_names.iter().enumerate() {
4948            let value = row.get_ref(i)?;
4949            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
4950        }
4951        out.push(record);
4952    }
4953    Ok(out)
4954}
4955
4956fn persisted_window_state(
4957    conn: &Connection,
4958    base: &WindowBase,
4959) -> Result<WindowState, QueryReadFailure> {
4960    let mut stmt = conn.prepare(
4961        "SELECT windows.unit, subscriptions.state_json
4962           FROM _syncular_windows AS windows
4963           JOIN _syncular_subscriptions AS subscriptions
4964             ON subscriptions.id = windows.sub_id
4965          WHERE windows.base = ?1
4966          ORDER BY windows.unit ASC",
4967    )?;
4968    let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
4969        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
4970    })?;
4971    let mut units = Vec::new();
4972    let mut pending = Vec::new();
4973    for row in rows {
4974        let (unit, raw) = row?;
4975        let state: Value = serde_json::from_str(&raw)
4976            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
4977        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
4978            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
4979            || state
4980                .get("bootstrapState")
4981                .is_some_and(|value| !value.is_null());
4982        if is_pending {
4983            pending.push(unit.clone());
4984        }
4985        units.push(unit);
4986    }
4987    Ok(WindowState { units, pending })
4988}
4989
4990fn snapshot_connection(
4991    conn: &Connection,
4992    sql: &str,
4993    params: &[Value],
4994    coverage: &[WindowCoverage],
4995) -> Result<QuerySnapshot, QueryReadFailure> {
4996    conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
4997    let result = (|| {
4998        let revision = conn
4999            .query_row(
5000                "SELECT value FROM _syncular_meta WHERE key = ?1",
5001                rusqlite::params![LOCAL_REVISION_KEY],
5002                |row| row.get::<_, String>(0),
5003            )
5004            .ok()
5005            .and_then(|value| value.parse::<u64>().ok())
5006            .unwrap_or(0);
5007        let rows = query_connection(conn, sql, params)?;
5008        let mut pending = Vec::new();
5009        let mut missing = Vec::new();
5010        for requested in coverage {
5011            let base_key = window_base_key(&requested.base);
5012            let state = persisted_window_state(conn, &requested.base)?;
5013            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
5014                let reference = WindowUnitRef {
5015                    base_key: base_key.clone(),
5016                    unit: unit.clone(),
5017                };
5018                if !state.units.iter().any(|held| held == &unit) {
5019                    missing.push(reference);
5020                } else if state.pending.iter().any(|held| held == &unit) {
5021                    pending.push(reference);
5022                }
5023            }
5024        }
5025        Ok(QuerySnapshot {
5026            revision: revision.to_string(),
5027            rows,
5028            coverage: CoverageSnapshot {
5029                complete: pending.is_empty() && missing.is_empty(),
5030                pending,
5031                missing,
5032            },
5033        })
5034    })();
5035    match result {
5036        Ok(snapshot) => {
5037            conn.execute_batch("RELEASE syncular_snapshot_read")?;
5038            Ok(snapshot)
5039        }
5040        Err(error) => {
5041            let _ = conn.execute_batch(
5042                "ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read",
5043            );
5044            Err(error)
5045        }
5046    }
5047}
5048
5049/// A long-lived read-only SQLite sidecar for latency-critical native views.
5050/// Network rounds stay serialized on the mutable core owner, while atomic
5051/// query snapshots use this independent connection and therefore never queue
5052/// behind HTTP/WebSocket latency.
5053pub struct FileQuerySnapshotReader {
5054    path: String,
5055    conn: Option<Connection>,
5056}
5057
5058impl FileQuerySnapshotReader {
5059    #[must_use]
5060    pub fn new(path: impl Into<String>) -> Self {
5061        Self {
5062            path: path.into(),
5063            conn: None,
5064        }
5065    }
5066
5067    fn connection(&mut self) -> Result<&Connection, String> {
5068        if self.conn.is_none() {
5069            let conn = Connection::open_with_flags(
5070                &self.path,
5071                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
5072            )
5073            .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
5074            conn.busy_timeout(std::time::Duration::from_millis(250))
5075                .map_err(|error| error.to_string())?;
5076            self.conn = Some(conn);
5077        }
5078        self.conn
5079            .as_ref()
5080            .ok_or_else(|| "read sidecar connection missing".to_owned())
5081    }
5082
5083    /// Hosts report an owned failure, and the first success after it, to the
5084    /// owning core through [`SyncClient::record_query_read`] (§7.6).
5085    pub fn query_snapshot(
5086        &mut self,
5087        sql: &str,
5088        params: &[Value],
5089        coverage: &[WindowCoverage],
5090    ) -> Result<QuerySnapshot, QueryReadFailure> {
5091        snapshot_connection(self.connection()?, sql, params, coverage)
5092    }
5093}
5094
5095impl SyncClient {
5096    pub fn new_with_identity(
5097        client_id: Option<String>,
5098        schema_json: &Value,
5099        limits: ClientLimits,
5100    ) -> Result<Self, String> {
5101        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5102        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5103        Self::with_connection(resolved, schema_json, limits, conn)
5104    }
5105
5106    pub fn new(
5107        client_id: String,
5108        schema_json: &Value,
5109        limits: ClientLimits,
5110    ) -> Result<Self, String> {
5111        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5112        Self::with_connection(client_id, schema_json, limits, conn)
5113    }
5114
5115    /// Build a client backed by an on-disk SQLite database at `path` — the
5116    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
5117    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
5118    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
5119    /// of the command router's dependency set (the router only holds a path).
5120    pub fn open_path(
5121        client_id: String,
5122        schema_json: &Value,
5123        limits: ClientLimits,
5124        path: &str,
5125    ) -> Result<Self, String> {
5126        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5127        Self::with_connection(client_id, schema_json, limits, conn)
5128    }
5129
5130    pub fn open_path_with_identity(
5131        client_id: Option<String>,
5132        schema_json: &Value,
5133        limits: ClientLimits,
5134        path: &str,
5135    ) -> Result<Self, String> {
5136        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5137        // File-backed native clients use an independent read connection for
5138        // latency-critical snapshots. WAL is SQLite's intended reader/writer
5139        // concurrency mode: a view read never holds a rollback-journal lock
5140        // that delays the mutable client's next commit, and a short busy
5141        // timeout absorbs the tiny checkpoint/schema-lock windows.
5142        conn.busy_timeout(std::time::Duration::from_millis(250))
5143            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
5144        conn.pragma_update(None, "journal_mode", "WAL")
5145            .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
5146        let persisted = conn
5147            .query_row(
5148                "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
5149                [],
5150                |row| row.get::<_, String>(0),
5151            )
5152            .ok();
5153        let resolved = persisted
5154            .clone()
5155            .or(client_id.clone())
5156            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5157        if let (Some(existing), Some(requested)) = (persisted, client_id) {
5158            if existing != requested {
5159                return Err(format!(
5160                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
5161                ));
5162            }
5163        }
5164        Self::with_connection(resolved, schema_json, limits, conn)
5165    }
5166
5167    #[must_use]
5168    pub fn client_id(&self) -> &str {
5169        &self.client_id
5170    }
5171
5172    /// Build a client over a caller-supplied rusqlite connection — the seam a
5173    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
5174    /// with an on-disk database (`Connection::open(path)`) rather than the
5175    /// default `:memory:`. The connection MUST be fresh (no pre-existing
5176    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
5177    /// the same file across process restarts reuses the persisted rows.
5178    pub fn with_connection(
5179        client_id: String,
5180        schema_json: &Value,
5181        limits: ClientLimits,
5182        conn: Connection,
5183    ) -> Result<Self, String> {
5184        if limits.outcome_retention_max_entries == Some(0) {
5185            return Err(
5186                "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
5187            );
5188        }
5189        let schema = parse_schema_json(schema_json)?;
5190        // RFC 0005 D8: resolve the previous-version config before the opening
5191        // reset runs — the capture happens inside it. Bad bounds fail loud.
5192        let previous_version = match limits.previous_version_context {
5193            Some(config) => {
5194                config.validate()?;
5195                Some(config)
5196            }
5197            None => None,
5198        };
5199        let mut client = SyncClient {
5200            #[cfg(feature = "bench-internals")]
5201            benchmark_phases: Recorder::default(),
5202            conn,
5203            schema,
5204            client_id,
5205            limits,
5206            subs: Vec::new(),
5207            outbox: Vec::new(),
5208            conflicts: Vec::new(),
5209            rejections: Vec::new(),
5210            schema_floor: None,
5211            lease_state: None,
5212            stopped: false,
5213            upgrading: false,
5214            sync_needed: false,
5215            realtime_connected: false,
5216            presence: HashMap::new(),
5217            now_ms: None,
5218            encryption: crate::values::EncryptionConfig::default(),
5219            security_preflight: false,
5220            previous_version,
5221            insert_sql: RefCell::new(HashMap::new()),
5222            overlay_dirty: Cell::new(false),
5223            #[cfg(test)]
5224            overlay_rebuild_count: Cell::new(0),
5225            #[cfg(test)]
5226            outcome_prune_count: Cell::new(0),
5227            progress: ProgressObserver::default(),
5228            change_queue: VecDeque::new(),
5229            sync_intent_queue: VecDeque::new(),
5230            retry_delay_ms: 250,
5231            last_round: None,
5232            last_change: None,
5233            query_failures: Vec::new(),
5234        };
5235        // The row write path leans on the prepared-statement cache (two
5236        // insert statements per synced table, plus the bookkeeping
5237        // statements); size it so a multi-table schema never thrashes.
5238        client
5239            .conn
5240            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
5241        // Protected bookkeeping must exist before inspecting the persisted
5242        // schema marker. New app indexes may reference columns that only
5243        // exist after a version-bump reset.
5244        client.create_bookkeeping_tables()?;
5245        match client.get_meta(CLIENT_ID_KEY) {
5246            Some(existing) if existing != client.client_id => {
5247                return Err(format!(
5248                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
5249                    client.client_id
5250                ));
5251            }
5252            None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
5253            _ => {}
5254        }
5255        client.restore_persisted_state()?;
5256        let marker = client
5257            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
5258            .and_then(|value| value.parse::<i32>().ok());
5259        match marker {
5260            None => {
5261                client.create_synced_tables()?;
5262                client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
5263                // D1: the descriptor is written beside every marker write.
5264                set_local_schema_descriptor(&client.conn, &client.schema);
5265            }
5266            Some(version) if version == client.schema.version => {
5267                client.create_synced_tables()?;
5268                // RFC 0005 D1: the same-version open backfills the descriptor
5269                // for a database first opened by an unaware binary, so the
5270                // NEXT bump can capture without needing a schema bump first.
5271                set_local_schema_descriptor(&client.conn, &client.schema);
5272            }
5273            Some(_) => client.run_schema_reset()?,
5274        }
5275        // RFC 0005 D9: the reset sweep cannot see a container whose capture
5276        // committed in its OWN file while the replica's savepoint never
5277        // released — a crash at that seam leaves a container beside a matching
5278        // OLD marker, and the next open at that version runs no reset. Discard
5279        // it before anything can read it. No-op when no container exists.
5280        client.reconcile_previous_version_at_boot()?;
5281        client.clear_satisfied_persisted_schema_floor();
5282        client.prune_unknown_subscriptions()?;
5283        if marker == Some(client.schema.version) && !client.outbox.is_empty() {
5284            // Reconstruct the visible optimistic overlay from the durable base
5285            // plus outbox instead of trusting a process-interrupted mirror.
5286            client.overlay_dirty.set(true);
5287            client.rebuild_overlay();
5288        }
5289        // Every persisted active subscription needs one catch-up pull on open:
5290        // realtime only covers changes after connection, while an idempotent
5291        // setWindow correctly creates no fresh command effect. Pending outbox
5292        // work has the same restart requirement. The core owns this intent so
5293        // native hosts never poll or require an application-issued sync().
5294        client.enqueue_startup_sync_if_needed()?;
5295        Ok(client)
5296    }
5297
5298    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
5299    /// against this instead of system time (conformance virtual clock).
5300    pub fn set_now_ms(&mut self, now_ms: i64) {
5301        self.now_ms = Some(now_ms);
5302    }
5303
5304    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
5305    /// The command router parses these from the `create` command's
5306    /// `encryption` config (keys as `{$bytes: hex}`).
5307    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
5308        self.encryption = encryption;
5309    }
5310
5311    #[must_use]
5312    pub fn security_lifecycle(&self) -> &'static str {
5313        if self.security_preflight {
5314            "preflight"
5315        } else {
5316            "active"
5317        }
5318    }
5319
5320    #[must_use]
5321    pub fn security_preflight(&self) -> bool {
5322        self.security_preflight
5323    }
5324
5325    /// Quarantine this replica: enter the fail-closed gate, release all
5326    /// core-owned key material, and record the gate in the database so
5327    /// reopening re-enters preflight until `activate_security` clears it.
5328    ///
5329    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
5330    /// which must NOT leave that durable mark.
5331    pub fn begin_security_preflight(&mut self) {
5332        self.seal_security_on_teardown();
5333        // Persist the quarantine so it survives handle teardown and restart:
5334        // reopening this replica re-enters preflight until activation clears it.
5335        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
5336    }
5337
5338    /// Release core-owned key material as a host tears the client down,
5339    /// WITHOUT recording a quarantine.
5340    ///
5341    /// Shutting an activated client down is not a quarantine event. Persisting
5342    /// the gate here marks every cleanly closed replica as pending, and the
5343    /// next plain `create` is then refused permanently: the reopen path
5344    /// restores the flag from the marker and the create guard rejects it. The
5345    /// in-memory flag still closes the gate for anything still holding this
5346    /// instance, which is all a teardown barrier needs.
5347    pub fn seal_security_on_teardown(&mut self) {
5348        self.security_preflight = true;
5349        self.encryption = crate::values::EncryptionConfig::default();
5350        self.sync_intent_queue.clear();
5351    }
5352
5353    /// Install the post-authentication keyring and release the host loop.
5354    pub fn activate_security(
5355        &mut self,
5356        encryption: crate::values::EncryptionConfig,
5357    ) -> Result<(), String> {
5358        if !self.security_preflight {
5359            return Err(
5360                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
5361            );
5362        }
5363        self.enqueue_startup_sync_if_needed()?;
5364        self.encryption = encryption;
5365        self.security_preflight = false;
5366        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
5367        Ok(())
5368    }
5369
5370    fn clock_now_ms(&self) -> i64 {
5371        self.now_ms.unwrap_or_else(|| {
5372            std::time::SystemTime::now()
5373                .duration_since(std::time::UNIX_EPOCH)
5374                .map(|d| d.as_millis() as i64)
5375                .unwrap_or(0)
5376        })
5377    }
5378
5379    fn create_bookkeeping_tables(&self) -> Result<(), String> {
5380        // Durable client bookkeeping (outbox + subscription + meta).
5381        self.conn
5382            .execute_batch(
5383                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
5384                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
5385                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
5386                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
5387                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
5388                   client_commit_id TEXT NOT NULL UNIQUE,
5389                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
5390                   recorded_at_ms INTEGER NOT NULL,
5391                   results_json TEXT NOT NULL,
5392                   operations_json TEXT,
5393                   resolution TEXT NOT NULL DEFAULT 'active'
5394                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
5395                   resolved_at_ms INTEGER,
5396                   replacement_client_commit_id TEXT);
5397                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
5398                   ON _syncular_commit_outcomes(resolution, seq);
5399                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
5400                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
5401                 CREATE TABLE IF NOT EXISTS _syncular_meta (
5402                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
5403                 CREATE TABLE IF NOT EXISTS _syncular_windows (
5404                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
5405                   PRIMARY KEY (base, unit));
5406                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
5407                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
5408                   effective_scopes TEXT NOT NULL);
5409                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
5410                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
5411                   PRIMARY KEY(commit_id, blob_id));
5412                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
5413                   ON _syncular_blob_commit_refs(blob_id);",
5414            )
5415            .map_err(|e| e.to_string())?;
5416        // Migrate an outcome journal created before failed aggregate
5417        // envelopes were retained. Historical rows intentionally stay NULL.
5418        let _ = self
5419            .conn
5420            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
5421        if self.get_meta(LOCAL_REVISION_KEY).is_none() {
5422            self.set_meta(LOCAL_REVISION_KEY, "0");
5423        }
5424        // §5.9.7 blob cache. Mutable upload state stays off the body row so
5425        // completing a large upload does not rewrite its BLOB record.
5426        if self.schema_has_blobs() {
5427            let exists = self
5428                .conn
5429                .query_row(
5430                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
5431                    [],
5432                    |row| row.get::<_, bool>(0),
5433                )
5434                .map_err(|e| e.to_string())?;
5435            if exists {
5436                let mut statement = self
5437                    .conn
5438                    .prepare("PRAGMA table_info(_syncular_blobs)")
5439                    .map_err(|e| e.to_string())?;
5440                let mut columns = statement
5441                    .query_map([], |row| row.get::<_, String>(1))
5442                    .map_err(|e| e.to_string())?
5443                    .collect::<Result<Vec<_>, _>>()
5444                    .map_err(|e| e.to_string())?;
5445                columns.sort();
5446                let mut expected = vec![
5447                    "blob_id",
5448                    "byte_length",
5449                    "bytes",
5450                    "created_at_ms",
5451                    "media_type",
5452                ];
5453                expected.sort_unstable();
5454                if columns != expected {
5455                    return Err(
5456                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
5457                    );
5458                }
5459            } else {
5460                self.conn
5461                    .execute_batch(
5462                        "CREATE TABLE _syncular_blobs(
5463                           blob_id TEXT PRIMARY KEY,
5464                           bytes BLOB NOT NULL,
5465                           byte_length INTEGER NOT NULL,
5466                           media_type TEXT,
5467                           created_at_ms INTEGER NOT NULL);",
5468                    )
5469                    .map_err(|e| e.to_string())?;
5470            }
5471            self.conn
5472                .execute_batch(
5473                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
5474                       blob_id TEXT PRIMARY KEY,
5475                       media_type TEXT,
5476                       created_at_ms INTEGER NOT NULL);",
5477                )
5478                .map_err(|e| e.to_string())?;
5479        }
5480        Ok(())
5481    }
5482
5483    /// True iff any synced table declares a `blob_ref` column (§5.9).
5484    fn schema_has_blobs(&self) -> bool {
5485        self.schema
5486            .tables
5487            .iter()
5488            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
5489    }
5490
5491    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
5492
5493    fn get_meta(&self, key: &str) -> Option<String> {
5494        meta_get(&self.conn, key)
5495    }
5496
5497    fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
5498        self.conn
5499            .query_row(
5500                "SELECT value FROM _syncular_meta WHERE key = ?1",
5501                rusqlite::params![key],
5502                |row| row.get::<_, String>(0),
5503            )
5504            .optional()
5505            .map_err(|_| {
5506                "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
5507            })
5508    }
5509
5510    fn set_meta(&self, key: &str, value: &str) {
5511        meta_set(&self.conn, key, value);
5512    }
5513
5514    fn delete_meta(&self, key: &str) {
5515        meta_delete(&self.conn, key);
5516    }
5517
5518    fn restore_persisted_state(&mut self) -> Result<(), String> {
5519        self.subs = {
5520            let mut stmt = self
5521                .conn
5522                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
5523                .map_err(|error| error.to_string())?;
5524            let rows = stmt
5525                .query_map([], |row| {
5526                    Ok((
5527                        row.get::<_, String>(0)?,
5528                        row.get::<_, String>(1)?,
5529                        row.get::<_, String>(2)?,
5530                    ))
5531                })
5532                .map_err(|error| error.to_string())?;
5533            let mut subscriptions = Vec::new();
5534            for row in rows {
5535                let (id, table, raw) = row.map_err(|error| error.to_string())?;
5536                let state: Value = serde_json::from_str(&raw)
5537                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
5538                let requested = json_to_scope_map(
5539                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
5540                )?;
5541                let effective = state
5542                    .get("effectiveScopes")
5543                    .filter(|value| !value.is_null())
5544                    .map(json_to_scope_map)
5545                    .transpose()?;
5546                subscriptions.push(Subscription {
5547                    id,
5548                    table,
5549                    requested,
5550                    params: state
5551                        .get("params")
5552                        .and_then(Value::as_str)
5553                        .map(str::to_owned),
5554                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
5555                    bootstrap_state: state
5556                        .get("bootstrapState")
5557                        .and_then(Value::as_str)
5558                        .map(str::to_owned),
5559                    state: SubState::parse(
5560                        state
5561                            .get("status")
5562                            .and_then(Value::as_str)
5563                            .unwrap_or("active"),
5564                    ),
5565                    reason_code: state
5566                        .get("reasonCode")
5567                        .and_then(Value::as_str)
5568                        .map(str::to_owned),
5569                    effective,
5570                    synced_once: state
5571                        .get("syncedOnce")
5572                        .and_then(Value::as_bool)
5573                        .unwrap_or(false),
5574                });
5575            }
5576            subscriptions
5577        };
5578
5579        self.outbox = {
5580            let mut stmt = self
5581                .conn
5582                .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
5583                .map_err(|error| error.to_string())?;
5584            let rows = stmt
5585                .query_map([], |row| {
5586                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5587                })
5588                .map_err(|error| error.to_string())?;
5589            let mut commits = Vec::new();
5590            for row in rows {
5591                let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
5592                let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
5593                    format!("invalid persisted outbox {client_commit_id:?}: {error}")
5594                })?;
5595                let mut ops = Vec::with_capacity(entries.len());
5596                for entry in entries {
5597                    let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
5598                    ops.push(OutboxOp {
5599                        upsert: op == "upsert",
5600                        table: entry
5601                            .get("table")
5602                            .and_then(Value::as_str)
5603                            .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
5604                            .to_owned(),
5605                        row_id: entry
5606                            .get("rowId")
5607                            .and_then(Value::as_str)
5608                            .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
5609                            .to_owned(),
5610                        base_version: entry.get("baseVersion").and_then(Value::as_i64),
5611                        // A legacy entry's `changedFields` key is ignored:
5612                        // the values map is the presence set (§6.1).
5613                        values: entry.get("values").and_then(Value::as_object).cloned(),
5614                    });
5615                }
5616                commits.push(OutboxCommit {
5617                    client_commit_id,
5618                    ops,
5619                });
5620            }
5621            commits
5622        };
5623
5624        self.prune_commit_outcomes()?;
5625        let active = self.commit_outcomes(CommitOutcomeQuery {
5626            active_only: true,
5627            ..CommitOutcomeQuery::default()
5628        })?;
5629        self.conflicts = active
5630            .iter()
5631            .flat_map(|outcome| outcome.results.iter())
5632            .filter_map(|result| match result {
5633                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
5634                _ => None,
5635            })
5636            .collect();
5637        self.rejections = active
5638            .iter()
5639            .flat_map(|outcome| outcome.results.iter())
5640            .filter_map(|result| match result {
5641                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
5642                _ => None,
5643            })
5644            .collect();
5645
5646        self.lease_state = self
5647            .get_meta(LEASE_STATE_KEY)
5648            .map(|raw| serde_json::from_str(&raw))
5649            .transpose()
5650            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
5651        self.schema_floor = self
5652            .get_meta(SCHEMA_FLOOR_KEY)
5653            .map(|raw| serde_json::from_str(&raw))
5654            .transpose()
5655            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
5656        self.stopped = self.schema_floor.is_some();
5657        // A replica left in unactivated preflight reopens gated: the quarantine
5658        // decision persists with the data across handle teardown and restart.
5659        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
5660            self.security_preflight = true;
5661        }
5662        Ok(())
5663    }
5664
5665    /// Remove registrations for tables the running schema no longer knows.
5666    /// Otherwise the server rejects every pull with `sync.unknown_table`.
5667    fn prune_unknown_subscriptions(&mut self) -> Result<(), String> {
5668        let valid_tables: BTreeSet<String> = self
5669            .schema
5670            .tables
5671            .iter()
5672            .map(|table| table.name.clone())
5673            .collect();
5674        let stale_ids: Vec<String> = self
5675            .subs
5676            .iter()
5677            .filter(|sub| !valid_tables.contains(&sub.table))
5678            .map(|sub| sub.id.clone())
5679            .collect();
5680        for id in &stale_ids {
5681            self.conn
5682                .execute(
5683                    "DELETE FROM _syncular_windows WHERE sub_id = ?1",
5684                    rusqlite::params![id],
5685                )
5686                .map_err(|error| error.to_string())?;
5687            self.conn
5688                .execute(
5689                    "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
5690                    rusqlite::params![id],
5691                )
5692                .map_err(|error| error.to_string())?;
5693            self.conn
5694                .execute(
5695                    "DELETE FROM _syncular_subscriptions WHERE id = ?1",
5696                    rusqlite::params![id],
5697                )
5698                .map_err(|error| error.to_string())?;
5699        }
5700        self.subs.retain(|sub| valid_tables.contains(&sub.table));
5701        Ok(())
5702    }
5703
5704    #[must_use]
5705    pub fn local_revision(&self) -> u64 {
5706        self.get_meta(LOCAL_REVISION_KEY)
5707            .and_then(|value| value.parse().ok())
5708            .unwrap_or(0)
5709    }
5710
5711    #[must_use]
5712    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
5713        SyncStatusSnapshot {
5714            current_schema_version: self.schema.version,
5715            outbox: self.outbox.len(),
5716            upgrading: self.upgrading,
5717            lease_state: self.lease_state.clone(),
5718            schema_floor: self.schema_floor.clone(),
5719            sync_needed: self.sync_needed,
5720            previous_version_context: self.previous_version_status(),
5721        }
5722    }
5723
5724    /// RFC 0005 A2: container presence for a host that must act on it (Diego's
5725    /// rollback precondition), read without creating the file.
5726    fn previous_version_status(&self) -> PreviousVersionStatus {
5727        let Some(replica_path) = self.previous_version_replica_path() else {
5728            return PreviousVersionStatus {
5729                present: false,
5730                created_at_ms: None,
5731            };
5732        };
5733        match crate::previous_version::read_previous_version_record(&replica_path) {
5734            Ok(Some(record)) => PreviousVersionStatus {
5735                present: true,
5736                created_at_ms: Some(record.created_at_ms),
5737            },
5738            _ => PreviousVersionStatus {
5739                present: std::path::Path::new(
5740                    &crate::previous_version::previous_version_container_path(&replica_path),
5741                )
5742                .exists(),
5743                created_at_ms: None,
5744            },
5745        }
5746    }
5747
5748    /// RFC 0005 D7: read captured rows from the previous local schema. `state`
5749    /// is always `previousVersion`; the container never makes
5750    /// `query_snapshot()` coverage complete. Lease stop/expiry, scope
5751    /// revocation, coverage completion and the TTL DISCARD the container and
5752    /// close the read with their own reason.
5753    pub fn previous_version_snapshot(
5754        &self,
5755        spec: &PreviousVersionReadSpec,
5756    ) -> Result<PreviousVersionSnapshot, String> {
5757        let now_ms = self.clock_now_ms();
5758        crate::previous_version::previous_version_snapshot(
5759            &self.conn,
5760            self.previous_version_replica_path().as_deref(),
5761            self.previous_version.as_ref(),
5762            PreviousVersionLifecycle {
5763                lease_inactive: self.previous_version_lease_inactive(now_ms),
5764                scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
5765                coverage_complete: self.previous_version_coverage_complete(),
5766            },
5767            now_ms,
5768            self.schema.version,
5769            spec,
5770        )
5771    }
5772
5773    /// RFC 0005 D6: the advisory pre-reset compatibility audit.
5774    #[must_use]
5775    pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
5776        crate::previous_version::stored_previous_version_audit(&self.conn)
5777    }
5778
5779    /// RFC 0005 A2/D9: the executable downgrade step. Drop the container file
5780    /// and both metadata records, and report whether anything was present.
5781    /// Idempotent: absence is a successful no-op.
5782    pub fn previous_version_discard(
5783        &self,
5784    ) -> crate::previous_version::PreviousVersionDiscardOutcome {
5785        self.drop_previous_version()
5786    }
5787
5788    /// The module's discard has no failure path (it removes the file even when
5789    /// the container cannot be opened), so the only error a caller could ever
5790    /// see is a bug; report absence rather than claiming a discard happened.
5791    fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
5792        let absent = crate::previous_version::PreviousVersionDiscardOutcome {
5793            present: false,
5794            discarded: false,
5795        };
5796        let Some(replica_path) = self.previous_version_replica_path() else {
5797            return absent;
5798        };
5799        crate::previous_version::discard_previous_version(&self.conn, &replica_path)
5800            .unwrap_or(absent)
5801    }
5802
5803    /// RFC 0005 D7: `leaseState.errorCode` or an expired lease.
5804    fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
5805        let Some(lease) = &self.lease_state else {
5806            return false;
5807        };
5808        if lease.error_code.is_some() {
5809            return true;
5810        }
5811        lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
5812    }
5813
5814    /// RFC 0005 D7: §7.4.5 replacement coverage — every ACTIVE subscription
5815    /// has `cursor >= 0` and no resume token. An empty active set is not
5816    /// completion (mirrors the TS predicate exactly).
5817    fn previous_version_coverage_complete(&self) -> bool {
5818        let active = self
5819            .subs
5820            .iter()
5821            .filter(|sub| sub.state == SubState::Active)
5822            .collect::<Vec<_>>();
5823        if active.is_empty() {
5824            return false;
5825        }
5826        active
5827            .iter()
5828            .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
5829    }
5830
5831    /// RFC 0005 D7: lifetime triggers evaluated once per sync round, after the
5832    /// apply. A container that no longer has a reason to exist is discarded so
5833    /// the rows do not outlive the window they were kept for.
5834    fn previous_version_lifetime_check(&self) {
5835        let Some(config) = self.previous_version.filter(|config| config.enabled) else {
5836            return;
5837        };
5838        let now_ms = self.clock_now_ms();
5839        if self.previous_version_lease_inactive(now_ms) {
5840            let _ = self.drop_previous_version();
5841            return;
5842        }
5843        let Some(replica_path) = self.previous_version_replica_path() else {
5844            return;
5845        };
5846        let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
5847        else {
5848            return;
5849        };
5850        if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
5851            let _ = self.drop_previous_version();
5852            return;
5853        }
5854        if now_ms - record.created_at_ms > config.max_age_ms {
5855            let _ = self.drop_previous_version();
5856            return;
5857        }
5858        if self.previous_version_coverage_complete() {
5859            let _ = self.drop_previous_version();
5860        }
5861    }
5862
5863    pub fn diagnostics_snapshot(
5864        &self,
5865        request: &ClientDiagnosticsRequest,
5866    ) -> Result<ClientDiagnosticsSnapshot, String> {
5867        if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
5868            return Err(format!(
5869                "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
5870            ));
5871        }
5872        let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
5873        for sub in &self.subs {
5874            let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
5875            let complete =
5876                sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
5877            let state = match sub.state {
5878                SubState::Revoked => "revoked",
5879                SubState::Failed => "failed",
5880                SubState::Active if reset => "reset",
5881                SubState::Active if complete => "complete",
5882                SubState::Active => "bootstrapping",
5883            };
5884            subscriptions.insert(
5885                sub.id.clone(),
5886                DiagnosticSubscription {
5887                    id: sub.id.clone(),
5888                    table: sub.table.clone(),
5889                    state: state.to_owned(),
5890                    complete,
5891                    cursor: Some(sub.cursor),
5892                    reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
5893                },
5894            );
5895        }
5896        for expected in &request.expected_subscriptions {
5897            if expected.id.is_empty() || expected.table.is_empty() {
5898                return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
5899            }
5900            if subscriptions
5901                .get(&expected.id)
5902                .is_some_and(|registered| registered.table != expected.table)
5903            {
5904                subscriptions.insert(
5905                    expected.id.clone(),
5906                    DiagnosticSubscription {
5907                        id: expected.id.clone(),
5908                        table: expected.table.clone(),
5909                        state: "failed".to_owned(),
5910                        complete: false,
5911                        cursor: None,
5912                        reason_code: Some("client.subscription_intent_mismatch".to_owned()),
5913                    },
5914                );
5915            } else {
5916                subscriptions.entry(expected.id.clone()).or_insert_with(|| {
5917                    DiagnosticSubscription {
5918                        id: expected.id.clone(),
5919                        table: expected.table.clone(),
5920                        state: "unregistered".to_owned(),
5921                        complete: false,
5922                        cursor: None,
5923                        reason_code: None,
5924                    }
5925                });
5926            }
5927        }
5928        let mut ordered_subscriptions = Vec::new();
5929        let mut included = BTreeSet::new();
5930        for expected in &request.expected_subscriptions {
5931            if included.insert(expected.id.clone()) {
5932                if let Some(subscription) = subscriptions.get(&expected.id) {
5933                    ordered_subscriptions.push(subscription.clone());
5934                }
5935            }
5936        }
5937        for (id, subscription) in &subscriptions {
5938            if included.insert(id.clone()) {
5939                ordered_subscriptions.push(subscription.clone());
5940            }
5941        }
5942        let subscriptions_truncated =
5943            ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
5944        ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
5945        let captured_at_ms = self.clock_now_ms();
5946        let lease = if let Some(error_code) = self
5947            .lease_state
5948            .as_ref()
5949            .and_then(|state| state.error_code.clone())
5950        {
5951            ClientDiagnosticsLease {
5952                state: "stopped".to_owned(),
5953                expires_at_ms: self
5954                    .lease_state
5955                    .as_ref()
5956                    .and_then(|state| state.expires_at_ms),
5957                error_code: Some(Self::diagnostic_code(&error_code)),
5958            }
5959        } else if let Some(expires_at_ms) = self
5960            .lease_state
5961            .as_ref()
5962            .and_then(|state| state.expires_at_ms)
5963        {
5964            ClientDiagnosticsLease {
5965                state: if expires_at_ms <= captured_at_ms {
5966                    "expired".to_owned()
5967                } else {
5968                    "active".to_owned()
5969                },
5970                expires_at_ms: Some(expires_at_ms),
5971                error_code: None,
5972            }
5973        } else {
5974            ClientDiagnosticsLease {
5975                state: "none".to_owned(),
5976                expires_at_ms: None,
5977                error_code: None,
5978            }
5979        };
5980        let connectivity = match self.last_round.as_ref() {
5981            Some(round) if round.status == "succeeded" => "online",
5982            Some(round)
5983                if round.status == "failed"
5984                    && round
5985                        .error_code
5986                        .as_deref()
5987                        .is_some_and(Self::retryable_transport_code) =>
5988            {
5989                "offline"
5990            }
5991            _ => "unknown",
5992        };
5993        Ok(ClientDiagnosticsSnapshot {
5994            version: CLIENT_DIAGNOSTICS_VERSION,
5995            captured_at_ms,
5996            host: ClientDiagnosticsHost {
5997                kind: "direct".to_owned(),
5998                role: "single".to_owned(),
5999                connectivity: connectivity.to_owned(),
6000                realtime: if self.realtime_connected {
6001                    "connected".to_owned()
6002                } else {
6003                    "disconnected".to_owned()
6004                },
6005            },
6006            security_lifecycle: self.security_lifecycle().to_owned(),
6007            schema: ClientDiagnosticsSchema {
6008                current_version: self.schema.version,
6009                upgrading: self.upgrading,
6010                required_version: self
6011                    .schema_floor
6012                    .as_ref()
6013                    .and_then(|floor| floor.required_schema_version),
6014                latest_version: self
6015                    .schema_floor
6016                    .as_ref()
6017                    .and_then(|floor| floor.latest_schema_version),
6018            },
6019            replica: ClientDiagnosticsReplica {
6020                local_revision: self.local_revision().to_string(),
6021                sync_needed: self.sync_needed,
6022                pending_outbox: self.outbox.len(),
6023            },
6024            lease,
6025            subscriptions: ordered_subscriptions,
6026            subscriptions_truncated,
6027            last_round: self.last_round.clone(),
6028            last_change: self.last_change.clone(),
6029            storage: self.diagnostics_storage(),
6030            query_failures: self.query_failures.clone(),
6031        })
6032    }
6033
6034    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
6035        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
6036            let page_count: i64 = self
6037                .conn
6038                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
6039            let page_size: i64 = self
6040                .conn
6041                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
6042            // Reuse compiled aggregates; every invocation still reads current storage.
6043            let outbox_bytes: i64 = self
6044                .conn
6045                .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
6046                .query_row([], |row| row.get(0))?;
6047            let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
6048                "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
6049            )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
6050            let blob_bytes = if self.schema_has_blobs() {
6051                self.conn
6052                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
6053                    .query_row([], |row| row.get(0))?
6054            } else {
6055                0
6056            };
6057            let pressure = self
6058                .limits
6059                .blob_cache_max_bytes
6060                .is_some_and(|limit| blob_bytes > limit);
6061            Ok(ClientDiagnosticsStorage {
6062                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
6063                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
6064                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
6065                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
6066                retained_outcome_entries: Some(outcome_entries.max(0)),
6067                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
6068                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
6069            })
6070        };
6071        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
6072            status: "unreadable".to_owned(),
6073            database_bytes_approx: None,
6074            pending_outbox_bytes_approx: None,
6075            retained_outcome_bytes_approx: None,
6076            retained_outcome_entries: None,
6077            blob_cache_bytes_approx: None,
6078            pressure_reason_code: None,
6079        })
6080    }
6081
6082    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
6083        self.change_queue.drain(..).collect()
6084    }
6085
6086    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
6087        self.sync_intent_queue.drain(..).collect()
6088    }
6089
6090    fn schedule_background_retry(&mut self) {
6091        self.sync_intent_queue.push_back(SyncIntent::Background {
6092            delay_ms: self.retry_delay_ms,
6093        });
6094        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
6095    }
6096
6097    fn reset_background_retry(&mut self) {
6098        self.retry_delay_ms = 250;
6099    }
6100
6101    fn retryable_transport_code(code: &str) -> bool {
6102        code == "transport.failed"
6103            || code == "transport.unavailable"
6104            || code == "sync.transport_failed"
6105    }
6106
6107    fn diagnostic_code(code: &str) -> String {
6108        let valid = !code.is_empty()
6109            && code.len() <= 96
6110            && code.contains('.')
6111            && code
6112                .bytes()
6113                .next()
6114                .is_some_and(|byte| byte.is_ascii_lowercase())
6115            && code.bytes().all(|byte| {
6116                byte.is_ascii_lowercase()
6117                    || byte.is_ascii_digit()
6118                    || matches!(byte, b'.' | b'_' | b'-')
6119            });
6120        if valid {
6121            code.to_owned()
6122        } else {
6123            "client.unknown_failure".to_owned()
6124        }
6125    }
6126
6127    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
6128        if self.sync_needed != value {
6129            if self.begin_observation("syncular_status").is_ok() {
6130                self.sync_needed = value;
6131                let batch = ChangeAccumulator {
6132                    status: true,
6133                    ..ChangeAccumulator::default()
6134                };
6135                if self.finish_observation("syncular_status", batch).is_err() {
6136                    self.rollback_observation("syncular_status");
6137                }
6138            } else {
6139                self.sync_needed = value;
6140            }
6141        }
6142        if value && interactive {
6143            self.sync_intent_queue.push_back(SyncIntent::Interactive);
6144        }
6145    }
6146
6147    fn begin_observation(&self, name: &str) -> Result<(), String> {
6148        self.conn
6149            .execute_batch(&format!("SAVEPOINT {name}"))
6150            .map_err(|error| error.to_string())
6151    }
6152
6153    fn rollback_observation(&self, name: &str) {
6154        let _ = self
6155            .conn
6156            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"));
6157    }
6158
6159    fn finish_observation(&mut self, name: &str, batch: ChangeAccumulator) -> Result<(), String> {
6160        #[cfg(feature = "bench-internals")]
6161        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
6162        if !batch.touched() {
6163            self.conn
6164                .execute_batch(&format!("RELEASE {name}"))
6165                .map_err(|error| error.to_string())?;
6166            return Ok(());
6167        }
6168        let revision = self
6169            .local_revision()
6170            .checked_add(1)
6171            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
6172        self.conn
6173            .execute(
6174                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
6175                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
6176            )
6177            .map_err(|error| error.to_string())?;
6178        let status = batch.status.then(|| self.status_snapshot());
6179        let event = ClientChangeBatch {
6180            revision: revision.to_string(),
6181            tables: batch
6182                .tables
6183                .into_iter()
6184                .map(|(table, scope_keys)| TableChange {
6185                    table,
6186                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
6187                })
6188                .collect(),
6189            windows: batch
6190                .windows
6191                .into_iter()
6192                .map(|((base_key, table), units)| WindowChange {
6193                    base_key,
6194                    table,
6195                    units: units.into_iter().collect(),
6196                })
6197                .collect(),
6198            status,
6199            conflicts_changed: batch.conflicts,
6200            rejections_changed: batch.rejections,
6201            outcomes_changed: batch.outcomes,
6202        };
6203        self.conn
6204            .execute_batch(&format!("RELEASE {name}"))
6205            .map_err(|error| error.to_string())?;
6206        let mut diagnostic_tables = event
6207            .tables
6208            .iter()
6209            .map(|entry| entry.table.clone())
6210            .collect::<BTreeSet<_>>()
6211            .into_iter()
6212            .collect::<Vec<_>>();
6213        let mut diagnostic_windows = event
6214            .windows
6215            .iter()
6216            .map(|entry| entry.table.clone())
6217            .collect::<BTreeSet<_>>()
6218            .into_iter()
6219            .collect::<Vec<_>>();
6220        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
6221            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
6222        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
6223        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
6224        self.last_change = Some(DiagnosticLastChange {
6225            revision: event.revision.clone(),
6226            recorded_at_ms: self.clock_now_ms(),
6227            tables: diagnostic_tables,
6228            windows: diagnostic_windows,
6229            domains_truncated,
6230            status_changed: event.status.is_some(),
6231            conflicts_changed: event.conflicts_changed,
6232            rejections_changed: event.rejections_changed,
6233            outcomes_changed: event.outcomes_changed,
6234        });
6235        self.change_queue.push_back(event);
6236        Ok(())
6237    }
6238
6239    fn record_scope_map(
6240        &self,
6241        batch: &mut ChangeAccumulator,
6242        table_name: &str,
6243        scopes: &[(String, Vec<String>)],
6244    ) {
6245        let Some(table) = self.schema.table(table_name) else {
6246            return;
6247        };
6248        for (variable, values) in scopes {
6249            let Some(scope) = table
6250                .scope_variables
6251                .iter()
6252                .find(|scope| &scope.variable == variable)
6253            else {
6254                continue;
6255            };
6256            for value in values {
6257                batch.scope(table_name, format!("{}:{value}", scope.prefix));
6258            }
6259        }
6260    }
6261
6262    /// Record a row's current scope keys from the base or visible table.
6263    fn record_row_scopes(
6264        &self,
6265        batch: &mut ChangeAccumulator,
6266        table_name: &str,
6267        row_id: &str,
6268        base: bool,
6269    ) -> bool {
6270        let Some(table) = self.schema.table(table_name) else {
6271            return false;
6272        };
6273        if table.scope_variables.is_empty() {
6274            return false;
6275        }
6276        let columns = table
6277            .scope_variables
6278            .iter()
6279            .map(|scope| quote_ident(&scope.column))
6280            .collect::<Vec<_>>()
6281            .join(", ");
6282        let full_table = if base {
6283            base_table(table_name)
6284        } else {
6285            visible_table(table_name)
6286        };
6287        let sql = format!(
6288            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
6289            row_id_predicate(table)
6290        );
6291        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
6292            return false;
6293        };
6294        let values = stmt.query_row(rusqlite::params![row_id], |row| {
6295            let mut values = Vec::with_capacity(table.scope_variables.len());
6296            for index in 0..table.scope_variables.len() {
6297                values.push(row.get::<_, Option<String>>(index)?);
6298            }
6299            Ok(values)
6300        });
6301        let Ok(values) = values else {
6302            return false;
6303        };
6304        let mut recorded = false;
6305        for (scope, value) in table.scope_variables.iter().zip(values) {
6306            if let Some(value) = value {
6307                batch.scope(table_name, format!("{}:{value}", scope.prefix));
6308                recorded = true;
6309            }
6310        }
6311        recorded
6312    }
6313
6314    fn record_commit_changes(
6315        &self,
6316        batch: &mut ChangeAccumulator,
6317        tables: &[String],
6318        changes: &[ssp2::model::Change],
6319    ) {
6320        #[cfg(feature = "bench-internals")]
6321        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
6322        for change in changes {
6323            let Some(table_name) = tables.get(change.table_index as usize) else {
6324                continue;
6325            };
6326            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
6327            if let Some(table) = self.schema.table(table_name) {
6328                for (variable, value) in &change.scopes {
6329                    if let Some(scope) = table
6330                        .scope_variables
6331                        .iter()
6332                        .find(|scope| &scope.variable == variable)
6333                    {
6334                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
6335                        precise = true;
6336                    }
6337                }
6338            }
6339            if !precise {
6340                batch.table(table_name);
6341            }
6342        }
6343    }
6344
6345    fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
6346        if effective.is_empty() {
6347            return false;
6348        }
6349        let Some(table) = self.schema.table(table_name) else {
6350            return false;
6351        };
6352        let mut clauses = Vec::new();
6353        let mut params = Vec::new();
6354        for (variable, values) in effective {
6355            let Some(column) = table.scope_column(variable) else {
6356                return false;
6357            };
6358            if values.is_empty() {
6359                return false;
6360            }
6361            let placeholders = values
6362                .iter()
6363                .map(|value| {
6364                    params.push(SqlValue::Text(value.clone()));
6365                    "?"
6366                })
6367                .collect::<Vec<_>>()
6368                .join(", ");
6369            clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
6370        }
6371        let sql = format!(
6372            "SELECT 1 FROM {} WHERE {} LIMIT 1",
6373            base_table(table_name),
6374            clauses.join(" AND ")
6375        );
6376        self.conn
6377            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
6378            .is_ok()
6379    }
6380
6381    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
6382    pub fn upgrading(&self) -> bool {
6383        self.upgrading
6384    }
6385
6386    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
6387    /// keeping this client's local database (identity, outbox, tables). The
6388    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
6389    /// version changed. Mirrors the TS client's boot-time detection —
6390    /// the Rust core has no persistent restart, so recreation IS the boot.
6391    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
6392        let new_schema = parse_schema_json(schema_json)?;
6393        let marker: Option<i32> = self
6394            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6395            .and_then(|v| v.parse().ok());
6396        self.schema = new_schema;
6397        self.create_bookkeeping_tables()?;
6398        if marker != Some(self.schema.version) {
6399            self.run_schema_reset()?;
6400        }
6401        self.prune_unknown_subscriptions()?;
6402        // The conformance recreate is the in-memory equivalent of reopening a
6403        // durable client. Apply the same startup catch-up contract even when
6404        // the schema itself did not change.
6405        self.enqueue_startup_sync_if_needed()?;
6406        Ok(())
6407    }
6408
6409    fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
6410        let startup_work = !self.stopped
6411            && (!self.outbox.is_empty()
6412                || !self.load_pending_evictions()?.is_empty()
6413                || self.subs.iter().any(|sub| sub.state == SubState::Active));
6414        if startup_work {
6415            self.sync_needed = true;
6416            self.sync_intent_queue.push_back(SyncIntent::Interactive);
6417        }
6418        Ok(())
6419    }
6420
6421    /// A native client persists schema-floor stops across process restarts.
6422    /// Once the running app already satisfies that floor, the persisted stop
6423    /// is only stale evidence from an older server round (for example, the
6424    /// server was restarted after catching up to an app that was ahead).
6425    /// Clear it and let the normal startup pull re-negotiate. If the server is
6426    /// still incompatible it will return the floor again in that first round.
6427    fn clear_satisfied_persisted_schema_floor(&mut self) {
6428        let satisfied = self
6429            .schema_floor
6430            .as_ref()
6431            .and_then(|floor| floor.required_schema_version)
6432            .is_some_and(|required| self.schema.version >= required);
6433        if !satisfied {
6434            return;
6435        }
6436        self.schema_floor = None;
6437        self.stopped = false;
6438        self.delete_meta(SCHEMA_FLOOR_KEY);
6439    }
6440
6441    /// RFC 0005 D1: discard an orphan or stale container at boot. The reset
6442    /// path already swept it; this covers a same-version or fresh-install open,
6443    /// which runs no reset and therefore no sweep — plus the D7 TTL, which only
6444    /// an aware binary can apply.
6445    fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
6446        let Some(replica_path) = self.previous_version_replica_path() else {
6447            return Ok(());
6448        };
6449        let max_age_ms = self
6450            .previous_version
6451            .filter(|config| config.enabled)
6452            .map(|config| config.max_age_ms);
6453        reconcile_previous_version_at_boot(
6454            &self.conn,
6455            &replica_path,
6456            self.schema.version,
6457            max_age_ms,
6458            self.clock_now_ms(),
6459        )
6460    }
6461
6462    /// RFC 0005 D3: the sibling container path, or `None` when the replica has
6463    /// no file — an in-memory replica returns an empty path, and a `file:` URI
6464    /// has no plain sibling path — so the feature resolves as off there.
6465    fn previous_version_replica_path(&self) -> Option<String> {
6466        let path = self.conn.path()?;
6467        if path.is_empty() || path.starts_with("file:") {
6468            return None;
6469        }
6470        Some(path.to_owned())
6471    }
6472
6473    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
6474    /// and leaseState. Drops/recreates every synced table from the new
6475    /// schema, resets subscription sync-state (keeping registrations), clears
6476    /// the schema-floor stop state, rewrites the marker, drops outbox commits
6477    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
6478    fn run_schema_reset(&mut self) -> Result<(), String> {
6479        self.begin_observation("syncular_schema_reset")?;
6480        let mut batch = ChangeAccumulator::default();
6481        let result = self.run_schema_reset_observed(&mut batch, true, true);
6482        if let Err(error) = result {
6483            self.rollback_observation("syncular_schema_reset");
6484            return Err(error);
6485        }
6486        if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
6487            self.rollback_observation("syncular_schema_reset");
6488            return Err(error);
6489        }
6490        Ok(())
6491    }
6492
6493    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
6494        let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
6495        self.begin_observation("syncular_log_epoch_reset")?;
6496        let mut batch = ChangeAccumulator::default();
6497        let result = self.run_schema_reset_observed(&mut batch, false, false);
6498        if let Err(error) = result {
6499            self.rollback_observation("syncular_log_epoch_reset");
6500            return Err(error);
6501        }
6502        self.set_meta(LOG_EPOCH_KEY, log_epoch);
6503        self.sync_needed = true;
6504        batch.status = true;
6505        if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
6506            self.rollback_observation("syncular_log_epoch_reset");
6507            return Err(error);
6508        }
6509        self.sync_intent_queue.push_back(SyncIntent::Interactive);
6510        Ok(resets)
6511    }
6512
6513    fn run_schema_reset_observed(
6514        &mut self,
6515        batch: &mut ChangeAccumulator,
6516        drop_incompatible: bool,
6517        capture: bool,
6518    ) -> Result<(), String> {
6519        self.upgrading = true;
6520        batch.status = true;
6521        // RFC 0005 D6: classify the pending outbox against the NEW compiled
6522        // schema BEFORE the wipe, and record the advisory audit. It drops
6523        // nothing — the §7.4.4 send-time drop stays the only path that removes
6524        // a commit. The classification is shared with that drop so the two can
6525        // never disagree about what is incompatible.
6526        if self.previous_version.is_some_and(|config| config.enabled) {
6527            let previous_version = self
6528                .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6529                .and_then(|value| value.parse::<i32>().ok())
6530                .unwrap_or(self.schema.version);
6531            let pending = self
6532                .outbox
6533                .iter()
6534                .map(|commit| PendingCommitAudit {
6535                    commit_id: &commit.client_commit_id,
6536                    operations: commit_audit_operations(commit),
6537                })
6538                .collect::<Vec<_>>();
6539            crate::previous_version::write_previous_version_audit(
6540                &self.conn,
6541                &crate::previous_version::build_previous_version_audit(
6542                    &self.schema,
6543                    &pending,
6544                    previous_version,
6545                    self.schema.version,
6546                    self.clock_now_ms(),
6547                ),
6548            );
6549        }
6550        // RFC 0005 D5: the container lives in its own FILE, so the reset can
6551        // never reach it. Sweep first (unconditional — also on the log-epoch
6552        // reset, which captures nothing), then capture BEFORE the wipe drops
6553        // any row. A refused capture records its reason and leaves no file.
6554        if let Some(replica_path) = self.previous_version_replica_path() {
6555            sweep_previous_version_container(&self.conn, &replica_path)?;
6556            if capture {
6557                if let Some(config) = self.previous_version.filter(|config| config.enabled) {
6558                    let previous_version = self
6559                        .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6560                        .and_then(|value| value.parse::<i32>().ok())
6561                        .unwrap_or(self.schema.version);
6562                    capture_previous_version_from_replica(
6563                        &self.conn,
6564                        &replica_path,
6565                        previous_version,
6566                        self.schema.version,
6567                        &config,
6568                        self.clock_now_ms(),
6569                    )?;
6570                }
6571            }
6572        }
6573        for table in &self.schema.tables {
6574            batch.table(&table.name);
6575        }
6576        for (base_key, unit, table) in self.load_registered_window_units() {
6577            batch.window(&base_key, &table, &unit);
6578        }
6579        // The per-table insert SQL is derived from the OLD column lists.
6580        self.insert_sql.borrow_mut().clear();
6581        self.overlay_dirty.set(true);
6582        // Drop every synced table (base + visible) that currently exists —
6583        // discovered from sqlite_master so a bump that adds/removes tables is
6584        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
6585        // and the blob cache) are preserved; base tables are `_syncular_base_*`
6586        // so they are matched explicitly, not by the bookkeeping filter.
6587        // Drop virtual tables first so SQLite removes every FTS shadow table
6588        // atomically instead of the generic discovery tearing it apart.
6589        let virtual_tables: Vec<String> = {
6590            let mut stmt = self
6591                .conn
6592                .prepare(
6593                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
6594                )
6595                .map_err(|e| e.to_string())?;
6596            let rows = stmt
6597                .query_map([], |row| row.get::<_, String>(0))
6598                .map_err(|e| e.to_string())?;
6599            rows.filter_map(Result::ok)
6600                .filter(|name| is_synced_table_name(name))
6601                .collect()
6602        };
6603        for name in virtual_tables {
6604            self.conn
6605                .execute_batch(&format!(
6606                    "DROP TABLE IF EXISTS {}",
6607                    quote_ident(&format!("_syncular_fts_{name}"))
6608                ))
6609                .map_err(|e| e.to_string())?;
6610            self.conn
6611                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
6612                .map_err(|e| e.to_string())?;
6613        }
6614        let existing: Vec<String> = {
6615            let mut stmt = self
6616                .conn
6617                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
6618                .map_err(|e| e.to_string())?;
6619            let rows = stmt
6620                .query_map([], |row| row.get::<_, String>(0))
6621                .map_err(|e| e.to_string())?;
6622            rows.filter_map(Result::ok)
6623                .filter(|name| is_synced_table_name(name))
6624                .collect()
6625        };
6626        for name in &existing {
6627            if let Some(table) = name.strip_prefix("_syncular_base_") {
6628                batch.table(table);
6629            } else if !name.starts_with("_syncular_") {
6630                batch.table(name);
6631            }
6632        }
6633        for name in existing {
6634            self.conn
6635                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
6636                .map_err(|e| e.to_string())?;
6637        }
6638        // Recreate the synced tables from the NEW schema.
6639        self.create_synced_tables()?;
6640        // Reset every subscription's sync-state, keeping the registration.
6641        for sub in &mut self.subs {
6642            sub.cursor = -1;
6643            sub.bootstrap_state = None;
6644            sub.effective = None;
6645            sub.state = SubState::Active;
6646            sub.reason_code = None;
6647            sub.synced_once = false;
6648        }
6649        let subs = self.subs.clone();
6650        for sub in &subs {
6651            self.persist_sub(sub)?;
6652        }
6653        // The stop state is over: this client now ships a servable schema.
6654        self.stopped = false;
6655        self.schema_floor = None;
6656        self.delete_meta(SCHEMA_FLOOR_KEY);
6657        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
6658        self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
6659        // D1: the descriptor follows every marker write. A crash in between
6660        // self-heals on the next same-version open, which backfills it.
6661        set_local_schema_descriptor(&self.conn, &self.schema);
6662        // §7.4.4: drop outbox commits that cannot re-encode under the new
6663        // schema (a referenced column/table the bump removed), surfacing each
6664        // as a `sync.outbox_incompatible` rejection.
6665        if drop_incompatible && self.drop_incompatible_outbox()? {
6666            batch.rejections = true;
6667            batch.status = true;
6668            batch.outcomes = true;
6669        }
6670        // Re-apply the surviving outbox optimistically over the empty tables.
6671        self.rebuild_overlay();
6672        Ok(())
6673    }
6674
6675    /// §7.4.4: a persisted upsert whose values reference a column the current
6676    /// schema lacks (or a removed table) cannot be encoded. Drop the commit
6677    /// and raise a client-local `sync.outbox_incompatible` rejection.
6678    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
6679        let incompatible = self
6680            .outbox
6681            .iter()
6682            .filter(|commit| {
6683                first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
6684            })
6685            .cloned()
6686            .collect::<Vec<_>>();
6687        if incompatible.is_empty() {
6688            return Ok(false);
6689        }
6690        let mut rejections = Vec::new();
6691        for commit in &incompatible {
6692            let results = commit
6693                .ops
6694                .iter()
6695                .enumerate()
6696                .map(|(op_index, operation)| {
6697                    let rejection = RejectionRecord {
6698                        client_commit_id: commit.client_commit_id.clone(),
6699                        op_index: op_index as i32,
6700                        code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
6701                        message: "the persisted commit cannot encode under the current schema"
6702                            .to_owned(),
6703                        retryable: false,
6704                        details: None,
6705                        operation: Some(CommitOperation::from(operation)),
6706                    };
6707                    rejections.push(rejection.clone());
6708                    CommitOperationOutcome::Error { rejection }
6709                })
6710                .collect::<Vec<_>>();
6711            self.persist_commit_outcome(
6712                &commit.client_commit_id,
6713                CommitOutcomeStatus::Rejected,
6714                &results,
6715                Some(&commit.ops),
6716            )?;
6717            self.delete_outbox_persisted(&commit.client_commit_id)?;
6718        }
6719        self.prune_commit_outcomes()?;
6720        let incompatible_ids = incompatible
6721            .iter()
6722            .map(|commit| commit.client_commit_id.as_str())
6723            .collect::<BTreeSet<_>>();
6724        self.outbox
6725            .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
6726        self.rejections.extend(rejections);
6727        Ok(true)
6728    }
6729
6730    /// §7.4.3: (re)create the base + visible table pair for every synced
6731    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
6732    fn create_synced_tables(&self) -> Result<(), String> {
6733        for table in &self.schema.tables {
6734            // The base half + the visible half form the synced-table pair. An
6735            // index name is global in SQLite, so the base half's indexes are
6736            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
6737            for (full, index_prefix) in [
6738                (base_table(&table.name), "_syncular_base_"),
6739                (visible_table(&table.name), ""),
6740            ] {
6741                let mut cols: Vec<String> =
6742                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
6743                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
6744                let sql = format!(
6745                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
6746                    cols.join(", "),
6747                    quote_ident(&table.primary_key)
6748                );
6749                self.conn.execute(&sql, []).map_err(|e| e.to_string())?;
6750                // Local secondary indexes (CREATE INDEX subset). Created on
6751                // both halves so mirror reads hit an index on either. Runs on
6752                // both the initial create and the §7.4.3 reset recreate path.
6753                for index in &table.indexes {
6754                    let unique = if index.unique { "UNIQUE " } else { "" };
6755                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
6756                    let cols_sql = index
6757                        .columns
6758                        .iter()
6759                        .map(|c| quote_ident(c))
6760                        .collect::<Vec<_>>()
6761                        .join(", ");
6762                    let index_sql = format!(
6763                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
6764                    );
6765                    self.conn
6766                        .execute(&index_sql, [])
6767                        .map_err(|e| e.to_string())?;
6768                }
6769            }
6770        }
6771        // FTS exists only on the visible half. It is a local search
6772        // projection, never a synced/base table or a wire-schema member.
6773        for table in &self.schema.tables {
6774            for index in &table.fts_indexes {
6775                self.create_fts_projection(table, index)?;
6776            }
6777        }
6778        Ok(())
6779    }
6780
6781    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
6782        let count: i64 = self
6783            .conn
6784            .query_row(
6785                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
6786                rusqlite::params![index.name],
6787                |row| row.get(0),
6788            )
6789            .map_err(|e| e.to_string())?;
6790        Ok(count > 0)
6791    }
6792
6793    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
6794        for suffix in ["ai", "ad", "au"] {
6795            self.conn
6796                .execute(
6797                    &format!(
6798                        "DROP TRIGGER IF EXISTS {}",
6799                        quote_ident(&format!("{}_{suffix}", index.name))
6800                    ),
6801                    [],
6802                )
6803                .map_err(|e| e.to_string())?;
6804        }
6805        Ok(())
6806    }
6807
6808    fn create_fts_triggers(
6809        &self,
6810        table: &TableSchema,
6811        index: &FtsIndexSchema,
6812    ) -> Result<(), String> {
6813        let fts = quote_ident(&index.name);
6814        let source = visible_table(&table.name);
6815        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
6816        let pk = quote_ident(&table.primary_key);
6817        let projection_columns = std::iter::once(source_id.clone())
6818            .chain(index.columns.iter().map(|column| quote_ident(column)))
6819            .collect::<Vec<_>>()
6820            .join(", ");
6821        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
6822            .chain(
6823                index
6824                    .columns
6825                    .iter()
6826                    .map(|column| format!("new.{}", quote_ident(column))),
6827            )
6828            .collect::<Vec<_>>()
6829            .join(", ");
6830        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
6831        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)");
6832        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)");
6833        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})");
6834        let bi = quote_ident(&format!("{}_bi", index.name));
6835        let ai = quote_ident(&format!("{}_ai", index.name));
6836        let ad = quote_ident(&format!("{}_ad", index.name));
6837        let au = quote_ident(&format!("{}_au", index.name));
6838        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
6839        let sql = format!(
6840            "DROP TRIGGER IF EXISTS {bi};
6841             DROP TRIGGER IF EXISTS {ai};
6842             DROP TRIGGER IF EXISTS {ad};
6843             DROP TRIGGER IF EXISTS {au};
6844             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
6845             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
6846             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
6847             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
6848        );
6849        self.conn.execute_batch(&sql).map_err(|e| e.to_string())
6850    }
6851
6852    fn rebuild_fts_projection(
6853        &self,
6854        table: &TableSchema,
6855        index: &FtsIndexSchema,
6856    ) -> Result<(), String> {
6857        let fts = quote_ident(&index.name);
6858        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
6859        let pk = quote_ident(&table.primary_key);
6860        let indexed_columns = index
6861            .columns
6862            .iter()
6863            .map(|column| quote_ident(column))
6864            .collect::<Vec<_>>();
6865        let projection_columns = std::iter::once(source_id.clone())
6866            .chain(indexed_columns.iter().cloned())
6867            .collect::<Vec<_>>()
6868            .join(", ");
6869        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
6870        let sql = format!(
6871            "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};",
6872            columns = indexed_columns.join(", "),
6873            source = visible_table(&table.name),
6874        );
6875        self.conn.execute_batch(&sql).map_err(|e| e.to_string())
6876    }
6877
6878    fn create_fts_projection(
6879        &self,
6880        table: &TableSchema,
6881        index: &FtsIndexSchema,
6882    ) -> Result<(), String> {
6883        self.begin_observation("syncular_fts_schema")?;
6884        let result = (|| {
6885            let existed = self.fts_projection_exists(index)?;
6886            let tokenizer = index.tokenize.replace('\'', "''");
6887            let columns = index
6888                .columns
6889                .iter()
6890                .map(|column| quote_ident(column))
6891                .collect::<Vec<_>>()
6892                .join(", ");
6893            let sql = format!(
6894            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
6895            fts = quote_ident(&index.name),
6896            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
6897        );
6898            self.conn.execute(&sql, []).map_err(|error| {
6899                format!(
6900                    "cannot create local FTS5 projection {:?}: {error}",
6901                    index.name
6902                )
6903            })?;
6904            let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
6905            let mapped: bool = self
6906                .conn
6907                .query_row(
6908                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
6909                    [format!("_syncular_fts_{}", index.name)],
6910                    |row| row.get(0),
6911                )
6912                .map_err(|e| e.to_string())?;
6913            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())?;
6914            if !mapped && existed {
6915                self.conn
6916                    .execute_batch(&format!(
6917                        "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
6918                        quote_ident(FTS_SOURCE_ID_COLUMN),
6919                        quote_ident(&index.name)
6920                    ))
6921                    .map_err(|e| e.to_string())?;
6922            }
6923            self.create_fts_triggers(table, index)?;
6924            if !existed {
6925                self.rebuild_fts_projection(table, index)?;
6926            }
6927            Ok(())
6928        })();
6929        if let Err(error) = result {
6930            self.rollback_observation("syncular_fts_schema");
6931            return Err(error);
6932        }
6933        if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
6934            self.rollback_observation("syncular_fts_schema");
6935            return Err(error.to_string());
6936        }
6937        Ok(())
6938    }
6939
6940    // -- persistence write-through --------------------------------------------
6941
6942    fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
6943        let state = serde_json::json!({
6944            "requested": scope_map_to_json(&sub.requested),
6945            "params": sub.params,
6946            "cursor": sub.cursor,
6947            "bootstrapState": sub.bootstrap_state,
6948            "status": sub.state.name(),
6949            "reasonCode": sub.reason_code,
6950            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
6951            "syncedOnce": sub.synced_once,
6952        });
6953        self.conn.execute(
6954            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
6955            rusqlite::params![sub.id, sub.table, state.to_string()],
6956        ).map(|_| ()).map_err(|error| error.to_string())
6957    }
6958
6959    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
6960        let ops: Vec<Value> = commit
6961            .ops
6962            .iter()
6963            .map(|op| {
6964                serde_json::json!({
6965                    "op": if op.upsert { "upsert" } else { "delete" },
6966                    "table": op.table,
6967                    "rowId": op.row_id,
6968                    "baseVersion": op.base_version,
6969                    "values": op.values.clone().map(Value::Object),
6970                })
6971            })
6972            .collect();
6973        self.conn
6974            .execute(
6975                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
6976                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
6977            )
6978            .map_err(|error| error.to_string())?;
6979        for operation in &commit.ops {
6980            let Some(values) = &operation.values else {
6981                continue;
6982            };
6983            let Some(table) = self.schema.table(&operation.table) else {
6984                continue;
6985            };
6986            for column in table
6987                .columns
6988                .iter()
6989                .filter(|column| column.ty == ColumnType::BlobRef)
6990            {
6991                let Some(Value::String(raw)) = values.get(&column.name) else {
6992                    continue;
6993                };
6994                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
6995                    continue;
6996                };
6997                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
6998                    continue;
6999                };
7000                self.conn
7001                    .execute(
7002                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
7003                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
7004                        rusqlite::params![commit.client_commit_id, blob_id],
7005                    )
7006                    .map_err(|error| error.to_string())?;
7007            }
7008        }
7009        Ok(())
7010    }
7011
7012    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
7013        self.conn
7014            .execute(
7015                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
7016                rusqlite::params![client_commit_id],
7017            )
7018            .map_err(|error| error.to_string())?;
7019        self.conn
7020            .execute(
7021                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
7022                rusqlite::params![client_commit_id],
7023            )
7024            .map(|_| ())
7025            .map_err(|error| error.to_string())
7026    }
7027
7028    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
7029        match status {
7030            CommitOutcomeStatus::Applied => "applied",
7031            CommitOutcomeStatus::Cached => "cached",
7032            CommitOutcomeStatus::Conflict => "conflict",
7033            CommitOutcomeStatus::Rejected => "rejected",
7034        }
7035    }
7036
7037    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
7038        match resolution {
7039            CommitOutcomeResolution::Active => "active",
7040            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
7041            CommitOutcomeResolution::Superseded => "superseded",
7042            CommitOutcomeResolution::Dismissed => "dismissed",
7043        }
7044    }
7045
7046    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
7047        match value {
7048            "applied" => Ok(CommitOutcomeStatus::Applied),
7049            "cached" => Ok(CommitOutcomeStatus::Cached),
7050            "conflict" => Ok(CommitOutcomeStatus::Conflict),
7051            "rejected" => Ok(CommitOutcomeStatus::Rejected),
7052            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
7053        }
7054    }
7055
7056    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
7057        match value {
7058            "active" => Ok(CommitOutcomeResolution::Active),
7059            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
7060            "superseded" => Ok(CommitOutcomeResolution::Superseded),
7061            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
7062            _ => Err(format!(
7063                "invalid persisted commit outcome resolution {value:?}"
7064            )),
7065        }
7066    }
7067
7068    fn persist_commit_outcome(
7069        &self,
7070        client_commit_id: &str,
7071        status: CommitOutcomeStatus,
7072        results: &[CommitOperationOutcome],
7073        operations: Option<&[OutboxOp]>,
7074    ) -> Result<(), String> {
7075        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
7076        let operations_json = operations
7077            .map(|items| {
7078                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
7079            })
7080            .transpose()
7081            .map_err(|error| error.to_string())?;
7082        self.conn
7083            .execute(
7084                "INSERT INTO _syncular_commit_outcomes (
7085                   client_commit_id, status, recorded_at_ms, results_json,
7086                   operations_json, resolution
7087                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
7088                rusqlite::params![
7089                    client_commit_id,
7090                    Self::outcome_status_name(status),
7091                    self.clock_now_ms(),
7092                    results_json,
7093                    operations_json
7094                ],
7095            )
7096            .map(|_| ())
7097            .map_err(|error| error.to_string())
7098    }
7099
7100    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
7101        let StoredCommitOutcomeRow {
7102            sequence,
7103            client_commit_id,
7104            status,
7105            recorded_at_ms,
7106            results_json,
7107            operations_json,
7108            resolution,
7109            resolved_at_ms,
7110            replacement_client_commit_id,
7111        } = row;
7112        Ok(CommitOutcome {
7113            sequence,
7114            client_commit_id,
7115            status: Self::parse_outcome_status(&status)?,
7116            recorded_at_ms,
7117            results: serde_json::from_str(&results_json)
7118                .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
7119            operations: operations_json
7120                .map(|value| {
7121                    serde_json::from_str(&value).map_err(|error| {
7122                        format!("invalid persisted commit outcome operations: {error}")
7123                    })
7124                })
7125                .transpose()?,
7126            resolution: Self::parse_outcome_resolution(&resolution)?,
7127            resolved_at_ms,
7128            replacement_client_commit_id,
7129        })
7130    }
7131
7132    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
7133        let row = self
7134            .conn
7135            .query_row(
7136                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7137                        resolution, resolved_at_ms, replacement_client_commit_id
7138                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
7139                rusqlite::params![client_commit_id],
7140                |row| {
7141                    Ok(StoredCommitOutcomeRow {
7142                        sequence: row.get(0)?,
7143                        client_commit_id: row.get(1)?,
7144                        status: row.get(2)?,
7145                        recorded_at_ms: row.get(3)?,
7146                        results_json: row.get(4)?,
7147                        operations_json: row.get(5)?,
7148                        resolution: row.get(6)?,
7149                        resolved_at_ms: row.get(7)?,
7150                        replacement_client_commit_id: row.get(8)?,
7151                    })
7152                },
7153            )
7154            .optional()
7155            .map_err(|error| error.to_string())?;
7156        row.map(Self::outcome_from_row).transpose()
7157    }
7158
7159    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
7160        if query.limit == Some(0) {
7161            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
7162        }
7163        let mut sql = String::from(
7164            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7165                    resolution, resolved_at_ms, replacement_client_commit_id
7166               FROM _syncular_commit_outcomes",
7167        );
7168        if query.active_only {
7169            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
7170        }
7171        sql.push_str(" ORDER BY seq DESC");
7172        if let Some(limit) = query.limit {
7173            sql.push_str(&format!(" LIMIT {limit}"));
7174        }
7175        let mut stmt = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
7176        let rows = stmt
7177            .query_map([], |row| {
7178                Ok(StoredCommitOutcomeRow {
7179                    sequence: row.get(0)?,
7180                    client_commit_id: row.get(1)?,
7181                    status: row.get(2)?,
7182                    recorded_at_ms: row.get(3)?,
7183                    results_json: row.get(4)?,
7184                    operations_json: row.get(5)?,
7185                    resolution: row.get(6)?,
7186                    resolved_at_ms: row.get(7)?,
7187                    replacement_client_commit_id: row.get(8)?,
7188                })
7189            })
7190            .map_err(|error| error.to_string())?;
7191        let mut outcomes = Vec::new();
7192        for row in rows {
7193            outcomes.push(Self::outcome_from_row(
7194                row.map_err(|error| error.to_string())?,
7195            )?);
7196        }
7197        Ok(outcomes)
7198    }
7199
7200    fn prune_commit_outcomes(&self) -> Result<(), String> {
7201        #[cfg(test)]
7202        self.outcome_prune_count
7203            .set(self.outcome_prune_count.get() + 1);
7204        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
7205        let count = self
7206            .conn
7207            .query_row(
7208                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
7209                [],
7210                |row| row.get::<_, i64>(0),
7211            )
7212            .map_err(|error| error.to_string())? as usize;
7213        let excess = count.saturating_sub(max_entries);
7214        if excess == 0 {
7215            return Ok(());
7216        }
7217        let mut stmt = self
7218            .conn
7219            .prepare(
7220                "SELECT seq FROM _syncular_commit_outcomes
7221                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
7222                  ORDER BY seq ASC LIMIT ?1",
7223            )
7224            .map_err(|error| error.to_string())?;
7225        let rows = stmt
7226            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
7227            .map_err(|error| error.to_string())?;
7228        let sequences = rows
7229            .collect::<Result<Vec<_>, _>>()
7230            .map_err(|error| error.to_string())?;
7231        drop(stmt);
7232        for sequence in sequences {
7233            self.conn
7234                .execute(
7235                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
7236                    rusqlite::params![sequence],
7237                )
7238                .map_err(|error| error.to_string())?;
7239        }
7240        Ok(())
7241    }
7242
7243    // -- driver surface ---------------------------------------------------------
7244
7245    pub fn subscribe(
7246        &mut self,
7247        id: String,
7248        table: String,
7249        scopes: Vec<(String, Vec<String>)>,
7250        params: Option<String>,
7251    ) -> Result<(), String> {
7252        if self.schema.table(&table).is_none() {
7253            return Err(format!("unknown table {table:?}"));
7254        }
7255        if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
7256            let same_intent = existing.table == table
7257                && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
7258                && existing.params == params;
7259            if same_intent {
7260                return Ok(());
7261            }
7262            return Err(
7263                "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
7264                    .to_owned(),
7265            );
7266        }
7267        let sub = Subscription {
7268            id: id.clone(),
7269            table,
7270            requested: scopes,
7271            params,
7272            cursor: -1,
7273            bootstrap_state: None,
7274            state: SubState::Active,
7275            reason_code: None,
7276            effective: None,
7277            synced_once: false,
7278        };
7279        self.persist_sub(&sub)?;
7280        self.subs.push(sub);
7281        Ok(())
7282    }
7283
7284    pub fn unsubscribe(&mut self, id: &str) {
7285        self.subs.retain(|s| s.id != id);
7286        let _ = self.conn.execute(
7287            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7288            rusqlite::params![id],
7289        );
7290    }
7291
7292    // -- windowed subscriptions (§4.8) ------------------------------------------
7293
7294    /// §4.8: set the live window units for a base — a value-sharded family
7295    /// of subscriptions, one per unit. Added units get fresh subscriptions
7296    /// (image-lane bootstrap on the next sync); removed units are
7297    /// unsubscribed with their first eviction chunk in one transaction (E1–E4).
7298    /// Idempotent; re-entry cancels any deferred eviction.
7299    pub fn set_window(
7300        &mut self,
7301        base: &WindowBase,
7302        units: &[String],
7303    ) -> Result<CommandEffects, String> {
7304        let table = self
7305            .schema
7306            .table(&base.table)
7307            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
7308        if table.scope_column(&base.variable).is_none() {
7309            return Err(format!(
7310                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
7311                base.table, base.variable
7312            ));
7313        }
7314        let base_key = window_base_key(base);
7315        let wanted: std::collections::HashSet<&String> = units.iter().collect();
7316        let live = self.load_window_units(&base_key);
7317        self.begin_observation("syncular_window")?;
7318        let mut batch = ChangeAccumulator::default();
7319        let mut changed = false;
7320
7321        let prior_subs = self.subs.clone();
7322        let widened = (|| {
7323            // Widen: units wanted but not live → fresh subscription + registry row.
7324            for unit in units {
7325                if live.iter().any(|(u, _)| u == unit) {
7326                    continue;
7327                }
7328                let sub_id = derive_sub_id(base, unit);
7329                self.delete_pending_evict(&sub_id)?;
7330                self.insert_window_unit(&base_key, unit, &sub_id)?;
7331                self.subscribe(
7332                    sub_id,
7333                    base.table.clone(),
7334                    unit_scopes(base, unit),
7335                    base.params.clone(),
7336                )?;
7337                batch.window(&base_key, &base.table, unit);
7338                changed = true;
7339            }
7340
7341            self.finish_observation("syncular_window", batch)
7342        })();
7343        if let Err(error) = widened {
7344            self.rollback_observation("syncular_window");
7345            self.subs = prior_subs;
7346            return Err(error);
7347        }
7348        for (unit, sub_id) in live {
7349            if wanted.contains(&unit) {
7350                continue;
7351            }
7352            self.rebuild_overlay_if_dirty();
7353            let prior_subs = self.subs.clone();
7354            self.begin_observation("syncular_window")?;
7355            let effective = self
7356                .subs
7357                .iter()
7358                .find(|sub| sub.id == sub_id)
7359                .and_then(|sub| sub.effective.clone())
7360                .unwrap_or_else(|| unit_scopes(base, &unit));
7361            let mut batch = ChangeAccumulator::default();
7362            self.record_scope_map(&mut batch, &base.table, &effective);
7363            batch.window(&base_key, &base.table, &unit);
7364            let result = self.evict_unit(&base_key, base, &unit, &sub_id);
7365            let remaining = match result {
7366                Ok(remaining) => remaining,
7367                Err(error) => {
7368                    self.rollback_observation("syncular_window");
7369                    self.subs = prior_subs;
7370                    return Err(error);
7371                }
7372            };
7373            if let Err(error) = self.finish_observation("syncular_window", batch) {
7374                self.rollback_observation("syncular_window");
7375                self.subs = prior_subs;
7376                return Err(error);
7377            }
7378            if remaining {
7379                self.drain_pending_evictions()?;
7380            }
7381            changed = true;
7382        }
7383        Ok(if changed {
7384            CommandEffects::interactive()
7385        } else {
7386            CommandEffects::none()
7387        })
7388    }
7389
7390    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
7391    /// the subset still bootstrap-pending. Registration alone is not
7392    /// completeness — a unit is pending until its subscription completes a
7393    /// bootstrap round (cursor advances past -1 with no resume token held).
7394    pub fn window_state(&self, base: &WindowBase) -> WindowState {
7395        let mut units = Vec::new();
7396        let mut pending = Vec::new();
7397        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
7398            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
7399                Some(sub) => {
7400                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
7401                }
7402                None => true,
7403            };
7404            if is_pending {
7405                pending.push(unit.clone());
7406            }
7407            units.push(unit);
7408        }
7409        WindowState { units, pending }
7410    }
7411
7412    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
7413        let mut stmt = match self
7414            .conn
7415            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
7416        {
7417            Ok(stmt) => stmt,
7418            Err(_) => return Vec::new(),
7419        };
7420        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
7421            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7422        });
7423        match rows {
7424            Ok(rows) => rows.filter_map(Result::ok).collect(),
7425            Err(_) => Vec::new(),
7426        }
7427    }
7428
7429    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
7430        let mut stmt = match self.conn.prepare(
7431            "SELECT windows.base, windows.unit, subscriptions.tbl
7432               FROM _syncular_windows AS windows
7433               JOIN _syncular_subscriptions AS subscriptions
7434                 ON subscriptions.id = windows.sub_id
7435               ORDER BY windows.base, windows.unit",
7436        ) {
7437            Ok(stmt) => stmt,
7438            Err(_) => return Vec::new(),
7439        };
7440        let rows = stmt.query_map([], |row| {
7441            Ok((
7442                row.get::<_, String>(0)?,
7443                row.get::<_, String>(1)?,
7444                row.get::<_, String>(2)?,
7445            ))
7446        });
7447        match rows {
7448            Ok(rows) => rows.filter_map(Result::ok).collect(),
7449            Err(_) => Vec::new(),
7450        }
7451    }
7452
7453    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
7454        self.conn
7455            .query_row(
7456                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
7457                rusqlite::params![sub_id],
7458                |row| Ok((row.get(0)?, row.get(1)?)),
7459            )
7460            .ok()
7461    }
7462
7463    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
7464        self.conn
7465            .execute(
7466                "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
7467                rusqlite::params![base_key, unit, sub_id],
7468            )
7469            .map(|_| ())
7470            .map_err(|error| error.to_string())
7471    }
7472
7473    fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
7474        self.conn
7475            .execute(
7476                "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
7477                rusqlite::params![base_key, unit],
7478            )
7479            .map(|_| ())
7480            .map_err(|error| error.to_string())
7481    }
7482
7483    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
7484    /// Deletes the unit's rows except those pinned by a pending outbox
7485    /// commit (E1); records a deferred eviction if any pin remains; discards
7486    /// the subscription's cursor/resume/effective-echo (E3) and version
7487    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
7488    /// nothing.
7489    fn evict_unit(
7490        &mut self,
7491        base_key: &str,
7492        base: &WindowBase,
7493        unit: &str,
7494        sub_id: &str,
7495    ) -> Result<bool, String> {
7496        let effective = self
7497            .subs
7498            .iter()
7499            .find(|s| s.id == sub_id)
7500            .and_then(|s| s.effective.clone())
7501            .unwrap_or_else(|| unit_scopes(base, unit));
7502        let pinned = self.pinned_row_ids(&base.table);
7503        let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
7504        self.delete_window_unit(base_key, unit)?;
7505        self.conn
7506            .execute(
7507                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7508                [sub_id],
7509            )
7510            .map_err(|error| error.to_string())?;
7511        self.subs.retain(|sub| sub.id != sub_id);
7512        if deferred {
7513            self.save_pending_evict(sub_id, &base.table, &effective)?;
7514        } else {
7515            self.delete_pending_evict(sub_id)?;
7516        }
7517        Ok(remaining)
7518    }
7519
7520    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
7521    /// primary keys. Returns (more unpinned work, any scoped rows remain).
7522    /// A missing scope-column mapping fails without deleting rows.
7523    fn evict_scope_rows(
7524        &self,
7525        table_name: &str,
7526        effective: &[(String, Vec<String>)],
7527        pinned: &std::collections::HashSet<String>,
7528    ) -> Result<(bool, bool), String> {
7529        if effective.is_empty() {
7530            return Ok((false, false));
7531        }
7532        let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
7533        let mut clauses = Vec::new();
7534        let mut params: Vec<SqlValue> = Vec::new();
7535        for (variable, values) in effective {
7536            let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
7537            if values.is_empty() {
7538                return Ok((false, false));
7539            }
7540            let holes = values
7541                .iter()
7542                .map(|v| {
7543                    params.push(SqlValue::Text(v.clone()));
7544                    "?"
7545                })
7546                .collect::<Vec<_>>()
7547                .join(", ");
7548            clauses.push(format!("{} IN ({holes})", quote_ident(column)));
7549        }
7550        let scope = clauses.join(" AND ");
7551        let scope_params = params.len();
7552        let pk = quote_ident(&table.primary_key);
7553        if !pinned.is_empty() {
7554            let holes = pinned
7555                .iter()
7556                .map(|id| {
7557                    params.push(SqlValue::Text(id.clone()));
7558                    "?"
7559                })
7560                .collect::<Vec<_>>()
7561                .join(", ");
7562            clauses.push(format!("{pk} NOT IN ({holes})"));
7563        }
7564        let predicate = clauses.join(" AND ");
7565        let base = base_table(table_name);
7566        let visible = visible_table(table_name);
7567        let mut ids: Vec<SqlValue> = Vec::new();
7568        for source in [&base, &visible] {
7569            let mut stmt = self
7570                .conn
7571                .prepare_cached(&format!(
7572                    "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
7573                ))
7574                .map_err(|e| e.to_string())?;
7575            ids = stmt
7576                .query_map(rusqlite::params_from_iter(&params), |row| row.get(0))
7577                .map_err(|e| e.to_string())?
7578                .collect::<Result<_, _>>()
7579                .map_err(|e| e.to_string())?;
7580            if !ids.is_empty() {
7581                break;
7582            }
7583        }
7584        if !ids.is_empty() {
7585            let holes = vec!["?"; ids.len()].join(", ");
7586            for source in [&base, &visible] {
7587                self.conn
7588                    .execute(
7589                        &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
7590                        rusqlite::params_from_iter(&ids),
7591                    )
7592                    .map_err(|e| e.to_string())?;
7593            }
7594        }
7595        let mut remaining = false;
7596        let mut deferred = false;
7597        for source in [&base, &visible] {
7598            remaining |= self
7599                .conn
7600                .query_row(
7601                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
7602                    rusqlite::params_from_iter(&params),
7603                    |row| row.get::<_, bool>(0),
7604                )
7605                .map_err(|e| e.to_string())?;
7606            deferred |= self
7607                .conn
7608                .query_row(
7609                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
7610                    rusqlite::params_from_iter(&params[..scope_params]),
7611                    |row| row.get::<_, bool>(0),
7612                )
7613                .map_err(|e| e.to_string())?;
7614        }
7615        Ok((remaining, deferred))
7616    }
7617
7618    /// Resume committed eviction chunks, retaining outbox pins and their durable marker.
7619    fn drain_pending_evictions(&mut self) -> Result<(), String> {
7620        let pending = self.load_pending_evictions()?;
7621        if pending.is_empty() {
7622            return Ok(());
7623        }
7624        self.rebuild_overlay_if_dirty();
7625        for (sub_id, table_name, effective) in pending {
7626            if self.schema.table(&table_name).is_none() {
7627                self.delete_pending_evict(&sub_id)?;
7628                continue;
7629            }
7630            loop {
7631                std::thread::yield_now();
7632                self.begin_observation("syncular_evict")?;
7633                let pinned = self.pinned_row_ids(&table_name);
7634                let result = self.evict_scope_rows(&table_name, &effective, &pinned);
7635                let (remaining, deferred) = match result {
7636                    Ok(result) => result,
7637                    Err(error) => {
7638                        self.rollback_observation("syncular_evict");
7639                        return Err(error);
7640                    }
7641                };
7642                if !deferred {
7643                    if let Err(error) = self.delete_pending_evict(&sub_id) {
7644                        self.rollback_observation("syncular_evict");
7645                        return Err(error);
7646                    }
7647                }
7648                let mut batch = ChangeAccumulator::default();
7649                self.record_scope_map(&mut batch, &table_name, &effective);
7650                if let Err(error) = self.finish_observation("syncular_evict", batch) {
7651                    self.rollback_observation("syncular_evict");
7652                    return Err(error);
7653                }
7654                if !remaining {
7655                    break;
7656                }
7657            }
7658        }
7659        Ok(())
7660    }
7661
7662    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
7663    /// commit — rows that MUST NOT be evicted until the commit drains.
7664    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
7665        let mut pinned = std::collections::HashSet::new();
7666        for commit in &self.outbox {
7667            for op in &commit.ops {
7668                if op.table == table {
7669                    pinned.insert(op.row_id.clone());
7670                }
7671            }
7672        }
7673        pinned
7674    }
7675
7676    fn save_pending_evict(
7677        &self,
7678        sub_id: &str,
7679        table: &str,
7680        effective: &[(String, Vec<String>)],
7681    ) -> Result<(), String> {
7682        self.conn.execute(
7683            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
7684            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
7685        ).map(|_| ()).map_err(|error| error.to_string())
7686    }
7687
7688    fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
7689        self.conn
7690            .execute(
7691                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
7692                rusqlite::params![sub_id],
7693            )
7694            .map(|_| ())
7695            .map_err(|error| error.to_string())
7696    }
7697
7698    fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
7699        let mut stmt = self
7700            .conn
7701            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
7702            .map_err(|error| error.to_string())?;
7703        let rows = stmt
7704            .query_map([], |row| {
7705                Ok((
7706                    row.get::<_, String>(0)?,
7707                    row.get::<_, String>(1)?,
7708                    row.get::<_, String>(2)?,
7709                ))
7710            })
7711            .map_err(|error| error.to_string())?;
7712        rows.map(|row| {
7713            let (sub_id, table, json) = row.map_err(|error| error.to_string())?;
7714            let value: Value = serde_json::from_str(&json)
7715                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
7716            let effective = json_to_scope_map(&value)
7717                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
7718            Ok((sub_id, table, effective))
7719        })
7720        .collect()
7721    }
7722
7723    /// Record one atomic local commit (§7.1) and apply it optimistically.
7724    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
7725        if mutations.is_empty() {
7726            return Err("a commit must contain at least one operation (§6.1)".to_owned());
7727        }
7728        let mut ops = Vec::with_capacity(mutations.len());
7729        for mutation in mutations {
7730            match mutation {
7731                Mutation::Upsert {
7732                    table,
7733                    values,
7734                    base_version,
7735                } => {
7736                    let schema_table = self
7737                        .schema
7738                        .table(&table)
7739                        .ok_or_else(|| format!("unknown table {table:?}"))?;
7740                    // §5: value keys are accepted in snake_case AND the
7741                    // generated row types' camelCase; normalize to SQL truth
7742                    // before the pk lookup / codec see them.
7743                    let values = normalize_values_casing(schema_table, values)?;
7744                    let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
7745                    // §6.7 `mutate` marks every column present: fill missing
7746                    // nullable columns with NULL before the payload is built.
7747                    let values = full_row_values(schema_table, values)?;
7748                    // §5.11: validate the payload encodes with the current
7749                    // codec. A key-selection or unknown-key failure is a
7750                    // durable rejection at the push seam (§10.3), never an
7751                    // author-time error. Full-row `mutate` presents every
7752                    // column, so no stored fallback is needed.
7753                    self.validate_author_encode(schema_table, &row_id, &values)?;
7754                    ops.push(OutboxOp {
7755                        upsert: true,
7756                        table,
7757                        row_id,
7758                        base_version,
7759                        values: Some(values),
7760                    });
7761                }
7762                Mutation::Delete {
7763                    table,
7764                    row_id,
7765                    base_version,
7766                } => {
7767                    if self.schema.table(&table).is_none() {
7768                        return Err(format!("unknown table {table:?}"));
7769                    }
7770                    ops.push(OutboxOp {
7771                        upsert: false,
7772                        table,
7773                        row_id,
7774                        base_version,
7775                        values: None,
7776                    });
7777                }
7778            }
7779        }
7780        self.record_outbox_commit(ops)
7781    }
7782
7783    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
7784        let commit = OutboxCommit {
7785            client_commit_id: uuid::Uuid::new_v4().to_string(),
7786            ops,
7787        };
7788        self.begin_observation("syncular_mutation")?;
7789        let mut batch = ChangeAccumulator::default();
7790        for op in &commit.ops {
7791            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
7792            if let Some(values) = &op.values {
7793                if let Some(table) = self.schema.table(&op.table) {
7794                    for scope in &table.scope_variables {
7795                        if let Some(Value::String(value)) = values.get(&scope.column) {
7796                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
7797                            precise = true;
7798                        }
7799                    }
7800                }
7801            }
7802            if !precise {
7803                batch.table(&op.table);
7804            }
7805        }
7806        if let Err(error) = self.persist_outbox_insert(&commit) {
7807            self.rollback_observation("syncular_mutation");
7808            return Err(error);
7809        }
7810        let was_dirty = self.overlay_dirty.get();
7811        // A clean overlay contains the FIFO fold of every previous commit, so
7812        // the new commit applies over it. §7.1: an operation that does not
7813        // apply (a secondary unique collision) fails the whole commit; the
7814        // savepoint rollback removes the outbox entry and every visible write.
7815        if was_dirty {
7816            self.rebuild_overlay();
7817        }
7818        if let Some(error) = commit
7819            .ops
7820            .iter()
7821            .find_map(|op| self.apply_outbox_op(op).err())
7822        {
7823            self.rollback_observation("syncular_mutation");
7824            self.overlay_dirty.set(was_dirty);
7825            return Err(error);
7826        }
7827        let id = commit.client_commit_id.clone();
7828        self.outbox.push(commit);
7829        batch.status = true;
7830        if let Err(error) = self.finish_observation("syncular_mutation", batch) {
7831            self.rollback_observation("syncular_mutation");
7832            self.outbox.pop();
7833            self.overlay_dirty.set(was_dirty);
7834            return Err(error);
7835        }
7836        Ok(id)
7837    }
7838
7839    /// Record one §6.1 sparse upsert: the primary key plus the supplied
7840    /// non-scope columns are present; every other column is absent and stays
7841    /// untouched on the server and in the local overlay. The row need not be
7842    /// locally present — an absent local row leaves the overlay absent (§7.1)
7843    /// and the server answers per §5.2.
7844    pub fn patch(
7845        &mut self,
7846        table: &str,
7847        row_id: &str,
7848        partial: Map<String, Value>,
7849        base_version: Option<i64>,
7850    ) -> Result<String, String> {
7851        let schema_table = self
7852            .schema
7853            .table(table)
7854            .ok_or_else(|| format!("unknown table {table:?}"))?;
7855        let partial = normalize_values_casing(schema_table, partial)?;
7856        if let Some(pk) = partial.get(&schema_table.primary_key) {
7857            if pk.as_str() != Some(row_id) {
7858                return Err(format!(
7859                    "sync.invalid_request: table {table:?}: patch cannot change the primary key"
7860                ));
7861            }
7862        }
7863        // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
7864        // server accepts a present scope column whose value equals the
7865        // stored row and applies it as a no-op, so a decoded envelope
7866        // round-trips. The local row is the only value the client can prove
7867        // equality against; when the row is absent locally the client cannot
7868        // prove equality against the server's stored row and fails closed.
7869        // A primary key that is also a scope column needs no stored-row
7870        // read: the primary key in a sparse payload is by construction the
7871        // row id being patched (§6.1), so its value is proven equal already.
7872        for scope in &schema_table.scope_variables {
7873            if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
7874                continue;
7875            }
7876            let Some(column) = schema_table
7877                .columns
7878                .iter()
7879                .find(|column| column.name == scope.column)
7880            else {
7881                continue;
7882            };
7883            let stored = self
7884                .conn
7885                .query_row(
7886                    &format!(
7887                        "SELECT {} FROM {} WHERE {}",
7888                        quote_ident(&scope.column),
7889                        visible_table(&schema_table.name),
7890                        row_id_predicate(schema_table)
7891                    ),
7892                    rusqlite::params![row_id],
7893                    |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
7894                )
7895                .ok();
7896            // Decode the supplied value through the same column-type path the
7897            // rest of the patch encoding uses, so a value that only equals
7898            // the stored one in another number representation still compares
7899            // equal.
7900            let incoming = json_to_column_value(column, partial.get(&scope.column))
7901                .ok()
7902                .map(|value| column_value_to_json(&value));
7903            // A stored blob never equals a patch value, matching the server's
7904            // scope-column comparison (§3.4): bytes are unstorable as scope
7905            // values.
7906            let equal = stored.as_ref().is_some_and(|stored| {
7907                stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
7908            });
7909            if !equal {
7910                return Err(format!(
7911                    "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
7912                    scope.column
7913                ));
7914            }
7915        }
7916        let mut values = partial;
7917        // Presence-set semantics: a proven-equal scope column is dropped, so
7918        // the patch leaves the stored value untouched (§6.2).
7919        for scope in &schema_table.scope_variables {
7920            values.remove(&scope.column);
7921        }
7922        values.insert(
7923            schema_table.primary_key.clone(),
7924            Value::from(row_id.to_owned()),
7925        );
7926        // §5.11: structural validation only; an absent key-id selector is
7927        // resolved at the push seam from the stored local row, and a
7928        // key-selection or unknown-key failure becomes a durable
7929        // `client.encrypt_failed` rejection there (§10.3), never an
7930        // author-time error.
7931        self.validate_author_encode(schema_table, row_id, &values)?;
7932        self.record_outbox_commit(vec![OutboxOp {
7933            upsert: true,
7934            table: table.to_owned(),
7935            row_id: row_id.to_owned(),
7936            base_version,
7937            values: Some(values),
7938        }])
7939    }
7940
7941    /// §5.11 key-id fallback: the stored local row's selector value as a
7942    /// positional row (only the selector slot filled). Returns None when no
7943    /// selector is configured, the row is locally absent, or the stored
7944    /// value is not a usable key id.
7945    fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
7946        let selector = self.encryption.key_id_columns.get(&table.name)?;
7947        let index = table
7948            .columns
7949            .iter()
7950            .position(|column| &column.name == selector)?;
7951        let sql = format!(
7952            "SELECT {} FROM {} WHERE {}",
7953            quote_ident(selector),
7954            visible_table(&table.name),
7955            row_id_predicate(table)
7956        );
7957        let key: Option<String> = self
7958            .conn
7959            .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
7960            .ok()?;
7961        let key = key.filter(|key| !key.is_empty())?;
7962        let mut fallback: Row = vec![None; table.columns.len()];
7963        fallback[index] = Some(ColumnValue::String(key));
7964        Some(fallback)
7965    }
7966
7967    /// §5.11/§10.3 author-time encode check: a structural failure (a
7968    /// non-nullable NULL, a value the codec rejects) is an author-time
7969    /// error; a key-selection or unknown-key failure is deferred to the push
7970    /// seam, where it becomes a durable `client.encrypt_failed` rejection.
7971    fn validate_author_encode(
7972        &self,
7973        table: &TableSchema,
7974        row_id: &str,
7975        values: &Map<String, Value>,
7976    ) -> Result<(), String> {
7977        match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
7978            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
7979            result => result.map(|_| ()),
7980        }
7981    }
7982
7983    /// §5.11/§10.3: drop every pending commit whose push encode fails with
7984    /// `client.encrypt_failed` (an unresolvable key id or an unknown key) and
7985    /// raise one durable rejection per commit. `sync()` never aborts for an
7986    /// encode failure. Mirrors §7.4.4's `drop_incompatible_outbox`, including
7987    /// undoing the dropped commit's purely-optimistic rows.
7988    fn drop_unencodable_outbox(&mut self) -> Result<bool, String> {
7989        if !self
7990            .schema
7991            .tables
7992            .iter()
7993            .any(|table| table.has_encrypted_columns())
7994        {
7995            return Ok(false);
7996        }
7997        let failures = self
7998            .outbox
7999            .iter()
8000            .filter_map(|commit| {
8001                commit
8002                    .ops
8003                    .iter()
8004                    .find_map(|op| {
8005                        let values = op.values.as_ref().filter(|_| op.upsert)?;
8006                        let table = self.schema.table(&op.table)?;
8007                        let fallback = self.stored_key_fallback(table, &op.row_id);
8008                        match encode_sparse_row_json(
8009                            table,
8010                            &op.row_id,
8011                            values,
8012                            &self.encryption,
8013                            fallback.as_ref(),
8014                        ) {
8015                            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Some(error),
8016                            _ => None,
8017                        }
8018                    })
8019                    .map(|error| (commit.clone(), error))
8020            })
8021            .collect::<Vec<_>>();
8022        if failures.is_empty() {
8023            return Ok(false);
8024        }
8025        for (commit, message) in failures {
8026            let rejection = RejectionRecord {
8027                client_commit_id: commit.client_commit_id.clone(),
8028                op_index: 0,
8029                code: ENCRYPT_FAILED_CODE.to_owned(),
8030                message,
8031                retryable: false,
8032                details: None,
8033                operation: commit.ops.first().map(CommitOperation::from),
8034            };
8035            self.persist_commit_outcome(
8036                &commit.client_commit_id,
8037                CommitOutcomeStatus::Rejected,
8038                &[CommitOperationOutcome::Error {
8039                    rejection: rejection.clone(),
8040                }],
8041                Some(&commit.ops),
8042            )?;
8043            self.delete_outbox_persisted(&commit.client_commit_id)?;
8044            self.outbox
8045                .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
8046            self.rejections.push(rejection);
8047        }
8048        self.prune_commit_outcomes()?;
8049        self.overlay_dirty.set(true);
8050        self.rebuild_overlay();
8051        Ok(true)
8052    }
8053
8054    pub fn pending_commit_ids(&self) -> Vec<String> {
8055        self.outbox
8056            .iter()
8057            .map(|c| c.client_commit_id.clone())
8058            .collect()
8059    }
8060
8061    /// §6.1: the sparse push payload bytes of every pending upsert operation,
8062    /// FIFO — exactly what the next push round would put on the wire. Used by
8063    /// conformance B.21(h) to pin cross-core byte identity.
8064    pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
8065        let mut payloads = Vec::new();
8066        for commit in &self.outbox {
8067            for op in &commit.ops {
8068                let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
8069                    continue;
8070                };
8071                let Some(table) = self.schema.table(&op.table) else {
8072                    continue;
8073                };
8074                let fallback = self.stored_key_fallback(table, &op.row_id);
8075                if let Ok(payload) = encode_sparse_row_json(
8076                    table,
8077                    &op.row_id,
8078                    values,
8079                    &self.encryption,
8080                    fallback.as_ref(),
8081                ) {
8082                    payloads.push(payload);
8083                }
8084            }
8085        }
8086        payloads
8087    }
8088
8089    pub fn conflicts(&self) -> &[ConflictRecord] {
8090        &self.conflicts
8091    }
8092
8093    pub fn rejections(&self) -> &[RejectionRecord] {
8094        &self.rejections
8095    }
8096
8097    pub fn resolve_commit_outcome(
8098        &mut self,
8099        input: ResolveCommitOutcomeInput,
8100    ) -> Result<CommitOutcome, String> {
8101        let current = self
8102            .commit_outcome(&input.client_commit_id)?
8103            .ok_or_else(|| {
8104                format!(
8105                    "sync.outcome_not_found: no durable outcome exists for {:?}",
8106                    input.client_commit_id
8107                )
8108            })?;
8109        if current.resolution != CommitOutcomeResolution::Active {
8110            return Ok(current);
8111        }
8112        if input.resolution == CommitOutcomeResolution::Active {
8113            return Err("sync.invalid_request: resolution must leave active state".to_owned());
8114        }
8115        match input.resolution {
8116            CommitOutcomeResolution::Superseded => {
8117                let replacement = input
8118                    .replacement_client_commit_id
8119                    .as_deref()
8120                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
8121                    .ok_or_else(|| {
8122                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
8123                            .to_owned()
8124                    })?;
8125                let _ = replacement;
8126            }
8127            _ if input.replacement_client_commit_id.is_some() => {
8128                return Err(
8129                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
8130                        .to_owned(),
8131                );
8132            }
8133            _ => {}
8134        }
8135        let allowed = match current.status {
8136            CommitOutcomeStatus::Conflict => matches!(
8137                input.resolution,
8138                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
8139            ),
8140            CommitOutcomeStatus::Rejected => {
8141                input.resolution == CommitOutcomeResolution::Superseded
8142            }
8143            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
8144                input.resolution == CommitOutcomeResolution::Dismissed
8145            }
8146        };
8147        if !allowed {
8148            return Err(format!(
8149                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
8150                input.resolution, current.status
8151            ));
8152        }
8153
8154        self.begin_observation("syncular_outcome_resolution")?;
8155        let result = (|| {
8156            self.conn
8157                .execute(
8158                    "UPDATE _syncular_commit_outcomes
8159                        SET resolution = ?1, resolved_at_ms = ?2,
8160                            replacement_client_commit_id = ?3
8161                      WHERE client_commit_id = ?4 AND resolution = 'active'",
8162                    rusqlite::params![
8163                        Self::outcome_resolution_name(input.resolution),
8164                        self.clock_now_ms(),
8165                        input.replacement_client_commit_id,
8166                        input.client_commit_id
8167                    ],
8168                )
8169                .map_err(|error| error.to_string())?;
8170            let resolved = self
8171                .commit_outcome(&current.client_commit_id)?
8172                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
8173            self.prune_commit_outcomes()?;
8174            Ok(resolved)
8175        })();
8176        let resolved = match result {
8177            Ok(outcome) => outcome,
8178            Err(error) => {
8179                self.rollback_observation("syncular_outcome_resolution");
8180                return Err(error);
8181            }
8182        };
8183        self.conflicts
8184            .retain(|record| record.client_commit_id != current.client_commit_id);
8185        self.rejections
8186            .retain(|record| record.client_commit_id != current.client_commit_id);
8187        let batch = ChangeAccumulator {
8188            conflicts: current.status == CommitOutcomeStatus::Conflict,
8189            rejections: current.status == CommitOutcomeStatus::Rejected,
8190            outcomes: true,
8191            ..ChangeAccumulator::default()
8192        };
8193        if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
8194            self.rollback_observation("syncular_outcome_resolution");
8195            return Err(error);
8196        }
8197        Ok(resolved)
8198    }
8199
8200    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
8201        self.schema_floor.as_ref()
8202    }
8203
8204    /// §7.3.5: the client's opaque auth-lease state, if any.
8205    pub fn lease_state(&self) -> Option<&LeaseState> {
8206        self.lease_state.as_ref()
8207    }
8208
8209    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
8210    /// the two lease codes set it; other errors leave leaseState untouched.
8211    fn record_lease_error(&mut self, code: &str) {
8212        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
8213            return;
8214        }
8215        let mut next = self.lease_state.clone().unwrap_or_default();
8216        next.error_code = Some(code.to_owned());
8217        self.set_lease_state(Some(next));
8218    }
8219
8220    fn set_lease_state(&mut self, next: Option<LeaseState>) {
8221        if self.lease_state == next {
8222            return;
8223        }
8224        if self.begin_observation("syncular_lease").is_err() {
8225            return;
8226        }
8227        self.lease_state = next;
8228        if let Some(lease) = &self.lease_state {
8229            if let Ok(json) = serde_json::to_string(lease) {
8230                self.set_meta(LEASE_STATE_KEY, &json);
8231            }
8232        } else {
8233            self.delete_meta(LEASE_STATE_KEY);
8234        }
8235        let batch = ChangeAccumulator {
8236            status: true,
8237            ..ChangeAccumulator::default()
8238        };
8239        if self.finish_observation("syncular_lease", batch).is_err() {
8240            self.rollback_observation("syncular_lease");
8241        }
8242    }
8243
8244    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
8245        if self.schema_floor == next {
8246            return;
8247        }
8248        if self.begin_observation("syncular_schema_floor").is_err() {
8249            return;
8250        }
8251        self.schema_floor = next;
8252        self.stopped = self.schema_floor.is_some();
8253        if let Some(floor) = &self.schema_floor {
8254            if let Ok(json) = serde_json::to_string(floor) {
8255                self.set_meta(SCHEMA_FLOOR_KEY, &json);
8256            }
8257        } else {
8258            self.delete_meta(SCHEMA_FLOOR_KEY);
8259        }
8260        let batch = ChangeAccumulator {
8261            status: true,
8262            ..ChangeAccumulator::default()
8263        };
8264        if self
8265            .finish_observation("syncular_schema_floor", batch)
8266            .is_err()
8267        {
8268            self.rollback_observation("syncular_schema_floor");
8269        }
8270    }
8271
8272    fn set_upgrading(&mut self, value: bool) {
8273        if self.upgrading == value {
8274            return;
8275        }
8276        if self.begin_observation("syncular_upgrading").is_err() {
8277            return;
8278        }
8279        self.upgrading = value;
8280        let batch = ChangeAccumulator {
8281            status: true,
8282            ..ChangeAccumulator::default()
8283        };
8284        if self
8285            .finish_observation("syncular_upgrading", batch)
8286            .is_err()
8287        {
8288            self.rollback_observation("syncular_upgrading");
8289        }
8290    }
8291
8292    pub fn sync_needed(&self) -> bool {
8293        self.sync_needed
8294    }
8295
8296    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
8297        let sub = self.subs.iter().find(|s| s.id == id)?;
8298        Some(SubscriptionStateView {
8299            id: sub.id.clone(),
8300            table: sub.table.clone(),
8301            status: sub.state.name().to_owned(),
8302            cursor: sub.cursor,
8303            has_resume_token: sub.bootstrap_state.is_some(),
8304            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
8305            reason_code: sub.reason_code.clone(),
8306        })
8307    }
8308
8309    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
8310        let schema_table = self
8311            .schema
8312            .table(table)
8313            .ok_or_else(|| format!("unknown table {table:?}"))?;
8314        let sql = format!(
8315            "SELECT * FROM {} ORDER BY {} ASC",
8316            visible_table(table),
8317            quote_ident(&schema_table.primary_key)
8318        );
8319        let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?;
8320        let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
8321        let mut out = Vec::new();
8322        while let Some(row) = rows.next().map_err(|e| e.to_string())? {
8323            let mut values = Map::new();
8324            for (i, column) in schema_table.columns.iter().enumerate() {
8325                let value = row.get_ref(i).map_err(|e| e.to_string())?;
8326                values.insert(column.name.clone(), sql_ref_to_json(column, value));
8327            }
8328            let version: i64 = row
8329                .get(schema_table.columns.len())
8330                .map_err(|e| e.to_string())?;
8331            let row_id = match values.get(&schema_table.primary_key) {
8332                Some(Value::String(s)) => s.clone(),
8333                Some(Value::Number(n)) => n.to_string(),
8334                Some(Value::Bool(b)) => b.to_string(),
8335                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
8336            };
8337            out.push(RowState {
8338                row_id,
8339                version,
8340                values,
8341            });
8342        }
8343        Ok(out)
8344    }
8345
8346    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
8347    //
8348    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
8349    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
8350    // the op with `yrs`, re-encodes the whole doc state, and records a
8351    // baseVersion-less sparse upsert carrying only the primary key and the
8352    // crdt column (§5.10.3, §6.6). No local merge — merging is server-side;
8353    // the overlay re-materializes the edit immediately (optimistic apply,
8354    // §7.1) and the server-merged bytes arrive on the next pull, idempotently.
8355    // Byte-compatible with `@syncular/crdt-yjs`.
8356
8357    /// The current stored value of a `crdt` column for one row — the visible
8358    /// (optimistic) bytes, or `None` when the row is absent or the column is
8359    /// NULL (the empty document, §5.10.1). Errors if the column is not a
8360    /// `crdt` column (guards the app against a typo'd column name).
8361    #[cfg(feature = "crdt-yjs")]
8362    fn crdt_column_bytes(
8363        &self,
8364        table: &str,
8365        row_id: &str,
8366        column: &str,
8367    ) -> Result<Option<Vec<u8>>, String> {
8368        let schema_table = self
8369            .schema
8370            .table(table)
8371            .ok_or_else(|| format!("unknown table {table:?}"))?;
8372        let col = schema_table
8373            .columns
8374            .iter()
8375            .find(|c| c.name == column)
8376            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
8377        if col.ty != ColumnType::Crdt {
8378            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
8379        }
8380        let sql = format!(
8381            "SELECT {} FROM {} WHERE {}",
8382            quote_ident(column),
8383            visible_table(table),
8384            row_id_predicate(schema_table)
8385        );
8386        let bytes: Option<Vec<u8>> = self
8387            .conn
8388            .prepare_cached(&sql)
8389            .map_err(|error| error.to_string())?
8390            .query_row(rusqlite::params![row_id], |row| {
8391                row.get::<_, Option<Vec<u8>>>(0)
8392            })
8393            .map_err(|e| match e {
8394                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
8395                other => other.to_string(),
8396            })?;
8397        Ok(bytes)
8398    }
8399
8400    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
8401    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
8402    /// An absent row / NULL column is the empty document (empty string).
8403    #[cfg(feature = "crdt-yjs")]
8404    pub fn crdt_text(
8405        &self,
8406        table: &str,
8407        row_id: &str,
8408        column: &str,
8409        name: &str,
8410    ) -> Result<String, String> {
8411        let bytes = self
8412            .crdt_column_bytes(table, row_id, column)?
8413            .unwrap_or_default();
8414        crate::crdt::text(&bytes, name)
8415    }
8416
8417    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and
8418    /// record the resulting crdt-only sparse operation. Returns the enqueued
8419    /// `clientCommitId`.
8420    #[cfg(feature = "crdt-yjs")]
8421    pub fn crdt_insert_text(
8422        &mut self,
8423        table: &str,
8424        row_id: &str,
8425        column: &str,
8426        name: &str,
8427        index: u32,
8428        value: &str,
8429    ) -> Result<String, String> {
8430        let current = self
8431            .crdt_column_bytes(table, row_id, column)?
8432            .unwrap_or_default();
8433        let update = crate::crdt::insert_text(&current, name, index, value)?;
8434        self.crdt_push_update(table, row_id, column, &update)
8435    }
8436
8437    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and
8438    /// record the resulting crdt-only sparse operation. Returns the enqueued
8439    /// `clientCommitId`.
8440    #[cfg(feature = "crdt-yjs")]
8441    pub fn crdt_delete_text(
8442        &mut self,
8443        table: &str,
8444        row_id: &str,
8445        column: &str,
8446        name: &str,
8447        index: u32,
8448        len: u32,
8449    ) -> Result<String, String> {
8450        let current = self
8451            .crdt_column_bytes(table, row_id, column)?
8452            .unwrap_or_default();
8453        let update = crate::crdt::delete_text(&current, name, index, len)?;
8454        self.crdt_push_update(table, row_id, column, &update)
8455    }
8456
8457    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
8458    /// `crdt` column's current state and record the resulting crdt-only sparse
8459    /// operation. The app authored the update with its own `yrs` model.
8460    /// Returns the enqueued `clientCommitId`.
8461    #[cfg(feature = "crdt-yjs")]
8462    pub fn crdt_apply_update(
8463        &mut self,
8464        table: &str,
8465        row_id: &str,
8466        column: &str,
8467        update: &[u8],
8468    ) -> Result<String, String> {
8469        let current = self
8470            .crdt_column_bytes(table, row_id, column)?
8471            .unwrap_or_default();
8472        let next = crate::crdt::apply_update(&current, update)?;
8473        self.crdt_push_update(table, row_id, column, &next)
8474    }
8475
8476    /// Shared tail of the crdt edit methods: record a §6.1 sparse crdt-only
8477    /// upsert — the primary key plus the crdt column, no `baseVersion` — and
8478    /// enqueue it. The server merges the crdt column and leaves every other
8479    /// column untouched (§5.10.3), so a baseVersion-less crdt edit cannot
8480    /// clobber a concurrent edit to another column. A locally absent row
8481    /// records the same partial operation; the server answers per §6.2.
8482    #[cfg(feature = "crdt-yjs")]
8483    fn crdt_push_update(
8484        &mut self,
8485        table: &str,
8486        row_id: &str,
8487        column: &str,
8488        crdt_bytes: &[u8],
8489    ) -> Result<String, String> {
8490        let mut bytes_obj = Map::new();
8491        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
8492        let mut partial = Map::new();
8493        partial.insert(column.to_owned(), Value::Object(bytes_obj));
8494        self.patch(table, row_id, partial, None)
8495    }
8496
8497    /// Run an arbitrary read-only SQL query against the local database and
8498    /// return each row as a `column-name → JSON value` map. This is the seam
8499    /// the React `useSyncQuery` live-query API needs (it takes app-authored
8500    /// SQL over the visible tables/views, not a fixed table read like
8501    /// [`read_rows`]).
8502    ///
8503    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
8504    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
8505    /// same envelope the command surface uses everywhere else. Output BLOB
8506    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
8507    ///
8508    /// The result column typing is dynamic (SQLite's stored affinity), because
8509    /// arbitrary SQL can alias, join, and compute — there is no schema column
8510    /// to consult per output cell, unlike [`read_rows`].
8511    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
8512        query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
8513    }
8514
8515    /// Repository benchmark and conformance access, present only with `bench-internals`.
8516    #[cfg(feature = "bench-internals")]
8517    #[doc(hidden)]
8518    pub fn benchmark_connection(&mut self) -> &mut Connection {
8519        &mut self.conn
8520    }
8521
8522    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
8523    #[cfg(feature = "bench-internals")]
8524    #[doc(hidden)]
8525    pub fn benchmark_phases(
8526        &self,
8527        enabled: Option<bool>,
8528        reset: bool,
8529    ) -> Result<Option<Value>, String> {
8530        self.benchmark_phases.configure(enabled, reset)?;
8531        Ok(self.benchmark_phases.snapshot())
8532    }
8533
8534    /// Rows, coverage, and local revision from one SQLite read snapshot.
8535    /// §7.5 atomic snapshot read. An `owner` records a failure in
8536    /// `diagnostics_snapshot().query_failures` until its next successful read.
8537    pub fn query_snapshot(
8538        &mut self,
8539        sql: &str,
8540        params: &[QueryValue],
8541        coverage: &[WindowCoverage],
8542        owner: Option<QueryOwner<'_>>,
8543    ) -> Result<QuerySnapshot, String> {
8544        let result = snapshot_connection(&self.conn, sql, params, coverage);
8545        if let Some(owner) = owner {
8546            self.record_query_read(owner, result.as_ref().err());
8547        }
8548        result.map_err(|failure| failure.to_string())
8549    }
8550
8551    /// §7.6: record the outcome of one owned snapshot read, including reads a
8552    /// host ran on a separate connection. A success removes the owner's entry;
8553    /// an identical failure leaves it unchanged; a changed failure moves last.
8554    pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
8555        let position = self
8556            .query_failures
8557            .iter()
8558            .position(|entry| entry.id == owner.id);
8559        let Some(failure) = failure else {
8560            if let Some(index) = position {
8561                self.query_failures.remove(index);
8562            }
8563            return;
8564        };
8565        let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
8566            .into_iter()
8567            .map(str::to_owned)
8568            .collect();
8569        let code = failure.code.unwrap_or("client.query_failed").to_owned();
8570        if position.is_some_and(|index| {
8571            let entry = &self.query_failures[index];
8572            entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
8573        }) {
8574            return;
8575        }
8576        if let Some(index) = position {
8577            self.query_failures.remove(index);
8578        }
8579        self.query_failures.push(DiagnosticQueryFailure {
8580            id: owner.id.to_owned(),
8581            tables,
8582            code,
8583            sqlite_code: failure.sqlite_code,
8584            at_ms: self.clock_now_ms(),
8585        });
8586        if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
8587            self.query_failures.remove(0);
8588        }
8589    }
8590
8591    // -- request building ---------------------------------------------------------
8592
8593    fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
8594        #[cfg(feature = "bench-internals")]
8595        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
8596        let log_epoch = self.get_meta(LOG_EPOCH_KEY);
8597        let mut frames = vec![Frame::ReqHeader {
8598            client_id: self.client_id.clone(),
8599            schema_version: self.schema.version,
8600            log_epoch: log_epoch.clone(),
8601        }];
8602        let mut pushed_ids = Vec::new();
8603        let mut ops_in_request = 0usize;
8604        let mut deferred_commits = 0usize;
8605        #[cfg(feature = "bench-internals")]
8606        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
8607        for (index, commit) in self
8608            .outbox
8609            .iter()
8610            .take(if log_epoch.is_some() {
8611                self.outbox.len()
8612            } else {
8613                0
8614            })
8615            .enumerate()
8616        {
8617            // §6.1 splitBatch: stop at the operation cap — commits apply in
8618            // order, so everything from the first non-fitting commit on is
8619            // deferred to the next round. A single over-cap commit still goes
8620            // alone (commits are atomic and cannot be split).
8621            if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
8622                deferred_commits = self.outbox.len() - index;
8623                break;
8624            }
8625            ops_in_request += commit.ops.len();
8626            let operations = commit
8627                .ops
8628                .iter()
8629                .map(|op| {
8630                    let payload = op.values.as_ref().and_then(|values| {
8631                        let table = self.schema.table(&op.table)?;
8632                        // §0: outbox entries encode at send time with the
8633                        // current codec (validated at mutate()). §6.1: the
8634                        // values map's keys are the sparse presence set.
8635                        // §5.11: encrypted columns are encrypted here, with
8636                        // an absent key-id selector read from the stored row.
8637                        // `sync_inner` runs `drop_unencodable_outbox` first,
8638                        // so an encrypt failure cannot reach this encode; the
8639                        // author seam validated every other failure.
8640                        let fallback = self.stored_key_fallback(table, &op.row_id);
8641                        encode_sparse_row_json(
8642                            table,
8643                            &op.row_id,
8644                            values,
8645                            &self.encryption,
8646                            fallback.as_ref(),
8647                        )
8648                        .ok()
8649                    });
8650                    ssp2::model::Operation {
8651                        table: op.table.clone(),
8652                        row_id: op.row_id.clone(),
8653                        op: if op.upsert { Op::Upsert } else { Op::Delete },
8654                        base_version: op.base_version,
8655                        payload,
8656                    }
8657                })
8658                .collect();
8659            frames.push(Frame::PushCommit {
8660                client_commit_id: commit.client_commit_id.clone(),
8661                operations,
8662            });
8663            pushed_ids.push(commit.client_commit_id.clone());
8664        }
8665        #[cfg(feature = "bench-internals")]
8666        drop(outbox_phase);
8667        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
8668        // bare URL — capability negotiation, decided per transport.
8669        let accept = self.limits.accept.unwrap_or(if url_capable {
8670            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
8671        } else {
8672            DEFAULT_ACCEPT
8673        });
8674        frames.push(Frame::PullHeader {
8675            limit_commits: self.limits.limit_commits.unwrap_or(0),
8676            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
8677            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
8678            accept,
8679        });
8680        let mut fresh = Vec::new();
8681        for sub in &self.subs {
8682            if sub.state != SubState::Active {
8683                continue;
8684            }
8685            let mut scopes = sub.requested.clone();
8686            sort_scope_map(&mut scopes);
8687            frames.push(Frame::Subscription {
8688                id: sub.id.clone(),
8689                table: sub.table.clone(),
8690                scopes,
8691                params: sub.params.clone().map(RawJson),
8692                cursor: sub.cursor,
8693                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
8694            });
8695            fresh.push((
8696                sub.id.clone(),
8697                sub.cursor < 0 && sub.bootstrap_state.is_none(),
8698            ));
8699        }
8700        let message = Message {
8701            wire_version: WIRE_VERSION,
8702            msg_kind: MsgKind::Request,
8703            frames,
8704        };
8705        (
8706            message,
8707            RequestMeta {
8708                pushed_ids,
8709                fresh,
8710                accept,
8711                deferred_commits,
8712            },
8713        )
8714    }
8715
8716    // -- sync -------------------------------------------------------------------
8717
8718    /// Cloneable observation handle; no client lock is needed to read it.
8719    pub fn progress(&self) -> ProgressObserver {
8720        self.progress.clone()
8721    }
8722
8723    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
8724        self.progress.start();
8725        let started_at_ms = self.clock_now_ms();
8726        let outcome = self.sync_inner(transport);
8727        self.progress.update(|p| match &outcome {
8728            SyncOutcome::Ok(report) if report.failed.is_empty() => {
8729                p.state = ProgressState::Complete
8730            }
8731            SyncOutcome::Ok(_) => {
8732                p.state = ProgressState::Failed;
8733                p.error_code = Some("sync.scope_revoked".into());
8734            }
8735            SyncOutcome::Failed { error_code, .. } => {
8736                p.state = ProgressState::Failed;
8737                p.error_code = Some(Self::diagnostic_code(error_code));
8738            }
8739        });
8740        let completed_at_ms = self.clock_now_ms();
8741        self.last_round = Some(match &outcome {
8742            SyncOutcome::Ok(report) => DiagnosticLastRound {
8743                status: "succeeded".to_owned(),
8744                started_at_ms,
8745                completed_at_ms,
8746                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
8747                counters: Some(DiagnosticRoundCounters {
8748                    pushed: report.pushed,
8749                    applied: report.applied.len(),
8750                    rejected: report.rejected.len(),
8751                    retryable: report.retryable.len(),
8752                    conflicts: report.conflicts,
8753                    commits_applied: report.commits_applied,
8754                    segment_rows_applied: report.segment_rows_applied,
8755                    bootstrapping: report.bootstrapping.len(),
8756                    resets: report.resets.len(),
8757                    revoked: report.revoked.len(),
8758                    failed: report.failed.len(),
8759                    deferred_commits: report.deferred_commits,
8760                }),
8761                error_code: None,
8762            },
8763            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
8764                status: "failed".to_owned(),
8765                started_at_ms,
8766                completed_at_ms,
8767                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
8768                counters: None,
8769                error_code: Some(Self::diagnostic_code(error_code)),
8770            },
8771        });
8772        outcome
8773    }
8774
8775    fn sync_inner(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
8776        if self.stopped {
8777            // §1.6: the client stopped at the schema floor; syncing is inert
8778            // until an upgrade. The outbox is preserved for replay.
8779            return SyncOutcome::Ok(SyncReport {
8780                schema_floor: self.schema_floor.clone(),
8781                ..SyncReport::default()
8782            });
8783        }
8784        // §8.4: the coalesced sync-needed signal clears when a pull round
8785        // BEGINS, so a wake-up landing mid-round survives it.
8786        self.set_sync_needed(false, false);
8787        // §5.9.7 B4: upload pending blobs before pushing the referencing
8788        // rows, so the server-side existence check (§6.6) passes.
8789        if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
8790            if let Err(TransportError { code, message }) = self.flush_blob_uploads(transport) {
8791                if Self::retryable_transport_code(&code) {
8792                    self.schedule_background_retry();
8793                }
8794                return SyncOutcome::Failed {
8795                    error_code: code,
8796                    message,
8797                };
8798            }
8799        }
8800        if let Err(message) = self.drain_pending_evictions() {
8801            return SyncOutcome::Failed {
8802                error_code: "storage.failed".into(),
8803                message,
8804            };
8805        }
8806        // §5.11/§10.3: an unresolvable key id or an unknown key is a durable
8807        // per-commit rejection, never a reason to abort the round. Drop those
8808        // commits before the request is built.
8809        if let Err(message) = self.drop_unencodable_outbox() {
8810            return SyncOutcome::Failed {
8811                error_code: "storage.failed".into(),
8812                message,
8813            };
8814        }
8815        let (message, meta) = self.build_request(transport.supports_url_fetch());
8816        let request_bytes = {
8817            #[cfg(feature = "bench-internals")]
8818            let _phase = self.benchmark_phases.start(Phase::RequestEncode);
8819            encode_message(&message)
8820        };
8821        // §8.7: rounds ride the socket whenever it is connected (one
8822        // loop, no fallback pair); the transport seam stays bytes-in /
8823        // bytes-out either way. Registration-at-round-end is server-side.
8824        let round = if self.realtime_connected {
8825            transport.realtime_sync(&request_bytes)
8826        } else {
8827            transport.sync(&request_bytes)
8828        };
8829        let response_bytes = match round {
8830            Ok(bytes) => bytes,
8831            Err(TransportError { code, message }) => {
8832                // §7.3.5: a request-level lease code stops-and-surfaces —
8833                // record it in leaseState (no local-data purge, §7.3.4).
8834                self.record_lease_error(&code);
8835                if Self::retryable_transport_code(&code) {
8836                    self.schedule_background_retry();
8837                }
8838                return SyncOutcome::Failed {
8839                    error_code: code,
8840                    message,
8841                };
8842            }
8843        };
8844        #[cfg(feature = "bench-internals")]
8845        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
8846        let response = match decode_message(&response_bytes) {
8847            Ok(message) => message,
8848            Err(error) => {
8849                // §1.2 rule 1 / §1.4 rule 5: truncated or malformed
8850                // responses abort without persisting anything.
8851                return SyncOutcome::Failed {
8852                    error_code: error.code.as_str().to_owned(),
8853                    message: error.detail,
8854                };
8855            }
8856        };
8857        #[cfg(feature = "bench-internals")]
8858        drop(decode_phase);
8859        if response.msg_kind != MsgKind::Response {
8860            return SyncOutcome::Failed {
8861                error_code: "sync.invalid_request".to_owned(),
8862                message: "expected a response message".to_owned(),
8863            };
8864        }
8865        let mut outcome = self.process_response(transport, response, &meta);
8866        if let SyncOutcome::Ok(report) = &mut outcome {
8867            report.deferred_commits = meta.deferred_commits;
8868        }
8869        match &outcome {
8870            SyncOutcome::Ok(_) => self.reset_background_retry(),
8871            SyncOutcome::Failed { error_code, .. }
8872                if Self::retryable_transport_code(error_code) =>
8873            {
8874                self.schedule_background_retry();
8875            }
8876            SyncOutcome::Failed { .. } => {}
8877        }
8878        if meta.deferred_commits > 0 {
8879            // §6.1 splitBatch: commits past the operation cap wait for the
8880            // next round — keep the host's sync signal raised until then.
8881            self.set_sync_needed(true, true);
8882        }
8883        // RFC 0005 D7: coverage completion, lease end, scope revocation and the
8884        // TTL are evaluated once per round, after the apply.
8885        self.previous_version_lifetime_check();
8886        outcome
8887    }
8888
8889    pub fn sync_until_idle(
8890        &mut self,
8891        transport: &mut dyn Transport,
8892        max_rounds: Option<u32>,
8893    ) -> SyncOutcome {
8894        let rounds = max_rounds.unwrap_or(20).max(1);
8895        let mut aggregate = SyncReport::default();
8896        let mut spent = 0;
8897        while spent < rounds {
8898            spent += 1;
8899            let before = self
8900                .subs
8901                .iter()
8902                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
8903                .collect::<Vec<_>>();
8904            match self.sync(transport) {
8905                SyncOutcome::Failed {
8906                    error_code,
8907                    message,
8908                } => {
8909                    return SyncOutcome::Failed {
8910                        error_code,
8911                        message,
8912                    };
8913                }
8914                SyncOutcome::Ok(report) => {
8915                    if max_rounds.is_none()
8916                        && report.segment_rows_applied > 0
8917                        && !report.bootstrapping.is_empty()
8918                        && before
8919                            != self
8920                                .subs
8921                                .iter()
8922                                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
8923                                .collect::<Vec<_>>()
8924                    {
8925                        spent = 0;
8926                    }
8927                    aggregate.pushed += report.pushed;
8928                    aggregate.applied.extend(report.applied.iter().cloned());
8929                    aggregate.rejected.extend(report.rejected.iter().cloned());
8930                    aggregate.retryable.extend(report.retryable.iter().cloned());
8931                    aggregate.conflicts += report.conflicts;
8932                    aggregate.commits_applied += report.commits_applied;
8933                    aggregate.segment_rows_applied += report.segment_rows_applied;
8934                    aggregate.bootstrapping = report.bootstrapping.clone();
8935                    aggregate.resets.extend(report.resets.iter().cloned());
8936                    aggregate.revoked.extend(report.revoked.iter().cloned());
8937                    aggregate.failed.extend(report.failed.iter().cloned());
8938                    aggregate.deferred_commits = report.deferred_commits;
8939                    if report.schema_floor.is_some() {
8940                        aggregate.schema_floor = report.schema_floor.clone();
8941                    }
8942                    // §4.5: pull again whenever the response contained
8943                    // commits or segments; resets re-bootstrap; a pending
8944                    // resume token continues paging (§4.7); a raised
8945                    // sync-needed signal covers §6.1 splitBatch remainders
8946                    // (deferred outbox commits push on the next round).
8947                    let more = !report.bootstrapping.is_empty()
8948                        || report.commits_applied > 0
8949                        || report.segment_rows_applied > 0
8950                        || !report.resets.is_empty()
8951                        || self.sync_needed;
8952                    if !more {
8953                        return SyncOutcome::Ok(aggregate);
8954                    }
8955                }
8956            }
8957        }
8958        SyncOutcome::Failed {
8959            error_code: "sync.invalid_request".into(),
8960            message: "sync did not reach idle within the round budget".into(),
8961        }
8962    }
8963
8964    fn process_response(
8965        &mut self,
8966        transport: &mut dyn Transport,
8967        response: Message,
8968        meta: &RequestMeta,
8969    ) -> SyncOutcome {
8970        #[cfg(feature = "bench-internals")]
8971        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
8972        let mut report = SyncReport {
8973            pushed: meta.pushed_ids.len() as u32,
8974            ..SyncReport::default()
8975        };
8976        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
8977            HashMap::new();
8978        let pushed_ids = meta
8979            .pushed_ids
8980            .iter()
8981            .map(String::as_str)
8982            .collect::<HashSet<_>>();
8983        let mut last_final_push_result_id: Option<String> = None;
8984        for frame in &response.frames {
8985            match frame {
8986                Frame::PushResultDetails {
8987                    client_commit_id,
8988                    entries,
8989                } => {
8990                    let details = rejection_details_by_commit
8991                        .entry(client_commit_id.clone())
8992                        .or_default();
8993                    for entry in entries {
8994                        let parsed = match RejectionDetails::parse(&entry.details.0) {
8995                            Ok(value) => value,
8996                            Err(message) => {
8997                                return SyncOutcome::Failed {
8998                                    error_code: "sync.invalid_request".to_owned(),
8999                                    message,
9000                                };
9001                            }
9002                        };
9003                        details.insert(entry.op_index, parsed);
9004                    }
9005                }
9006                Frame::PushResult {
9007                    client_commit_id,
9008                    status,
9009                    results,
9010                    ..
9011                } if pushed_ids.contains(client_commit_id.as_str())
9012                    && Self::push_result_is_final(*status, results) =>
9013                {
9014                    last_final_push_result_id = Some(client_commit_id.clone());
9015                }
9016                _ => {}
9017            }
9018        }
9019        let mut frames = response.frames.into_iter().peekable();
9020        if response.wire_version < 2 {
9021            return SyncOutcome::Failed {
9022                error_code: "client.invalid_host_response".to_owned(),
9023                message: "server response does not carry wire version 2 log-epoch state".to_owned(),
9024            };
9025        }
9026        match frames.next() {
9027            Some(Frame::RespHeader {
9028                required_schema_version,
9029                latest_schema_version,
9030                log_epoch,
9031                reset_required,
9032            }) => {
9033                let Some(log_epoch) = log_epoch else {
9034                    return SyncOutcome::Failed {
9035                        error_code: "client.invalid_host_response".to_owned(),
9036                        message: "response header omits logEpoch".to_owned(),
9037                    };
9038                };
9039                let Some(reset_required) = reset_required else {
9040                    return SyncOutcome::Failed {
9041                        error_code: "client.invalid_host_response".to_owned(),
9042                        message: "response header omits resetRequired".to_owned(),
9043                    };
9044                };
9045                if let Some(required) = required_schema_version {
9046                    // §1.6 schema-floor response: nothing else is processed;
9047                    // stop syncing and surface the upgrade requirement.
9048                    let floor = SchemaFloor {
9049                        required_schema_version: Some(required),
9050                        latest_schema_version,
9051                    };
9052                    self.set_schema_floor(Some(floor.clone()));
9053                    report.schema_floor = Some(floor);
9054                    return SyncOutcome::Ok(report);
9055                }
9056                if reset_required {
9057                    if frames.next().is_some() {
9058                        return SyncOutcome::Failed {
9059                            error_code: "client.invalid_host_response".to_owned(),
9060                            message: "log-epoch reset response contains body frames".to_owned(),
9061                        };
9062                    }
9063                    match self.run_log_epoch_reset(&log_epoch) {
9064                        Ok(resets) => {
9065                            report.resets = resets;
9066                            return SyncOutcome::Ok(report);
9067                        }
9068                        Err(message) => {
9069                            return SyncOutcome::Failed {
9070                                error_code: "sync.local_corrupt".to_owned(),
9071                                message,
9072                            };
9073                        }
9074                    }
9075                }
9076                if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
9077                    return SyncOutcome::Failed {
9078                        error_code: "client.invalid_host_response".to_owned(),
9079                        message: "server changed logEpoch without requiring a reset".to_owned(),
9080                    };
9081                }
9082            }
9083            _ => {
9084                return SyncOutcome::Failed {
9085                    error_code: "sync.invalid_request".to_owned(),
9086                    message: "response does not start with RESP_HEADER".to_owned(),
9087                };
9088            }
9089        }
9090
9091        let mut failure: Option<(String, String)> = None;
9092        while let Some(frame) = frames.next() {
9093            match frame {
9094                frame @ Frame::PushResult { .. } => {
9095                    let successful = matches!(
9096                        &frame,
9097                        Frame::PushResult {
9098                            status: PushStatus::Applied | PushStatus::Cached,
9099                            ..
9100                        }
9101                    );
9102                    let mut results = vec![frame];
9103                    if successful {
9104                        while matches!(
9105                            frames.peek(),
9106                            Some(Frame::PushResult {
9107                                status: PushStatus::Applied | PushStatus::Cached,
9108                                ..
9109                            })
9110                        ) {
9111                            if let Some(next) = frames.next() {
9112                                results.push(next);
9113                            }
9114                        }
9115                    }
9116                    if self
9117                        .handle_push_results(
9118                            &results,
9119                            &pushed_ids,
9120                            &rejection_details_by_commit,
9121                            &mut report,
9122                            last_final_push_result_id.as_deref(),
9123                        )
9124                        .is_err()
9125                    {
9126                        failure = Some((
9127                            "client.outcome_persistence_failed".to_owned(),
9128                            "local commit outcome could not be persisted".to_owned(),
9129                        ));
9130                        break;
9131                    }
9132                }
9133                Frame::PushResultDetails { .. } => {}
9134                Frame::SubStart {
9135                    id,
9136                    status,
9137                    reason_code,
9138                    effective_scopes,
9139                    bootstrap: _,
9140                } => {
9141                    let mut body = Vec::new();
9142                    let mut sub_end: Option<(i64, Option<String>)> = None;
9143                    for inner in frames.by_ref() {
9144                        match inner {
9145                            Frame::SubEnd {
9146                                next_cursor,
9147                                bootstrap_state,
9148                            } => {
9149                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
9150                                break;
9151                            }
9152                            Frame::Unknown { .. } => {}
9153                            other => body.push(other),
9154                        }
9155                    }
9156                    if let Err(SectionError::Abort(code, message)) = self.process_section(
9157                        transport,
9158                        &id,
9159                        status,
9160                        &reason_code,
9161                        effective_scopes,
9162                        body,
9163                        sub_end,
9164                        meta,
9165                        &mut report,
9166                    ) {
9167                        failure = Some((code, message));
9168                        break;
9169                    }
9170                }
9171                Frame::Lease {
9172                    lease_id,
9173                    expires_at_ms,
9174                } => {
9175                    // §7.3.5: persist the opaque lease; a fresh lease clears
9176                    // any prior lease error (the outage/revocation is over).
9177                    self.set_lease_state(Some(LeaseState {
9178                        lease_id: Some(lease_id),
9179                        expires_at_ms: Some(expires_at_ms),
9180                        error_code: None,
9181                    }));
9182                }
9183                Frame::Error { code, message, .. } => {
9184                    // §1.6: the whole request failed; already-completed
9185                    // subscriptions keep their applied data and cursors.
9186                    failure = Some((code, message));
9187                    break;
9188                }
9189                Frame::Unknown { .. } => {}
9190                _ => {
9191                    failure = Some((
9192                        "sync.invalid_request".to_owned(),
9193                        "unexpected frame in response".to_owned(),
9194                    ));
9195                    break;
9196                }
9197            }
9198        }
9199
9200        // §7.1: reconcile the visible overlay once at the response boundary
9201        // after all outbox acknowledgements and server data applied so a
9202        // batch of PUSH_RESULT frames cannot trigger repeated full-table
9203        // rebuilds. This also covers a round that aborted mid-way.
9204        if self.overlay_dirty.get() {
9205            self.rebuild_overlay();
9206        }
9207        if let Some((error_code, message)) = failure {
9208            return SyncOutcome::Failed {
9209                error_code,
9210                message,
9211            };
9212        }
9213        // §4.8 E1: the push half may have drained commits that pinned rows of
9214        // a shrunk window unit — retry any deferred evictions now.
9215        if let Err(message) = self.drain_pending_evictions() {
9216            return SyncOutcome::Failed {
9217                error_code: "storage.failed".into(),
9218                message,
9219            };
9220        }
9221        self.ack_after_pull(transport);
9222        // §7.4.5: the reset is over once the first post-reset pull round
9223        // leaves no subscription mid-bootstrap — the tables are rebuilt.
9224        if self.upgrading && report.bootstrapping.is_empty() {
9225            self.set_upgrading(false);
9226        }
9227        SyncOutcome::Ok(report)
9228    }
9229
9230    // -- push results (§6.3, §7.2) ------------------------------------------------
9231
9232    fn handle_push_results(
9233        &mut self,
9234        frames: &[Frame],
9235        pushed_ids: &HashSet<&str>,
9236        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
9237        report: &mut SyncReport,
9238        last_final_push_result_id: Option<&str>,
9239    ) -> Result<(), String> {
9240        self.begin_observation("syncular_push_results")?;
9241        let conflict_count = self.conflicts.len();
9242        let rejection_count = self.rejections.len();
9243        let applied_count = report.applied.len();
9244        let rejected_count = report.rejected.len();
9245        let retryable_count = report.retryable.len();
9246        let was_dirty = self.overlay_dirty.get();
9247        let mut removed = Vec::new();
9248        let mut batch = ChangeAccumulator::default();
9249        let persisted = (|| {
9250            for frame in frames {
9251                let Frame::PushResult {
9252                    client_commit_id,
9253                    status,
9254                    results,
9255                    ..
9256                } = frame
9257                else {
9258                    return Err("unexpected acknowledgement frame".to_owned());
9259                };
9260                if !pushed_ids.contains(client_commit_id.as_str()) {
9261                    continue;
9262                }
9263                let Some(index) = self
9264                    .outbox
9265                    .iter()
9266                    .position(|commit| commit.client_commit_id == *client_commit_id)
9267                else {
9268                    continue;
9269                };
9270                let status = *status;
9271                let rejection_details = rejection_details_by_commit.get(client_commit_id);
9272                let operations = self.outbox[index].ops.clone();
9273                match status {
9274                    PushStatus::Applied | PushStatus::Cached => {
9275                        // §7.2: a lost ack replays as `cached` — proceed as if the
9276                        // ack had arrived.
9277                        let journal_results = results
9278                            .iter()
9279                            .map(|result| {
9280                                let op_index = match result {
9281                                    OpResult::Applied { op_index }
9282                                    | OpResult::Conflict { op_index, .. }
9283                                    | OpResult::Error { op_index, .. } => *op_index,
9284                                };
9285                                CommitOperationOutcome::Applied { op_index }
9286                            })
9287                            .collect::<Vec<_>>();
9288                        let outcome_status = if status == PushStatus::Applied {
9289                            CommitOutcomeStatus::Applied
9290                        } else {
9291                            CommitOutcomeStatus::Cached
9292                        };
9293                        let persisted = self
9294                            .persist_commit_outcome(
9295                                client_commit_id,
9296                                outcome_status,
9297                                &journal_results,
9298                                None,
9299                            )
9300                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
9301                        persisted?;
9302                        self.overlay_dirty.set(true);
9303                        batch.status = true;
9304                        batch.outcomes = true;
9305                    }
9306                    PushStatus::Rejected => {
9307                        if results.iter().any(|result| {
9308                            matches!(
9309                                result,
9310                                OpResult::Error {
9311                                    code,
9312                                    retryable: true,
9313                                    ..
9314                                } if code == "sync.idempotency_cache_miss"
9315                            )
9316                        }) {
9317                            // §6.3/§7.2: a serving failure, not an outcome — keep the
9318                            // exact commit queued for an identical retry.
9319                            report.retryable.push(client_commit_id.to_owned());
9320                            continue;
9321                        }
9322
9323                        let mut journal_results = Vec::with_capacity(results.len());
9324                        let mut conflicts = Vec::new();
9325                        let mut rejections = Vec::new();
9326                        for result in results {
9327                            match result {
9328                                OpResult::Applied { op_index } => {
9329                                    journal_results.push(CommitOperationOutcome::Applied {
9330                                        op_index: *op_index,
9331                                    });
9332                                }
9333                                OpResult::Conflict {
9334                                    op_index,
9335                                    code,
9336                                    message,
9337                                    server_version,
9338                                    server_row,
9339                                    conflict_columns: conflict_columns_bitmap,
9340                                } => {
9341                                    let operation = operations
9342                                        .get(*op_index as usize)
9343                                        .map(CommitOperation::from);
9344                                    let (table, row_id) = operation
9345                                        .as_ref()
9346                                        .map(|op| (op.table.clone(), op.row_id.clone()))
9347                                        .unwrap_or_default();
9348                                    let server_row_json = self
9349                                        .schema
9350                                        .table(&table)
9351                                        .and_then(|t| {
9352                                            decode_row_bytes(t, server_row, &self.encryption)
9353                                                .ok()
9354                                                .map(|row| (t, row))
9355                                        })
9356                                        .map(|(t, row)| {
9357                                            let mut map = Map::new();
9358                                            for (i, column) in t.columns.iter().enumerate() {
9359                                                map.insert(
9360                                                    column.name.clone(),
9361                                                    column_value_to_json(
9362                                                        row.get(i).unwrap_or(&None),
9363                                                    ),
9364                                                );
9365                                            }
9366                                            map
9367                                        })
9368                                        .unwrap_or_default();
9369                                    // §6.3: decode the presence-layout bitmap
9370                                    // into the marked column names.
9371                                    let conflict_columns = self
9372                                        .schema
9373                                        .table(&table)
9374                                        .map(|t| {
9375                                            t.columns
9376                                                .iter()
9377                                                .enumerate()
9378                                                .filter(|(index, _)| {
9379                                                    conflict_columns_bitmap
9380                                                        .get(index / 8)
9381                                                        .is_some_and(|byte| {
9382                                                            byte & (1 << (index % 8)) != 0
9383                                                        })
9384                                                })
9385                                                .map(|(_, column)| column.name.clone())
9386                                                .collect::<Vec<_>>()
9387                                        })
9388                                        .unwrap_or_default();
9389                                    let conflict = ConflictRecord {
9390                                        client_commit_id: client_commit_id.to_owned(),
9391                                        op_index: *op_index,
9392                                        table,
9393                                        row_id,
9394                                        code: code.clone(),
9395                                        message: message.clone(),
9396                                        server_version: *server_version,
9397                                        server_row: server_row_json,
9398                                        conflict_columns,
9399                                        operation,
9400                                    };
9401                                    journal_results.push(CommitOperationOutcome::Conflict {
9402                                        conflict: conflict.clone(),
9403                                    });
9404                                    conflicts.push(conflict);
9405                                }
9406                                OpResult::Error {
9407                                    op_index,
9408                                    code,
9409                                    message,
9410                                    retryable,
9411                                } => {
9412                                    let rejection = RejectionRecord {
9413                                        client_commit_id: client_commit_id.to_owned(),
9414                                        op_index: *op_index,
9415                                        code: code.clone(),
9416                                        message: message.clone(),
9417                                        retryable: *retryable,
9418                                        details: rejection_details
9419                                            .and_then(|details| details.get(op_index))
9420                                            .cloned(),
9421                                        operation: operations
9422                                            .get(*op_index as usize)
9423                                            .map(CommitOperation::from),
9424                                    };
9425                                    journal_results.push(CommitOperationOutcome::Error {
9426                                        rejection: rejection.clone(),
9427                                    });
9428                                    rejections.push(rejection);
9429                                }
9430                            }
9431                        }
9432                        let outcome_status = if conflicts.is_empty() {
9433                            CommitOutcomeStatus::Rejected
9434                        } else {
9435                            CommitOutcomeStatus::Conflict
9436                        };
9437                        let persisted = self
9438                            .persist_commit_outcome(
9439                                client_commit_id,
9440                                outcome_status,
9441                                &journal_results,
9442                                Some(&operations),
9443                            )
9444                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
9445                        persisted?;
9446                        batch.conflicts = !conflicts.is_empty();
9447                        batch.rejections = !rejections.is_empty();
9448                        batch.status = true;
9449                        batch.outcomes = true;
9450                        self.conflicts.extend(conflicts);
9451                        self.rejections.extend(rejections);
9452                        self.overlay_dirty.set(true);
9453                    }
9454                }
9455                removed.push((index, self.outbox.remove(index)));
9456                if batch.status {
9457                    for operation in &operations {
9458                        if !self.record_row_scopes(
9459                            &mut batch,
9460                            &operation.table,
9461                            &operation.row_id,
9462                            false,
9463                        ) {
9464                            batch.table(&operation.table);
9465                        }
9466                    }
9467                }
9468                match status {
9469                    PushStatus::Applied | PushStatus::Cached => {
9470                        report.applied.push(client_commit_id.to_owned())
9471                    }
9472                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
9473                }
9474            }
9475            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)) {
9476                self.prune_commit_outcomes()?;
9477            }
9478            self.finish_observation("syncular_push_results", batch)
9479        })();
9480        if let Err(error) = persisted {
9481            self.rollback_observation("syncular_push_results");
9482            for (index, commit) in removed.into_iter().rev() {
9483                self.outbox.insert(index, commit);
9484            }
9485            self.conflicts.truncate(conflict_count);
9486            self.rejections.truncate(rejection_count);
9487            self.overlay_dirty.set(was_dirty);
9488            report.applied.truncate(applied_count);
9489            report.rejected.truncate(rejected_count);
9490            report.retryable.truncate(retryable_count);
9491            return Err(error);
9492        }
9493        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
9494        Ok(())
9495    }
9496
9497    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
9498        status != PushStatus::Rejected
9499            || !results.iter().any(|result| {
9500                matches!(
9501                    result,
9502                    OpResult::Error {
9503                        code,
9504                        retryable: true,
9505                        ..
9506                    } if code == "sync.idempotency_cache_miss"
9507                )
9508            })
9509    }
9510
9511    // -- subscription sections ------------------------------------------------------
9512
9513    #[allow(clippy::too_many_arguments)]
9514    fn process_section(
9515        &mut self,
9516        transport: &mut dyn Transport,
9517        id: &str,
9518        status: SubStatus,
9519        reason_code: &str,
9520        effective_scopes: Vec<(String, Vec<String>)>,
9521        body: Vec<Frame>,
9522        sub_end: Option<(i64, Option<String>)>,
9523        meta: &RequestMeta,
9524        report: &mut SyncReport,
9525    ) -> Result<(), SectionError> {
9526        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
9527            return Ok(()); // unknown echo: ignore
9528        };
9529        if status != SubStatus::Active && sub_end.is_none() {
9530            return Err(SectionError::Abort(
9531                "sync.invalid_request".into(),
9532                "subscription section without SUB_END".into(),
9533            ));
9534        }
9535        match status {
9536            SubStatus::Revoked => {
9537                self.begin_observation("syncular_revocation")
9538                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
9539                let previous = self.subs[sub_index].clone();
9540                let was_dirty = self.overlay_dirty.get();
9541                let previous_outbox = self.outbox.clone();
9542                let rejection_count = self.rejections.len();
9543                let revoked_count = report.revoked.len();
9544                let failed_count = report.failed.len();
9545                let result =
9546                    (|| {
9547                        let mut batch = ChangeAccumulator::default();
9548                        let registered = self.window_unit_by_sub_id(id);
9549                        // §3.3: stop pulling, purge exactly the last effective grant.
9550                        let (table, effective) = {
9551                            let sub = &self.subs[sub_index];
9552                            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
9553                        };
9554                        let purged = self.purge_scope_rows(&table, &effective);
9555                        match purged {
9556                            Ok(()) => {
9557                                self.record_scope_map(&mut batch, &table, &effective);
9558                                let sub = &mut self.subs[sub_index];
9559                                sub.state = SubState::Revoked;
9560                                sub.reason_code = Some(if reason_code.is_empty() {
9561                                    "sync.scope_revoked".to_owned()
9562                                } else {
9563                                    reason_code.to_owned()
9564                                });
9565                                report.revoked.push(id.to_owned());
9566                                let doomed_effective = effective;
9567                                let sub_table = table;
9568                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
9569                                    |message| SectionError::Abort("storage.failed".into(), message),
9570                                )?;
9571                                let dropped = self
9572                                    .drop_doomed_outbox(&sub_table, &doomed_effective)
9573                                    .map_err(|message| {
9574                                        SectionError::Abort("storage.failed".to_owned(), message)
9575                                    })?;
9576                                if dropped {
9577                                    batch.status = true;
9578                                    batch.rejections = true;
9579                                    batch.outcomes = true;
9580                                }
9581                                // §5.9.7 B2: revocation deletes now-unauthorized blob
9582                                // bodies (evicted ≠ revoked).
9583                                self.delete_unreferenced_cached_blobs();
9584                            }
9585                            Err(()) => {
9586                                // §3.3 fail closed: no local mapping — never clear by
9587                                // approximation; fatal configuration error.
9588                                let sub = &mut self.subs[sub_index];
9589                                sub.state = SubState::Failed;
9590                                sub.reason_code = Some("sync.scope_revoked".to_owned());
9591                                report.failed.push(id.to_owned());
9592                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
9593                                    |message| SectionError::Abort("storage.failed".into(), message),
9594                                )?;
9595                            }
9596                        }
9597                        if let Some((base_key, unit)) = registered {
9598                            batch.window(&base_key, &self.subs[sub_index].table, &unit);
9599                        }
9600                        self.rebuild_overlay_if_dirty();
9601                        self.finish_observation("syncular_revocation", batch)
9602                            .map_err(|message| {
9603                                SectionError::Abort("storage.failed".to_owned(), message)
9604                            })?;
9605                        Ok(())
9606                    })();
9607                if result.is_err() {
9608                    self.rollback_observation("syncular_revocation");
9609                    self.subs[sub_index] = previous;
9610                    self.overlay_dirty.set(was_dirty);
9611                    self.outbox = previous_outbox;
9612                    self.rejections.truncate(rejection_count);
9613                    report.revoked.truncate(revoked_count);
9614                    report.failed.truncate(failed_count);
9615                }
9616                result
9617            }
9618            SubStatus::Reset => {
9619                self.begin_observation("syncular_reset")
9620                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
9621                let previous = self.subs[sub_index].clone();
9622                let was_dirty = self.overlay_dirty.get();
9623                let reset_count = report.resets.len();
9624                let result = (|| {
9625                    let mut batch = ChangeAccumulator::default();
9626                    let registered = self.window_unit_by_sub_id(id);
9627                    // §4.6: discard cursor + bootstrap state, keep local rows —
9628                    // reset is a staleness signal, not a purge signal.
9629                    let sub = &mut self.subs[sub_index];
9630                    sub.cursor = -1;
9631                    sub.bootstrap_state = None;
9632                    report.resets.push(id.to_owned());
9633                    self.persist_sub(&self.subs[sub_index].clone())
9634                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9635                    if let Some((base_key, unit)) = registered {
9636                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
9637                    }
9638                    self.finish_observation("syncular_reset", batch)
9639                        .map_err(|message| {
9640                            SectionError::Abort("storage.failed".to_owned(), message)
9641                        })?;
9642                    Ok(())
9643                })();
9644                if result.is_err() {
9645                    self.rollback_observation("syncular_reset");
9646                    self.subs[sub_index] = previous;
9647                    self.overlay_dirty.set(was_dirty);
9648                    report.resets.truncate(reset_count);
9649                }
9650                result
9651            }
9652            SubStatus::Active => {
9653                let fresh = meta
9654                    .fresh
9655                    .iter()
9656                    .find(|(fid, _)| fid == id)
9657                    .map(|(_, fresh)| *fresh)
9658                    .unwrap_or(false);
9659                let previous = self.subs[sub_index].clone();
9660                self.subs[sub_index].effective = Some(effective_scopes);
9661                let outcome = self
9662                    .apply_section_body(transport, sub_index, body, fresh, meta, report)
9663                    .and_then(|()| {
9664                        let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
9665                            SectionError::Abort(
9666                                "sync.invalid_request".into(),
9667                                "subscription section without SUB_END".into(),
9668                            )
9669                        })?;
9670                        self.apply_sub_end(sub_index, cursor, bootstrap_state)
9671                            .map_err(|message| {
9672                                SectionError::Abort("storage.failed".into(), message)
9673                            })
9674                    });
9675                match outcome {
9676                    Ok(()) => {
9677                        if self.subs[sub_index].bootstrap_state.is_some() {
9678                            report.bootstrapping.push(id.to_owned());
9679                        }
9680                        Ok(())
9681                    }
9682                    Err(SectionError::FailClosed) => {
9683                        self.subs[sub_index] = previous.clone();
9684                        self.begin_observation("syncular_section_failure")
9685                            .map_err(|message| {
9686                                SectionError::Abort("storage.failed".into(), message)
9687                            })?;
9688                        let persisted = (|| {
9689                            let mut batch = ChangeAccumulator::default();
9690                            if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
9691                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
9692                            }
9693                            self.subs[sub_index].state = SubState::Failed;
9694                            self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
9695                            self.persist_sub(&self.subs[sub_index])?;
9696                            self.finish_observation("syncular_section_failure", batch)
9697                        })();
9698                        if let Err(message) = persisted {
9699                            self.rollback_observation("syncular_section_failure");
9700                            self.subs[sub_index] = previous;
9701                            return Err(SectionError::Abort("storage.failed".into(), message));
9702                        }
9703                        report.failed.push(id.to_owned());
9704                        Ok(())
9705                    }
9706                    Err(error) => {
9707                        // Frame/block transactions already completed. Preserve their
9708                        // rows and revisions, but leave this subscription cursor unchanged.
9709                        self.subs[sub_index] = previous;
9710                        Err(error)
9711                    }
9712                }
9713            }
9714        }
9715    }
9716
9717    fn apply_sub_end(
9718        &mut self,
9719        sub_index: usize,
9720        cursor: i64,
9721        bootstrap_state: Option<String>,
9722    ) -> Result<(), String> {
9723        #[cfg(feature = "bench-internals")]
9724        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
9725        let previous = self.subs[sub_index].clone();
9726        self.begin_observation("syncular_sub_end")?;
9727        let persisted = (|| {
9728            let mut batch = ChangeAccumulator::default();
9729            let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
9730                && cursor >= 0
9731                && bootstrap_state.is_none();
9732            if completed {
9733                if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
9734                    batch.window(&base_key, &previous.table, &unit);
9735                }
9736            }
9737            let sub = &mut self.subs[sub_index];
9738            sub.cursor = cursor;
9739            sub.bootstrap_state = bootstrap_state;
9740            sub.synced_once = true;
9741            self.persist_sub(&self.subs[sub_index])?;
9742            self.finish_observation("syncular_sub_end", batch)
9743        })();
9744        if persisted.is_err() {
9745            self.rollback_observation("syncular_sub_end");
9746            self.subs[sub_index] = previous;
9747        }
9748        persisted
9749    }
9750
9751    fn apply_section_body(
9752        &mut self,
9753        transport: &mut dyn Transport,
9754        sub_index: usize,
9755        body: Vec<Frame>,
9756        fresh: bool,
9757        meta: &RequestMeta,
9758        report: &mut SyncReport,
9759    ) -> Result<(), SectionError> {
9760        let mut saw_segment = false;
9761        for frame in body {
9762            match frame {
9763                Frame::Commit {
9764                    tables, changes, ..
9765                } => {
9766                    self.apply_commit_frame(&tables, &changes)
9767                        .map_err(|(c, m)| SectionError::Abort(c, m))?;
9768                    report.commits_applied += 1;
9769                }
9770                Frame::SegmentInline { payload } => {
9771                    let segment = decode_rows_segment(&payload)
9772                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
9773                    self.progress.update(|p| {
9774                        p.phase = ProgressPhase::Import;
9775                        p.subscription_id = Some(self.subs[sub_index].id.clone());
9776                        p.table = Some(self.subs[sub_index].table.clone());
9777                        p.segment_id = None;
9778                        p.bytes_received = payload.len() as u64;
9779                        p.bytes_total = Some(payload.len() as u64);
9780                        p.rows_processed = 0;
9781                        p.rows_total =
9782                            Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
9783                    });
9784                    let first = !saw_segment;
9785                    saw_segment = true;
9786                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
9787                    report.segment_rows_applied += applied;
9788                }
9789                Frame::SegmentRef {
9790                    segment_id,
9791                    byte_length,
9792                    media_type,
9793                    table,
9794                    row_count,
9795                    as_of_commit_seq,
9796                    scope_digest,
9797                    row_cursor,
9798                    next_row_cursor,
9799                    url,
9800                    url_expires_at_ms,
9801                    ..
9802                } => {
9803                    // §4.2: reject a descriptor whose mediaType was not
9804                    // advertised — never skip or guess.
9805                    let advertised = match media_type {
9806                        MediaType::Rows => {
9807                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
9808                                || meta.accept & ACCEPT_INLINE_ROWS != 0
9809                        }
9810                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
9811                    };
9812                    if !advertised {
9813                        return Err(SectionError::Abort(
9814                            "sync.invalid_request".to_owned(),
9815                            format!(
9816                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
9817                                media_type.name()
9818                            ),
9819                        ));
9820                    }
9821                    self.progress.update(|p| {
9822                        p.phase = ProgressPhase::Download;
9823                        p.subscription_id = Some(self.subs[sub_index].id.clone());
9824                        p.table = Some(table.clone());
9825                        p.segment_id = Some(segment_id.clone());
9826                        p.bytes_received = 0;
9827                        p.bytes_total = u64::try_from(byte_length).ok();
9828                        p.rows_processed = 0;
9829                        p.rows_total = u64::try_from(row_count).ok();
9830                    });
9831                    let observer = self.progress.clone();
9832                    let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
9833                    let bytes = if let Some(url) = url {
9834                        // §5.4: a url-carrying descriptor MUST be fetched
9835                        // from that URL; failure invalidates the whole
9836                        // descriptor (no fall-through to §5.5 — re-pull
9837                        // recovers, §1.4 rule 5).
9838                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
9839                            return Err(SectionError::Abort(
9840                                "sync.invalid_request".to_owned(),
9841                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
9842                                    .to_owned(),
9843                            ));
9844                        }
9845                        // §5.4: MUST NOT start a fetch at/past expiry.
9846                        if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
9847                            return Err(SectionError::Abort(
9848                                "sync.segment_expired".to_owned(),
9849                                format!(
9850                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
9851                                ),
9852                            ));
9853                        }
9854                        transport
9855                            .fetch_url(&url, &mut on_progress)
9856                            .map_err(|e| SectionError::Abort(e.code, e.message))?
9857                    } else {
9858                        let requested_scopes_json =
9859                            canonical_scope_json(&self.subs[sub_index].requested);
9860                        transport
9861                            .download_segment(
9862                                &SegmentRequest {
9863                                    segment_id: segment_id.clone(),
9864                                    table,
9865                                    requested_scopes_json,
9866                                },
9867                                &mut on_progress,
9868                            )
9869                            .map_err(|e| SectionError::Abort(e.code, e.message))?
9870                    };
9871                    // §5.1: verify the content address before applying.
9872                    on_progress(bytes.len() as u64);
9873                    let digest = Sha256::digest(&bytes);
9874                    let expected = segment_id
9875                        .strip_prefix("sha256:")
9876                        .unwrap_or(segment_id.as_str());
9877                    if bytes_to_hex(&digest) != expected {
9878                        return Err(SectionError::Abort(
9879                            "sync.invalid_request".to_owned(),
9880                            "segment bytes do not match the content address (§5.1)".to_owned(),
9881                        ));
9882                    }
9883                    self.progress.update(|p| p.phase = ProgressPhase::Import);
9884                    if media_type == MediaType::Sqlite {
9885                        // §5.3: images are whole-table — a paged sqlite
9886                        // descriptor is invalid.
9887                        if row_cursor.is_some() || next_row_cursor.is_some() {
9888                            return Err(SectionError::Abort(
9889                                "sync.invalid_request".to_owned(),
9890                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
9891                                    .to_owned(),
9892                            ));
9893                        }
9894                        let first = !saw_segment;
9895                        saw_segment = true;
9896                        let applied = self.apply_sqlite_segment(
9897                            sub_index,
9898                            &bytes,
9899                            fresh && first,
9900                            row_count,
9901                            as_of_commit_seq,
9902                            &scope_digest,
9903                        )?;
9904                        report.segment_rows_applied += applied;
9905                    } else {
9906                        let segment = decode_rows_segment(&bytes).map_err(|e| {
9907                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
9908                        })?;
9909                        let first = row_cursor.is_none();
9910                        saw_segment = true;
9911                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
9912                        report.segment_rows_applied += applied;
9913                    }
9914                }
9915                Frame::Unknown { .. } => {}
9916                Frame::Error { code, message, .. } => {
9917                    return Err(SectionError::Abort(code, message))
9918                }
9919                _ => {
9920                    return Err(SectionError::Abort(
9921                        "sync.invalid_request".to_owned(),
9922                        "unexpected frame inside a subscription section".to_owned(),
9923                    ));
9924                }
9925            }
9926        }
9927        Ok(())
9928    }
9929
9930    // Both pull and realtime frames use the same durable observation boundary.
9931    fn apply_commit_frame(
9932        &mut self,
9933        tables: &[String],
9934        changes: &[ssp2::model::Change],
9935    ) -> Result<(), (String, String)> {
9936        #[cfg(feature = "bench-internals")]
9937        let _phase = self.benchmark_phases.start(Phase::CommitApply);
9938        let was_dirty = self.overlay_dirty.get();
9939        self.begin_observation("syncular_commit")
9940            .map_err(|message| ("storage.failed".into(), message))?;
9941        let applied = (|| {
9942            let mut batch = ChangeAccumulator::default();
9943            self.record_commit_changes(&mut batch, tables, changes);
9944            self.apply_commit_changes(tables, changes)?;
9945            self.rebuild_overlay_if_dirty();
9946            self.finish_observation("syncular_commit", batch)
9947                .map_err(|message| ("storage.failed".into(), message))
9948        })();
9949        if applied.is_err() {
9950            self.rollback_observation("syncular_commit");
9951            self.overlay_dirty.set(was_dirty);
9952        }
9953        applied
9954    }
9955
9956    fn apply_commit_changes(
9957        &mut self,
9958        tables: &[String],
9959        changes: &[ssp2::model::Change],
9960    ) -> Result<(), (String, String)> {
9961        // A clean overlay can reconcile changed primary keys independently
9962        // when their tables have no secondary unique constraints. Such a
9963        // constraint can change whether another row's pending write succeeds,
9964        // so those frames still require the complete FIFO replay below.
9965        let mirror_visible = !self.overlay_dirty.get()
9966            && (self.outbox.is_empty()
9967                || changes.iter().all(|change| {
9968                    tables
9969                        .get(change.table_index as usize)
9970                        .and_then(|name| self.schema.table(name))
9971                        .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
9972                }));
9973        for change in changes {
9974            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
9975                (
9976                    "sync.invalid_request".to_owned(),
9977                    "change tableIndex out of range".to_owned(),
9978                )
9979            })?;
9980            let table = self.schema.table(table_name).ok_or_else(|| {
9981                (
9982                    "sync.schema_mismatch".to_owned(),
9983                    format!("change targets unknown table {table_name:?}"),
9984                )
9985            })?;
9986            match change.op {
9987                Op::Upsert => {
9988                    let payload = change.row.as_ref().ok_or_else(|| {
9989                        (
9990                            "sync.invalid_request".to_owned(),
9991                            "upsert change without row payload".to_owned(),
9992                        )
9993                    })?;
9994                    // §5.11: decrypt encrypted columns on apply.
9995                    let row = {
9996                        #[cfg(feature = "bench-internals")]
9997                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
9998                        decode_row_bytes(table, payload, &self.encryption)
9999                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?
10000                    };
10001                    let version = change.row_version.unwrap_or(0);
10002                    let table_name = table.name.clone();
10003                    self.write_base_row(&table_name, &row, version)
10004                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10005                    if mirror_visible {
10006                        self.write_row(&visible_table(&table_name), &table_name, &row, version)
10007                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10008                    }
10009                }
10010                Op::Delete => {
10011                    self.delete_base_row(table_name, &change.row_id)
10012                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10013                    if mirror_visible {
10014                        let sql = format!(
10015                            "DELETE FROM {} WHERE {}",
10016                            visible_table(table_name),
10017                            row_id_predicate(table)
10018                        );
10019                        self.conn
10020                            .prepare_cached(&sql)
10021                            .and_then(|mut statement| {
10022                                statement.execute(rusqlite::params![change.row_id])
10023                            })
10024                            .map_err(|error| {
10025                                ("sync.invalid_request".to_owned(), error.to_string())
10026                            })?;
10027                    }
10028                }
10029            }
10030        }
10031        if mirror_visible {
10032            if !self.outbox.is_empty() {
10033                let changed_rows: HashSet<(&str, &str)> = changes
10034                    .iter()
10035                    .map(|change| {
10036                        (
10037                            tables[change.table_index as usize].as_str(),
10038                            change.row_id.as_str(),
10039                        )
10040                    })
10041                    .collect();
10042                self.apply_outbox_ops(
10043                    self.outbox
10044                        .iter()
10045                        .flat_map(|commit| &commit.ops)
10046                        .filter(|op| {
10047                            changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
10048                        }),
10049                );
10050            }
10051            self.overlay_dirty.set(false);
10052        }
10053        Ok(())
10054    }
10055
10056    /// §5.6 segment application: validate against the generated schema,
10057    /// clear the grant on a fresh bootstrap's first page (fail closed
10058    /// without a mapping), then replace-or-upsert each row with its
10059    /// segment-carried server version (§5.2).
10060    fn apply_segment(
10061        &mut self,
10062        sub_index: usize,
10063        segment: &RowsSegment,
10064        first_fresh_page: bool,
10065    ) -> Result<u32, SectionError> {
10066        let (sub_table, effective) = {
10067            let sub = &self.subs[sub_index];
10068            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10069        };
10070        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
10071            SectionError::Abort(
10072                "sync.schema_mismatch".to_owned(),
10073                format!("subscription table {sub_table:?} is not in the client schema"),
10074            )
10075        })?;
10076        // §5.2: the column table validates against the generated schema —
10077        // order, names, types, nullability; mismatch is fatal. §5.11: the
10078        // server sends the WIRE types (bytes for an encrypted column), so
10079        // validate against wire_columns.
10080        let matches = segment.table == table.name
10081            && segment.schema_version == self.schema.version
10082            && segment.columns.len() == table.wire_columns.len()
10083            && segment
10084                .columns
10085                .iter()
10086                .zip(table.wire_columns.iter())
10087                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
10088        if !matches {
10089            return Err(SectionError::Abort(
10090                "sync.schema_mismatch".to_owned(),
10091                "segment column table does not match the generated schema (§5.2)".to_owned(),
10092            ));
10093        }
10094        let mut applied = 0u32;
10095        let empty = Vec::new();
10096        let blocks = if segment.blocks.is_empty() {
10097            std::slice::from_ref(&empty)
10098        } else {
10099            &segment.blocks
10100        };
10101        for (index, block) in blocks.iter().enumerate() {
10102            let clear = first_fresh_page && index == 0;
10103            if !clear && block.is_empty() {
10104                continue;
10105            }
10106            let was_dirty = self.overlay_dirty.get();
10107            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
10108                && self
10109                    .outbox
10110                    .iter()
10111                    .flat_map(|commit| &commit.ops)
10112                    .any(|op| op.table == table.name);
10113            let mirror_visible = !reconcile_pending;
10114            self.begin_observation("syncular_segment_block")
10115                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10116            let outcome = (|| {
10117                self.rebuild_overlay_if_dirty();
10118                let mut changed_keys = Vec::new();
10119                let mut batch = ChangeAccumulator::default();
10120                if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
10121                    batch.table(&table.name);
10122                }
10123                if clear {
10124                    self.purge_scope_rows(&table.name, &effective)
10125                        .map_err(|()| SectionError::FailClosed)?;
10126                }
10127                for (row_index, row) in block.iter().enumerate() {
10128                    let decrypted;
10129                    let values = if table.has_encrypted_columns() {
10130                        let mut values = row.values.clone();
10131                        crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
10132                            .map_err(|message| {
10133                            SectionError::Abort("client.decrypt_failed".into(), message)
10134                        })?;
10135                        decrypted = values;
10136                        &decrypted
10137                    } else {
10138                        &row.values
10139                    };
10140                    self.write_base_row(&table.name, values, row.server_version)
10141                        .map_err(|message| {
10142                            SectionError::Abort("sync.invalid_request".into(), message)
10143                        })?;
10144                    if reconcile_pending {
10145                        changed_keys.push(
10146                            owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
10147                                |message| SectionError::Abort("storage.failed".into(), message),
10148                            )?,
10149                        );
10150                    }
10151                    if mirror_visible {
10152                        self.write_row(
10153                            &visible_table(&table.name),
10154                            &table.name,
10155                            values,
10156                            row.server_version,
10157                        )
10158                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10159                    }
10160                    let processed = u64::from(applied) + row_index as u64 + 1;
10161                    if processed.is_multiple_of(1024) {
10162                        self.progress.update(|p| p.rows_processed = processed);
10163                    }
10164                }
10165                if reconcile_pending {
10166                    self.reconcile_imported_rows(&table, &mut changed_keys)
10167                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10168                }
10169                self.apply_outbox_ops(
10170                    self.outbox
10171                        .iter()
10172                        .flat_map(|commit| &commit.ops)
10173                        .filter(|op| op.table == table.name),
10174                );
10175                self.overlay_dirty.set(false);
10176                self.finish_observation("syncular_segment_block", batch)
10177                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
10178            })();
10179            if let Err(error) = outcome {
10180                self.rollback_observation("syncular_segment_block");
10181                self.overlay_dirty.set(was_dirty);
10182                return Err(error);
10183            }
10184            applied += block.len() as u32;
10185            self.progress
10186                .update(|p| p.rows_processed = u64::from(applied));
10187            std::thread::yield_now();
10188        }
10189        self.progress
10190            .update(|p| p.rows_processed = u64::from(applied));
10191        Ok(applied)
10192    }
10193
10194    /// §5.3 sqlite-image application: validate the in-file metadata
10195    /// against the descriptor, validate column names/order against the
10196    /// generated schema, run the §5.6 first-page clear when fresh, then
10197    /// replace-or-upsert every image row with its `_syncular_version`.
10198    /// Mechanics: the image lands in a temp file read through a second
10199    /// rusqlite connection, equivalent to ATTACH + INSERT…SELECT.
10200    /// Each image chunk commits its writes and revision before readers continue.
10201    fn apply_sqlite_segment(
10202        &mut self,
10203        sub_index: usize,
10204        bytes: &[u8],
10205        first_fresh_page: bool,
10206        row_count: i64,
10207        as_of_commit_seq: i64,
10208        scope_digest: &str,
10209    ) -> Result<u32, SectionError> {
10210        let invalid = |detail: &str| {
10211            SectionError::Abort(
10212                "sync.invalid_request".to_owned(),
10213                format!("sqlite segment rejected: {detail} (§5.3)"),
10214            )
10215        };
10216        let (sub_table, effective) = {
10217            let sub = &self.subs[sub_index];
10218            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10219        };
10220        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
10221            SectionError::Abort(
10222                "sync.schema_mismatch".to_owned(),
10223                format!("subscription table {sub_table:?} is not in the client schema"),
10224            )
10225        })?;
10226
10227        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
10228        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
10229        let img = match rusqlite::Connection::open_with_flags(
10230            &path,
10231            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
10232        ) {
10233            Ok(conn) => conn,
10234            Err(_) => {
10235                let _ = std::fs::remove_file(&path);
10236                return Err(invalid("bytes do not open as a SQLite database"));
10237            }
10238        };
10239        let outcome = self.apply_sqlite_image(
10240            &img,
10241            &table,
10242            first_fresh_page,
10243            &effective,
10244            row_count,
10245            as_of_commit_seq,
10246            scope_digest,
10247        );
10248        drop(img);
10249        let _ = std::fs::remove_file(&path);
10250        outcome
10251    }
10252
10253    #[allow(clippy::too_many_arguments)]
10254    fn apply_sqlite_image(
10255        &mut self,
10256        img: &rusqlite::Connection,
10257        table: &crate::schema::TableSchema,
10258        first_fresh_page: bool,
10259        effective: &[(String, Vec<String>)],
10260        row_count: i64,
10261        as_of_commit_seq: i64,
10262        scope_digest: &str,
10263    ) -> Result<u32, SectionError> {
10264        let invalid = |detail: String| {
10265            SectionError::Abort(
10266                "sync.invalid_request".to_owned(),
10267                format!("sqlite segment rejected: {detail} (§5.3)"),
10268            )
10269        };
10270
10271        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
10272        //    one row).
10273        type MetaRow = (i64, String, i64, i64, String, i64, i64);
10274        let meta: MetaRow = img
10275            .query_row(
10276                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
10277                        \"scopeDigest\", \"rowCount\",
10278                        (SELECT count(*) FROM _syncular_segment)
10279                 FROM _syncular_segment",
10280                [],
10281                |row| {
10282                    Ok((
10283                        row.get(0)?,
10284                        row.get(1)?,
10285                        row.get(2)?,
10286                        row.get(3)?,
10287                        row.get(4)?,
10288                        row.get(5)?,
10289                        row.get(6)?,
10290                    ))
10291                },
10292            )
10293            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
10294        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
10295        if meta_count != 1 {
10296            return Err(invalid(format!(
10297                "_syncular_segment must contain exactly one row, found {meta_count}"
10298            )));
10299        }
10300        if format != 1 {
10301            return Err(invalid(format!("format {format}")));
10302        }
10303        if meta_table != table.name {
10304            return Err(invalid(format!("image table {meta_table:?}")));
10305        }
10306        if schema_version != i64::from(self.schema.version) {
10307            return Err(invalid(format!("schemaVersion {schema_version}")));
10308        }
10309        if pin != as_of_commit_seq {
10310            return Err(invalid(format!("asOfCommitSeq {pin}")));
10311        }
10312        if digest != scope_digest {
10313            return Err(invalid("scopeDigest mismatch".to_owned()));
10314        }
10315        if meta_rows != row_count {
10316            return Err(invalid(format!("rowCount {meta_rows}")));
10317        }
10318
10319        // 2. Column names and order vs the generated schema (§5.3 rule 3).
10320        let mut names: Vec<String> = Vec::new();
10321        let mut primary_keys: Vec<String> = Vec::new();
10322        {
10323            let mut stmt = img
10324                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
10325                .map_err(|_| invalid("image data table missing".to_owned()))?;
10326            let mut rows = stmt
10327                .query([])
10328                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
10329            while let Some(row) = rows
10330                .next()
10331                .map_err(|_| invalid("image data table unreadable".to_owned()))?
10332            {
10333                if row.get::<_, i64>(5).map_err(|e| invalid(e.to_string()))? > 0 {
10334                    primary_keys.push(row.get(1).map_err(|e| invalid(e.to_string()))?);
10335                }
10336                names.push(
10337                    row.get::<_, String>(1)
10338                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
10339                );
10340            }
10341        }
10342        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
10343        expected.push("_syncular_version");
10344        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
10345            return Err(SectionError::Abort(
10346                "sync.schema_mismatch".to_owned(),
10347                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
10348            ));
10349        }
10350
10351        if primary_keys != vec![table.primary_key.clone()] {
10352            return Err(invalid(
10353                "image primary key does not match generated schema".into(),
10354            ));
10355        }
10356        let actual_count: i64 = img
10357            .query_row(
10358                &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
10359                [],
10360                |row| row.get(0),
10361            )
10362            .map_err(|e| invalid(e.to_string()))?;
10363        if actual_count != row_count {
10364            return Err(invalid("image row count does not match descriptor".into()));
10365        }
10366        let insert = self.insert_row_sql(&base_table(&table.name), table);
10367        let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
10368        let column_list = names
10369            .iter()
10370            .map(|n| quote_ident(n))
10371            .collect::<Vec<_>>()
10372            .join(", ");
10373        let mut stmt = img
10374            .prepare(&format!(
10375                "SELECT {column_list} FROM {} ORDER BY {}",
10376                quote_ident(&table.name),
10377                quote_ident(&table.primary_key)
10378            ))
10379            .map_err(|e| invalid(e.to_string()))?;
10380        let mut rows = stmt.query([]).map_err(|e| invalid(e.to_string()))?;
10381        let mut applied = 0u32;
10382        loop {
10383            let clear = applied == 0 && first_fresh_page;
10384            let was_dirty = self.overlay_dirty.get();
10385            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
10386                && self
10387                    .outbox
10388                    .iter()
10389                    .flat_map(|commit| &commit.ops)
10390                    .any(|op| op.table == table.name);
10391            let mirror_visible = !reconcile_pending;
10392            self.begin_observation("syncular_image_chunk")
10393                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10394            let result = (|| {
10395                self.rebuild_overlay_if_dirty();
10396                let mut changed_keys = Vec::new();
10397                let mut batch = ChangeAccumulator::default();
10398                if clear {
10399                    if self.scoped_rows_exist(&table.name, effective) {
10400                        batch.table(&table.name);
10401                    }
10402                    self.purge_scope_rows(&table.name, effective)
10403                        .map_err(|()| SectionError::FailClosed)?;
10404                }
10405                let mut processed = 0u32;
10406                {
10407                    let mut ins = self
10408                        .conn
10409                        .prepare_cached(&insert)
10410                        .map_err(|e| invalid(e.to_string()))?;
10411                    let mut mirror = if mirror_visible {
10412                        Some(
10413                            self.conn
10414                                .prepare_cached(&visible_insert)
10415                                .map_err(|e| invalid(e.to_string()))?,
10416                        )
10417                    } else {
10418                        None
10419                    };
10420                    for _ in 0..1024 {
10421                        let Some(row) = rows.next().map_err(|e| invalid(e.to_string()))? else {
10422                            break;
10423                        };
10424                        for (i, column) in table.columns.iter().enumerate() {
10425                            let cell = row.get_ref(i).map_err(|e| invalid(e.to_string()))?;
10426                            let param = image_cell_param(column, cell).map_err(&invalid)?;
10427                            ins.raw_bind_parameter(i + 1, &param)
10428                                .map_err(|e| invalid(e.to_string()))?;
10429                            if let Some(mirror) = &mut mirror {
10430                                mirror
10431                                    .raw_bind_parameter(i + 1, &param)
10432                                    .map_err(|e| invalid(e.to_string()))?;
10433                            }
10434                        }
10435                        let version_index = table.columns.len();
10436                        let version: i64 =
10437                            row.get(version_index).map_err(|e| invalid(e.to_string()))?;
10438                        if version < 1 {
10439                            return Err(invalid("image row version must be positive".into()));
10440                        }
10441                        ins.raw_bind_parameter(version_index + 1, version)
10442                            .map_err(|e| invalid(e.to_string()))?;
10443                        ins.raw_execute().map_err(|e| invalid(e.to_string()))?;
10444                        if let Some(mirror) = &mut mirror {
10445                            mirror
10446                                .raw_bind_parameter(version_index + 1, version)
10447                                .map_err(|e| invalid(e.to_string()))?;
10448                            mirror.raw_execute().map_err(|e| invalid(e.to_string()))?;
10449                        }
10450                        if reconcile_pending {
10451                            changed_keys.push(
10452                                row.get::<_, SqlValue>(table.pk_index)
10453                                    .map_err(|error| invalid(error.to_string()))?,
10454                            );
10455                        }
10456                        processed += 1;
10457                    }
10458                }
10459                if processed > 0 {
10460                    batch.table(&table.name);
10461                    self.overlay_dirty.set(true);
10462                }
10463                if reconcile_pending {
10464                    self.reconcile_imported_rows(table, &mut changed_keys)
10465                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10466                }
10467                self.apply_outbox_ops(
10468                    self.outbox
10469                        .iter()
10470                        .flat_map(|commit| &commit.ops)
10471                        .filter(|op| op.table == table.name),
10472                );
10473                self.overlay_dirty.set(false);
10474                self.finish_observation("syncular_image_chunk", batch)
10475                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10476                Ok(processed)
10477            })();
10478            let processed = match result {
10479                Ok(processed) => processed,
10480                Err(error) => {
10481                    self.rollback_observation("syncular_image_chunk");
10482                    self.overlay_dirty.set(was_dirty);
10483                    return Err(error);
10484                }
10485            };
10486            applied += processed;
10487            self.progress
10488                .update(|p| p.rows_processed = u64::from(applied));
10489            std::thread::yield_now();
10490            if i64::from(applied) == actual_count {
10491                break;
10492            }
10493        }
10494        Ok(applied)
10495    }
10496
10497    /// Restore only imported and pending row identities before FIFO replay. All pending
10498    /// identities participate: a server row can free a unique value for a different edit.
10499    /// The caller owns the chunk transaction and publishes its revision after replay.
10500    fn reconcile_imported_rows(
10501        &self,
10502        table: &TableSchema,
10503        keys: &mut Vec<SqlValue>,
10504    ) -> Result<(), String> {
10505        let visible = visible_table(&table.name);
10506        let base = base_table(&table.name);
10507        let pk = quote_ident(&table.primary_key);
10508        let predicate = row_id_predicate(table);
10509        let mut lookup = self.conn.prepare_cached(&format!(
10510            "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
10511        )).map_err(|error| error.to_string())?;
10512        let mut deleted = HashSet::new();
10513        for op in self
10514            .outbox
10515            .iter()
10516            .flat_map(|commit| &commit.ops)
10517            .filter(|op| op.table == table.name)
10518        {
10519            if op.upsert {
10520                if let Some(values) = &op.values {
10521                    let key = json_to_column_value(
10522                        &table.columns[table.pk_index],
10523                        values.get(&table.primary_key),
10524                    )?;
10525                    keys.push(owned_sql_value(RowParam::Cell(&key))?);
10526                }
10527            } else if deleted.insert(&op.row_id) {
10528                keys.extend(
10529                    lookup
10530                        .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
10531                        .map_err(|error| error.to_string())?
10532                        .collect::<Result<Vec<_>, _>>()
10533                        .map_err(|error| error.to_string())?,
10534                );
10535            }
10536        }
10537        // Delete the complete affected set before restoring any base value, so
10538        // old optimistic unique values cannot collide with the new server rows.
10539        for chunk in keys.chunks(400) {
10540            let holes = vec!["?"; chunk.len()].join(",");
10541            self.conn
10542                .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
10543                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10544                .map_err(|error| error.to_string())?;
10545        }
10546        let updates = table
10547            .columns
10548            .iter()
10549            .map(|column| column.name.as_str())
10550            .chain(std::iter::once("_syncular_version"))
10551            .filter(|name| *name != table.primary_key)
10552            .map(|name| {
10553                let name = quote_ident(name);
10554                format!("{name}=excluded.{name}")
10555            })
10556            .collect::<Vec<_>>()
10557            .join(",");
10558        for chunk in keys.chunks(400) {
10559            let holes = vec!["?"; chunk.len()].join(",");
10560            self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
10561                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10562                .map_err(|error| error.to_string())?;
10563        }
10564        Ok(())
10565    }
10566
10567    // -- application-authorized local purge ---------------------------------------
10568
10569    fn compile_local_data_purge(
10570        &self,
10571        input: &LocalDataPurgeInput,
10572    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
10573        let invalid = |message: String| format!("sync.invalid_request: {message}");
10574        if input.purge_id.is_empty()
10575            || input.purge_id.len() > 128
10576            || !is_local_operation_code_like(&input.purge_id)
10577        {
10578            return Err(invalid(
10579                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
10580            ));
10581        }
10582        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
10583            return Err(invalid(format!(
10584                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
10585            )));
10586        }
10587
10588        let mut deduplicated: BTreeMap<
10589            String,
10590            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
10591        > = BTreeMap::new();
10592        for target in &input.targets {
10593            let table = self.schema.table(&target.table).ok_or_else(|| {
10594                invalid(format!(
10595                    "local purge names unknown table {:?}",
10596                    target.table
10597                ))
10598            })?;
10599            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
10600                return Err(invalid(format!(
10601                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
10602                    target.table
10603                )));
10604            }
10605            let mut selectors = Vec::with_capacity(target.selectors.len());
10606            let mut canonical_selectors = BTreeMap::new();
10607            for (column_name, raw_values) in &target.selectors {
10608                let Some((column_index, column)) = table
10609                    .columns
10610                    .iter()
10611                    .enumerate()
10612                    .find(|(_, column)| column.name == *column_name)
10613                else {
10614                    return Err(invalid(format!(
10615                        "local purge target {:?} names unknown column {:?}",
10616                        target.table, column_name
10617                    )));
10618                };
10619                let encrypted = table
10620                    .encrypted_columns
10621                    .iter()
10622                    .any(|candidate| candidate.index == column_index);
10623                if column.ty != ColumnType::String || encrypted {
10624                    return Err(invalid(format!(
10625                        "local purge selector {:?}.{:?} must be a plaintext string column",
10626                        target.table, column_name
10627                    )));
10628                }
10629                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
10630                    return Err(invalid(format!(
10631                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
10632                        target.table, column_name
10633                    )));
10634                }
10635                let mut values = raw_values.clone();
10636                values.sort();
10637                values.dedup();
10638                if values.iter().any(|value| {
10639                    value.is_empty()
10640                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
10641                        || !is_local_operation_code_like(value)
10642                }) {
10643                    return Err(invalid(format!(
10644                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
10645                    )));
10646                }
10647                selectors.push((column_name.clone(), values.clone()));
10648                canonical_selectors.insert(column_name.clone(), values);
10649            }
10650            let canonical = LocalDataPurgeTarget {
10651                table: target.table.clone(),
10652                selectors: canonical_selectors,
10653            };
10654            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
10655            deduplicated.insert(
10656                key,
10657                (
10658                    CompiledLocalDataPurgeTarget {
10659                        table: target.table.clone(),
10660                        selectors,
10661                    },
10662                    canonical,
10663                ),
10664            );
10665        }
10666        let targets = deduplicated
10667            .values()
10668            .map(|(compiled, _)| compiled.clone())
10669            .collect::<Vec<_>>();
10670        let canonical = deduplicated
10671            .values()
10672            .map(|(_, target)| target.clone())
10673            .collect::<Vec<_>>();
10674        let canonical_plan =
10675            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
10676        Ok((targets, canonical_plan))
10677    }
10678
10679    fn local_purge_base_row_ids(
10680        &self,
10681        targets: &[CompiledLocalDataPurgeTarget],
10682    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
10683        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10684        for target in targets {
10685            let table = self
10686                .schema
10687                .table(&target.table)
10688                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
10689            let mut clauses = Vec::with_capacity(target.selectors.len());
10690            let mut params = Vec::new();
10691            for (column, values) in &target.selectors {
10692                clauses.push(format!(
10693                    "{} IN ({})",
10694                    quote_ident(column),
10695                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
10696                ));
10697                params.extend(values.iter().cloned().map(SqlValue::Text));
10698            }
10699            let sql = format!(
10700                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
10701                quote_ident(&table.primary_key),
10702                base_table(&target.table),
10703                clauses.join(" AND ")
10704            );
10705            let mut statement = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
10706            let rows = statement
10707                .query_map(rusqlite::params_from_iter(params), |row| {
10708                    row.get::<_, String>(0)
10709                })
10710                .map_err(|error| error.to_string())?;
10711            let ids = by_table.entry(target.table.clone()).or_default();
10712            for row in rows {
10713                ids.insert(row.map_err(|error| error.to_string())?);
10714            }
10715        }
10716        Ok(by_table)
10717    }
10718
10719    fn local_purge_values_match(
10720        target: &CompiledLocalDataPurgeTarget,
10721        values: &Map<String, Value>,
10722    ) -> bool {
10723        target.selectors.iter().all(|(column, allowed)| {
10724            values
10725                .get(column)
10726                .and_then(Value::as_str)
10727                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
10728        })
10729    }
10730
10731    /// Apply one host-authorized local security purge. The host MUST gate the
10732    /// corresponding subscriptions first; this primitive owns local SQLite
10733    /// cleanup and never grants/revokes server authority by itself.
10734    pub fn purge_local_data(
10735        &mut self,
10736        input: &LocalDataPurgeInput,
10737    ) -> Result<LocalDataPurgeResult, String> {
10738        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
10739        let meta_key = format!("localPurge:{}", input.purge_id);
10740        let applied_plan = self
10741            .conn
10742            .query_row(
10743                "SELECT value FROM _syncular_meta WHERE key = ?1",
10744                rusqlite::params![meta_key],
10745                |row| row.get::<_, String>(0),
10746            )
10747            .optional()
10748            .map_err(|error| error.to_string())?;
10749        if let Some(applied_plan) = applied_plan {
10750            if applied_plan != canonical_plan {
10751                return Err(format!(
10752                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
10753                    input.purge_id
10754                ));
10755            }
10756            return Ok(LocalDataPurgeResult {
10757                already_applied: true,
10758                purged_rows: 0,
10759                dropped_commits: 0,
10760            });
10761        }
10762
10763        // RFC 0005 D7: the container file cannot be a purge target (its name is
10764        // not in the running schema), so purge drops it as a fixed step, before
10765        // any mirror row is touched and unconditionally.
10766        let _ = self.drop_previous_version();
10767
10768        let prior_outbox = self.outbox.clone();
10769        let prior_rejection_count = self.rejections.len();
10770        let prior_overlay_dirty = self.overlay_dirty.get();
10771        self.begin_observation("syncular_local_purge")?;
10772        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
10773            let row_ids = self.local_purge_base_row_ids(&targets)?;
10774            let doomed = self
10775                .outbox
10776                .iter()
10777                .filter(|commit| {
10778                    commit.ops.iter().any(|operation| {
10779                        let matching_targets = targets
10780                            .iter()
10781                            .filter(|target| target.table == operation.table)
10782                            .collect::<Vec<_>>();
10783                        if matching_targets.is_empty() {
10784                            return false;
10785                        }
10786                        if row_ids
10787                            .get(&operation.table)
10788                            .is_some_and(|ids| ids.contains(&operation.row_id))
10789                        {
10790                            return true;
10791                        }
10792                        operation.values.as_ref().is_some_and(|values| {
10793                            matching_targets
10794                                .iter()
10795                                .any(|target| Self::local_purge_values_match(target, values))
10796                        })
10797                    })
10798                })
10799                .cloned()
10800                .collect::<Vec<_>>();
10801            let doomed_ids = doomed
10802                .iter()
10803                .map(|commit| commit.client_commit_id.clone())
10804                .collect::<BTreeSet<_>>();
10805            let mut batch = ChangeAccumulator::default();
10806            let mut rejections = Vec::new();
10807            for commit in &doomed {
10808                for operation in &commit.ops {
10809                    batch.table(&operation.table);
10810                }
10811                let results = commit
10812                    .ops
10813                    .iter()
10814                    .enumerate()
10815                    .map(|(op_index, operation)| {
10816                        let rejection = RejectionRecord {
10817                            client_commit_id: commit.client_commit_id.clone(),
10818                            op_index: op_index as i32,
10819                            code: "client.local_data_purged".to_owned(),
10820                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
10821                            retryable: false,
10822                            details: None,
10823                            operation: Some(CommitOperation::from(operation)),
10824                        };
10825                        rejections.push(rejection.clone());
10826                        CommitOperationOutcome::Error { rejection }
10827                    })
10828                    .collect::<Vec<_>>();
10829                self.persist_commit_outcome(
10830                    &commit.client_commit_id,
10831                    CommitOutcomeStatus::Rejected,
10832                    &results,
10833                    Some(&commit.ops),
10834                )?;
10835                self.delete_outbox_persisted(&commit.client_commit_id)?;
10836            }
10837            if !doomed.is_empty() {
10838                self.outbox
10839                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
10840                self.rejections.extend(rejections);
10841                self.prune_commit_outcomes()?;
10842                self.overlay_dirty.set(true);
10843                batch.status = true;
10844                batch.rejections = true;
10845                batch.outcomes = true;
10846            }
10847
10848            let mut purged_rows = 0usize;
10849            for (table, ids) in &row_ids {
10850                if ids.is_empty() {
10851                    continue;
10852                }
10853                batch.table(table);
10854                for row_id in ids {
10855                    self.delete_base_row(table, row_id)?;
10856                    purged_rows += 1;
10857                }
10858            }
10859            self.rebuild_overlay_if_dirty();
10860            self.delete_unreferenced_cached_blobs();
10861            self.conn
10862                .execute(
10863                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
10864                    rusqlite::params![meta_key, canonical_plan],
10865                )
10866                .map_err(|error| error.to_string())?;
10867            Ok((
10868                batch,
10869                LocalDataPurgeResult {
10870                    already_applied: false,
10871                    purged_rows,
10872                    dropped_commits: doomed.len(),
10873                },
10874            ))
10875        })();
10876        let (batch, result) = match applied {
10877            Ok(value) => value,
10878            Err(error) => {
10879                self.rollback_observation("syncular_local_purge");
10880                self.outbox = prior_outbox;
10881                self.rejections.truncate(prior_rejection_count);
10882                self.overlay_dirty.set(prior_overlay_dirty);
10883                return Err(error);
10884            }
10885        };
10886        if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
10887            self.rollback_observation("syncular_local_purge");
10888            self.outbox = prior_outbox;
10889            self.rejections.truncate(prior_rejection_count);
10890            self.overlay_dirty.set(prior_overlay_dirty);
10891            return Err(error);
10892        }
10893        Ok(result)
10894    }
10895
10896    /// Application-authorized recovery of the replicated projection. Unlike a
10897    /// local security purge, this retains the entire outbox, device identity,
10898    /// lease, outcomes, subscription registrations, and protected bookkeeping.
10899    /// The projection reset, subscription rewind, optimistic replay, and
10900    /// idempotency marker share one savepoint for interruption safety.
10901    pub fn rebootstrap_local_data(
10902        &mut self,
10903        input: &LocalDataRebootstrapInput,
10904    ) -> Result<LocalDataRebootstrapResult, String> {
10905        if input.rebootstrap_id.is_empty()
10906            || input.rebootstrap_id.len() > 128
10907            || !is_local_operation_code_like(&input.rebootstrap_id)
10908        {
10909            return Err(
10910                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
10911                    .to_owned(),
10912            );
10913        }
10914        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
10915        if let Some(persisted) = self.get_meta_strict(&meta_key)? {
10916            let (retained_commits, reset_subscriptions) =
10917                decode_local_rebootstrap_receipt(&persisted)?;
10918            return Ok(LocalDataRebootstrapResult {
10919                already_applied: true,
10920                retained_commits,
10921                reset_subscriptions,
10922            });
10923        }
10924        if self.stopped || self.schema_floor.is_some() {
10925            return Err(
10926                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
10927                    .to_owned(),
10928            );
10929        }
10930
10931        let retained_commits = self.outbox.len();
10932        let reset_subscriptions = self.subs.len();
10933        let prior_subs = self.subs.clone();
10934        let prior_upgrading = self.upgrading;
10935        let prior_stopped = self.stopped;
10936        let prior_schema_floor = self.schema_floor.clone();
10937        let prior_overlay_dirty = self.overlay_dirty.get();
10938        let prior_sync_needed = self.sync_needed;
10939        let prior_sync_intents = self.sync_intent_queue.clone();
10940        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
10941
10942        self.begin_observation("syncular_local_rebootstrap")?;
10943        let mut batch = ChangeAccumulator::default();
10944        let applied = (|| -> Result<(), String> {
10945            self.run_schema_reset_observed(&mut batch, false, false)?;
10946            self.conn
10947                .execute(
10948                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
10949                    rusqlite::params![meta_key, receipt],
10950                )
10951                .map_err(|error| error.to_string())?;
10952            self.sync_needed = true;
10953            self.sync_intent_queue.push_back(SyncIntent::Interactive);
10954            batch.status = true;
10955            Ok(())
10956        })();
10957        if let Err(error) = applied {
10958            self.rollback_observation("syncular_local_rebootstrap");
10959            self.subs = prior_subs;
10960            self.upgrading = prior_upgrading;
10961            self.stopped = prior_stopped;
10962            self.schema_floor = prior_schema_floor;
10963            self.overlay_dirty.set(prior_overlay_dirty);
10964            self.sync_needed = prior_sync_needed;
10965            self.sync_intent_queue = prior_sync_intents;
10966            return Err(error);
10967        }
10968        if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
10969            self.rollback_observation("syncular_local_rebootstrap");
10970            self.subs = prior_subs;
10971            self.upgrading = prior_upgrading;
10972            self.stopped = prior_stopped;
10973            self.schema_floor = prior_schema_floor;
10974            self.overlay_dirty.set(prior_overlay_dirty);
10975            self.sync_needed = prior_sync_needed;
10976            self.sync_intent_queue = prior_sync_intents;
10977            return Err(error);
10978        }
10979
10980        Ok(LocalDataRebootstrapResult {
10981            already_applied: false,
10982            retained_commits,
10983            reset_subscriptions,
10984        })
10985    }
10986
10987    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
10988
10989    /// Delete base rows matching the effective scopes; `Err(())` = no local
10990    /// scope-column mapping for a key (the fail-closed case).
10991    fn purge_scope_rows(
10992        &mut self,
10993        table_name: &str,
10994        effective: &[(String, Vec<String>)],
10995    ) -> Result<(), ()> {
10996        if effective.is_empty() {
10997            return Ok(());
10998        }
10999        let table = self.schema.table(table_name).ok_or(())?.clone();
11000        let mut clauses = Vec::new();
11001        let mut params: Vec<SqlValue> = Vec::new();
11002        for (variable, values) in effective {
11003            let column = table.scope_column(variable).ok_or(())?;
11004            let placeholders: Vec<String> = values
11005                .iter()
11006                .map(|v| {
11007                    params.push(SqlValue::Text(v.clone()));
11008                    "?".to_owned()
11009                })
11010                .collect();
11011            clauses.push(format!(
11012                "{} IN ({})",
11013                quote_ident(column),
11014                placeholders.join(", ")
11015            ));
11016        }
11017        let sql = format!(
11018            "DELETE FROM {} WHERE {}",
11019            base_table(table_name),
11020            clauses.join(" AND ")
11021        );
11022        self.overlay_dirty.set(true);
11023        self.conn
11024            .execute(&sql, rusqlite::params_from_iter(&params))
11025            .map_err(|_| ())?;
11026        self.conn
11027            .execute(
11028                &format!(
11029                    "DELETE FROM {} WHERE {}",
11030                    visible_table(table_name),
11031                    clauses.join(" AND ")
11032                ),
11033                rusqlite::params_from_iter(&params),
11034            )
11035            .map_err(|_| ())?;
11036        Ok(())
11037    }
11038
11039    /// §3.3: drop pending commits whose upserts provably land in the
11040    /// revoked effective scopes — whole-commit, never per-operation.
11041    fn drop_doomed_outbox(
11042        &mut self,
11043        table_name: &str,
11044        effective: &[(String, Vec<String>)],
11045    ) -> Result<bool, String> {
11046        if effective.is_empty() {
11047            return Ok(false);
11048        }
11049        let Some(table) = self.schema.table(table_name).cloned() else {
11050            return Ok(false);
11051        };
11052        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
11053        for (variable, values) in effective {
11054            match table.scope_column(variable) {
11055                Some(column) => mappings.push((column, values)),
11056                None => return Ok(false), // not provable without a mapping
11057            }
11058        }
11059        let doomed: Vec<OutboxCommit> = self
11060            .outbox
11061            .iter()
11062            .filter(|commit| {
11063                commit.ops.iter().any(|op| {
11064                    op.upsert
11065                        && op.table == table_name
11066                        && op.values.as_ref().is_some_and(|values| {
11067                            mappings
11068                                .iter()
11069                                .all(|(column, allowed)| match values.get(*column) {
11070                                    Some(Value::String(s)) => allowed.contains(s),
11071                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
11072                                    _ => false,
11073                                })
11074                        })
11075                })
11076            })
11077            .cloned()
11078            .collect();
11079        if doomed.is_empty() {
11080            return Ok(false);
11081        }
11082        let mut rejections = Vec::new();
11083        for commit in &doomed {
11084            let results = commit
11085                .ops
11086                .iter()
11087                .enumerate()
11088                .map(|(op_index, operation)| {
11089                    let rejection = RejectionRecord {
11090                        client_commit_id: commit.client_commit_id.clone(),
11091                        op_index: op_index as i32,
11092                        code: "sync.scope_revoked".to_owned(),
11093                        message: "the commit was dropped because its effective scope was revoked"
11094                            .to_owned(),
11095                        retryable: false,
11096                        details: None,
11097                        operation: Some(CommitOperation::from(operation)),
11098                    };
11099                    rejections.push(rejection.clone());
11100                    CommitOperationOutcome::Error { rejection }
11101                })
11102                .collect::<Vec<_>>();
11103            self.persist_commit_outcome(
11104                &commit.client_commit_id,
11105                CommitOutcomeStatus::Rejected,
11106                &results,
11107                Some(&commit.ops),
11108            )?;
11109            self.delete_outbox_persisted(&commit.client_commit_id)?;
11110        }
11111        self.prune_commit_outcomes()?;
11112        let doomed_ids = doomed
11113            .iter()
11114            .map(|commit| commit.client_commit_id.as_str())
11115            .collect::<BTreeSet<_>>();
11116        self.outbox
11117            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
11118        self.rejections.extend(rejections);
11119        self.overlay_dirty.set(true);
11120        Ok(true)
11121    }
11122
11123    // -- blobs (§5.9) ----------------------------------------------------------------
11124
11125    /// §5.9.7: hash bytes into the content address, cache them, queue the
11126    /// upload (flushed before the next push, B4). Returns the canonical
11127    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
11128    /// column value.
11129    pub fn upload_blob(
11130        &mut self,
11131        bytes: &[u8],
11132        media_type: Option<String>,
11133        name: Option<String>,
11134    ) -> Result<Value, String> {
11135        let blob_id = blob_id_for(bytes);
11136        let now = self.clock_now_ms();
11137        let tx = self.conn.transaction().map_err(|e| e.to_string())?;
11138        tx.execute(
11139            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
11140             ON CONFLICT(blob_id) DO NOTHING",
11141            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
11142        )
11143        .map_err(|e| e.to_string())?;
11144        tx.execute(
11145            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
11146            rusqlite::params![blob_id, media_type, now],
11147        )
11148        .map_err(|e| e.to_string())?;
11149        tx.commit().map_err(|e| e.to_string())?;
11150        self.enforce_blob_cache_cap();
11151        let mut obj = Map::new();
11152        obj.insert("blobId".to_owned(), Value::from(blob_id));
11153        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
11154        if let Some(mt) = media_type {
11155            obj.insert("mediaType".to_owned(), Value::from(mt));
11156        }
11157        if let Some(n) = name {
11158            obj.insert("name".to_owned(), Value::from(n));
11159        }
11160        Ok(Value::Object(obj))
11161    }
11162
11163    /// §5.9.7: resolve an owned blob body without driver encoding. A
11164    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
11165    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
11166    pub fn fetch_blob_bytes(
11167        &mut self,
11168        transport: &mut dyn Transport,
11169        blob_id_or_ref: &str,
11170    ) -> Result<FetchedBlob, (String, String)> {
11171        let simple = |m: String| ("client.failed".to_owned(), m);
11172        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
11173            blob_id_or_ref.to_owned()
11174        } else {
11175            let value: Value = serde_json::from_str(blob_id_or_ref)
11176                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
11177            value
11178                .get("blobId")
11179                .and_then(Value::as_str)
11180                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
11181                .to_owned()
11182        };
11183        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
11184            return Ok(cached);
11185        }
11186        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
11187        // blob.not_found) verbatim so the harness can assert on it. The
11188        // authorized endpoint serves bytes inline OR (always-issue, presign
11189        // configured) a signed url the client fetches directly — no host auth,
11190        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
11191        #[cfg(feature = "bench-internals")]
11192        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
11193        let bytes = match transport
11194            .blob_download(&blob_id)
11195            .map_err(|e| (e.code, e.message))?
11196        {
11197            BlobDownload::Bytes(bytes) => bytes,
11198            BlobDownload::Url {
11199                url,
11200                url_expires_at_ms,
11201            } => {
11202                // §5.9.5: MUST NOT start a fetch at/past expiry.
11203                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
11204                    return Err((
11205                        "sync.segment_expired".to_owned(),
11206                        format!(
11207                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
11208                        ),
11209                    ));
11210                }
11211                transport
11212                    .fetch_blob_url(&url)
11213                    .map_err(|e| (e.code, e.message))?
11214            }
11215        };
11216        #[cfg(feature = "bench-internals")]
11217        drop(download_phase);
11218        #[cfg(feature = "bench-internals")]
11219        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
11220        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
11221        if blob_id_for(&bytes) != blob_id {
11222            return Err(simple(format!(
11223                "blob content address mismatch for {blob_id}"
11224            )));
11225        }
11226        #[cfg(feature = "bench-internals")]
11227        drop(validate_phase);
11228        #[cfg(feature = "bench-internals")]
11229        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
11230        let now = self.clock_now_ms();
11231        self.conn
11232            .execute(
11233                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
11234                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
11235            )
11236            .map_err(|e| simple(e.to_string()))?;
11237        self.conn
11238            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
11239            .map_err(|e| simple(e.to_string()))?;
11240        self.enforce_blob_cache_cap();
11241        #[cfg(feature = "bench-internals")]
11242        drop(insert_phase);
11243        self.get_cached_blob_bytes(&blob_id)
11244            .map_err(simple)?
11245            .ok_or_else(|| simple("blob cache write failed".to_owned()))
11246    }
11247
11248    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
11249        #[cfg(feature = "bench-internals")]
11250        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
11251        let mut stmt = self
11252            .conn
11253            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
11254            .map_err(|e| e.to_string())?;
11255        let mut rows = stmt
11256            .query(rusqlite::params![blob_id])
11257            .map_err(|e| e.to_string())?;
11258        if let Some(row) = rows.next().map_err(|e| e.to_string())? {
11259            let bytes: Vec<u8> = row.get(0).map_err(|e| e.to_string())?;
11260            let byte_length: i64 = row.get(1).map_err(|e| e.to_string())?;
11261            let media_type: Option<String> = row.get(2).map_err(|e| e.to_string())?;
11262            return Ok(Some(FetchedBlob {
11263                blob_id: blob_id.to_owned(),
11264                byte_length,
11265                bytes,
11266                media_type,
11267            }));
11268        }
11269        Ok(None)
11270    }
11271
11272    /// §5.9.7 B4: upload every queued blob before push.
11273    fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
11274        let pending: Vec<(String, Option<String>)> = {
11275            let mut stmt = self
11276                .conn
11277                .prepare(
11278                    "SELECT blob_id, media_type FROM (
11279                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
11280                      UNION ALL
11281                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
11282                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
11283                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
11284                    ) ORDER BY created_at_ms, blob_id",
11285                )
11286                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11287            let rows = stmt
11288                .query_map([], |row| {
11289                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
11290                })
11291                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11292            rows.collect::<Result<_, _>>()
11293                .map_err(|error| match error {
11294                    rusqlite::Error::InvalidColumnType(..)
11295                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
11296                        "sync.local_corrupt",
11297                        "Pending blob upload metadata is invalid",
11298                    ),
11299                    _ => TransportError::new("client.failed", error.to_string()),
11300                })?
11301        };
11302        for (blob_id, media_type) in pending {
11303            let stored: Option<(SqlValue, SqlValue)> = self
11304                .conn
11305                .query_row(
11306                    "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
11307                    rusqlite::params![blob_id],
11308                    |row| Ok((row.get(0)?, row.get(1)?)),
11309                )
11310                .optional()
11311                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11312            let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
11313                return Err(TransportError::new(
11314                    "sync.local_corrupt",
11315                    "Pending blob upload body is missing or corrupt",
11316                ));
11317            };
11318            if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
11319                return Err(TransportError::new(
11320                    "sync.local_corrupt",
11321                    "Pending blob upload body is missing or corrupt",
11322                ));
11323            }
11324            self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
11325            self.conn
11326                .execute(
11327                    "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
11328                    rusqlite::params![blob_id],
11329                )
11330                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11331        }
11332        Ok(())
11333    }
11334
11335    /// §5.9.3: upload one blob, preferring the presigned direct-to-storage
11336    /// grant when the transport supports it, else streaming through the direct
11337    /// host-authenticated endpoint (capability, not fallback). A `Url` grant
11338    /// PUTs direct with no host auth; on a grant PUT failure the client streams
11339    /// through the direct endpoint — a *different, host-authenticated
11340    /// capability*, not a fall-through of the grant's authority.
11341    fn upload_one(
11342        &self,
11343        transport: &mut dyn Transport,
11344        blob_id: &str,
11345        bytes: &[u8],
11346        media_type: Option<&str>,
11347    ) -> Result<(), TransportError> {
11348        match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
11349            BlobUploadGrant::Present => return Ok(()), // idempotent §5.9.3
11350            BlobUploadGrant::Url {
11351                url,
11352                url_expires_at_ms,
11353            } => {
11354                let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
11355                if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
11356                    return Ok(());
11357                }
11358                // Failed/expired grant PUT — stream through the direct endpoint.
11359            }
11360            BlobUploadGrant::None => {
11361                // No presign store — stream through the direct endpoint.
11362            }
11363        }
11364        transport.blob_upload(blob_id, bytes, media_type)
11365    }
11366
11367    fn visible_blob_ids_sql(&self) -> String {
11368        let mut selects = Vec::new();
11369        for table in &self.schema.tables {
11370            for column in table
11371                .columns
11372                .iter()
11373                .filter(|column| column.ty == ColumnType::BlobRef)
11374            {
11375                let identifier = quote_ident(&column.name);
11376                selects.push(format!(
11377                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
11378                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
11379                     AND json_type({identifier}, '$.blobId') = 'text'",
11380                    quote_ident(&table.name),
11381                ));
11382            }
11383        }
11384        if selects.is_empty() {
11385            "SELECT NULL AS blob_id WHERE 0".to_owned()
11386        } else {
11387            selects.join(" UNION ")
11388        }
11389    }
11390
11391    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
11392    /// staged uploads pin bodies directly; no derived refcount is stored.
11393    fn enforce_blob_cache_cap(&self) {
11394        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
11395            return;
11396        };
11397        let mut total: i64 = self
11398            .conn
11399            .query_row(
11400                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
11401                [],
11402                |row| row.get(0),
11403            )
11404            .unwrap_or(0);
11405        if total <= max_bytes {
11406            return;
11407        }
11408        let query = format!(
11409            "SELECT blob_id, byte_length FROM _syncular_blobs
11410             WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
11411               AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
11412               AND blob_id NOT IN ({})
11413             ORDER BY created_at_ms ASC, blob_id ASC",
11414            self.visible_blob_ids_sql(),
11415        );
11416        let candidates: Vec<(String, i64)> = {
11417            let Ok(mut stmt) = self.conn.prepare(&query) else {
11418                return;
11419            };
11420            let Ok(rows) = stmt.query_map([], |row| {
11421                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
11422            }) else {
11423                return;
11424            };
11425            rows.filter_map(Result::ok).collect()
11426        };
11427        for (blob_id, byte_length) in candidates {
11428            if total <= max_bytes {
11429                break;
11430            }
11431            let _ = self.conn.execute(
11432                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
11433                rusqlite::params![blob_id],
11434            );
11435            total -= byte_length;
11436        }
11437    }
11438
11439    fn delete_unreferenced_cached_blobs(&mut self) {
11440        #[cfg(feature = "bench-internals")]
11441        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
11442        if !self.schema_has_blobs() {
11443            return;
11444        }
11445        self.rebuild_overlay_if_dirty();
11446        let _ = self.conn.execute(
11447            &format!(
11448                "DELETE FROM _syncular_blobs
11449                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
11450                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
11451                   AND blob_id NOT IN ({})",
11452                self.visible_blob_ids_sql(),
11453            ),
11454            [],
11455        );
11456    }
11457
11458    // -- local row storage ----------------------------------------------------------
11459
11460    /// The cached per-table primary-key upsert SQL for `full_table` (see
11461    /// the `insert_sql` field: built once, reused per row).
11462    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
11463        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
11464            return sql.clone();
11465        }
11466        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
11467        columns.push(quote_ident("_syncular_version"));
11468        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
11469        let primary_key = quote_ident(&table.primary_key);
11470        let updates = columns
11471            .iter()
11472            .filter(|column| **column != primary_key)
11473            .map(|column| format!("{column}=excluded.{column}"))
11474            .collect::<Vec<_>>()
11475            .join(", ");
11476        let sql = format!(
11477            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
11478            columns.join(", "),
11479            placeholders.join(", ")
11480        );
11481        self.insert_sql
11482            .borrow_mut()
11483            .insert(full_table.to_owned(), sql.clone());
11484        sql
11485    }
11486
11487    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
11488        self.overlay_dirty.set(true);
11489        self.write_row(&base_table(table_name), table_name, row, version)
11490    }
11491
11492    fn write_row(
11493        &self,
11494        full_table: &str,
11495        table_name: &str,
11496        row: &Row,
11497        version: i64,
11498    ) -> Result<(), String> {
11499        #[cfg(feature = "bench-internals")]
11500        let _phase = self.benchmark_phases.start(Phase::RowWrite);
11501        let table = self
11502            .schema
11503            .table(table_name)
11504            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
11505        let sql = self.insert_row_sql(full_table, table);
11506        let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
11507        let params = row
11508            .iter()
11509            .map(RowParam::Cell)
11510            .chain(std::iter::once(RowParam::Version(version)));
11511        stmt.execute(rusqlite::params_from_iter(params))
11512            .map_err(|e| e.to_string())?;
11513        Ok(())
11514    }
11515
11516    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
11517        let table = self
11518            .schema
11519            .table(table_name)
11520            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
11521        self.overlay_dirty.set(true);
11522        let sql = format!(
11523            "DELETE FROM {} WHERE {}",
11524            base_table(table_name),
11525            row_id_predicate(table)
11526        );
11527        let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
11528        stmt.execute(rusqlite::params![row_id])
11529            .map_err(|e| e.to_string())?;
11530        Ok(())
11531    }
11532
11533    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
11534    /// the outbox changed since the last rebuild (the no-op sync round).
11535    fn rebuild_overlay_if_dirty(&mut self) {
11536        if self.overlay_dirty.get() {
11537            self.rebuild_overlay();
11538        }
11539    }
11540
11541    /// §7.1: local reads see outbox state applied optimistically — rebuild
11542    /// every visible table as (base server state) + (pending outbox replay
11543    /// on top). Optimistic rows carry version `-1`.
11544    fn rebuild_overlay(&mut self) {
11545        #[cfg(feature = "bench-internals")]
11546        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
11547        #[cfg(test)]
11548        self.overlay_rebuild_count
11549            .set(self.overlay_rebuild_count.get() + 1);
11550        self.exec("SAVEPOINT syncular_overlay");
11551        for table in self.schema.tables.clone() {
11552            for index in &table.fts_indexes {
11553                let _ = self.drop_fts_triggers(index);
11554            }
11555            let visible = visible_table(&table.name);
11556            let base = base_table(&table.name);
11557            self.exec(&format!("DELETE FROM {visible}"));
11558            self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
11559        }
11560        for commit in &self.outbox {
11561            self.apply_outbox_ops(&commit.ops);
11562        }
11563        for table in self.schema.tables.clone() {
11564            for index in &table.fts_indexes {
11565                let _ = self.rebuild_fts_projection(&table, index);
11566                let _ = self.create_fts_triggers(&table, index);
11567            }
11568        }
11569        self.exec("RELEASE syncular_overlay");
11570        self.overlay_dirty.set(false);
11571    }
11572
11573    /// §7.1 replay: every operation is applied over the current visible
11574    /// state. An operation that no longer applies (a server row now holds its
11575    /// unique value) stays pending and invisible until a later replay admits
11576    /// it or the server answers its push.
11577    fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
11578        #[cfg(feature = "bench-internals")]
11579        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
11580        for op in ops {
11581            #[cfg(feature = "bench-internals")]
11582            if let Some(phase) = &mut phase {
11583                phase.unit();
11584            }
11585            let _ = self.apply_outbox_op(op);
11586        }
11587    }
11588
11589    fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
11590        let Some(table) = self.schema.table(&op.table) else {
11591            return Ok(());
11592        };
11593        let visible = visible_table(&table.name);
11594        if !op.upsert {
11595            let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
11596            self.conn
11597                .prepare_cached(&sql)
11598                .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
11599                .map_err(|e| e.to_string())?;
11600            return Ok(());
11601        }
11602        let Some(values) = op.values.as_ref() else {
11603            return Ok(());
11604        };
11605        // §7.1: the overlay applies the operation's PRESENT columns
11606        // over the current local row. A partial operation over an
11607        // absent local row leaves it absent — the server answers it
11608        // with sync.row_deleted or sync.row_missing (§6.2).
11609        let full = table
11610            .columns
11611            .iter()
11612            .all(|column| values.contains_key(&column.name));
11613        let local = self.visible_row(table, &op.row_id);
11614        if local.is_none() && !full {
11615            return Ok(());
11616        }
11617        let mut row: Row = Vec::with_capacity(table.columns.len());
11618        for (index, column) in table.columns.iter().enumerate() {
11619            let incoming = match values.get(&column.name) {
11620                Some(raw) => json_to_column_value(column, Some(raw)),
11621                None => Ok(local
11622                    .as_ref()
11623                    .and_then(|(values, _)| values.get(index).cloned())
11624                    .flatten()),
11625            };
11626            match incoming {
11627                Ok(value) => row.push(value),
11628                Err(_) => return Ok(()),
11629            }
11630        }
11631        let version = local.as_ref().map_or(-1, |(_, version)| *version);
11632        self.write_row(&visible, &table.name, &row, version)
11633            .map_err(|error| {
11634                if error.starts_with("UNIQUE constraint failed:") {
11635                    "sync.constraint_violation: local write violates a unique constraint".to_owned()
11636                } else {
11637                    error
11638                }
11639            })
11640    }
11641
11642    /// The visible (optimistic) row's values plus version, or `None` when the
11643    /// row is absent from the overlay.
11644    fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
11645        let sql = format!(
11646            "SELECT * FROM {} WHERE {}",
11647            visible_table(&table.name),
11648            row_id_predicate(table)
11649        );
11650        let mut stmt = self.conn.prepare_cached(&sql).ok()?;
11651        let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
11652        let row = rows.next().ok()??;
11653        let mut values = Vec::with_capacity(table.columns.len());
11654        for (index, column) in table.columns.iter().enumerate() {
11655            let raw = row.get_ref(index).ok()?;
11656            let json = sql_ref_to_json(column, raw);
11657            values.push(json_to_column_value(column, Some(&json)).ok()?);
11658        }
11659        let version: i64 = row.get(table.columns.len()).ok()?;
11660        Some((values, version))
11661    }
11662
11663    fn exec(&self, sql: &str) {
11664        let _ = self.conn.execute_batch(sql);
11665    }
11666
11667    // -- realtime (§8) ---------------------------------------------------------------
11668
11669    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
11670        if self.realtime_connected {
11671            return Ok(());
11672        }
11673        transport
11674            .realtime_connect_for_client(&self.client_id)
11675            .map_err(|e| format!("{}: {}", e.code, e.message))?;
11676        self.realtime_connected = true;
11677        Ok(())
11678    }
11679
11680    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
11681        if !self.realtime_connected {
11682            return;
11683        }
11684        let _ = transport.realtime_close();
11685        self.realtime_connected = false;
11686        self.presence.clear(); // §8.6.1: presence is per-connection
11687    }
11688
11689    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
11690    /// document for `scope_key`. Requires a live socket; the document is
11691    /// ephemeral (lost on disconnect). Authorization is the connection's
11692    /// registration (§8.6.3) — an unheld key is rejected loudly by the
11693    /// server with `presence.forbidden`.
11694    pub fn set_presence(
11695        &mut self,
11696        transport: &mut dyn Transport,
11697        scope_key: &str,
11698        doc: Option<&Value>,
11699    ) -> Result<(), String> {
11700        if !self.realtime_connected {
11701            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
11702        }
11703        let text = encode_presence_publish(scope_key, doc);
11704        transport
11705            .realtime_send(&text)
11706            .map_err(|e| format!("{}: {}", e.code, e.message))
11707    }
11708
11709    /// §8.6: the peers currently present on a scope key (ephemeral).
11710    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
11711        self.presence
11712            .get(scope_key)
11713            .map(|peers| peers.values().cloned().collect())
11714            .unwrap_or_default()
11715    }
11716
11717    /// §8.6 apply an inbound presence fanout to the local map.
11718    fn apply_presence(
11719        &mut self,
11720        scope_key: String,
11721        kind: Option<PresenceKind>,
11722        actor_id: Option<String>,
11723        client_id: Option<String>,
11724        doc: Option<Value>,
11725        error: Option<String>,
11726    ) {
11727        // The publisher-directed error variant is out-of-band; nothing to
11728        // record in the peer map.
11729        if error.is_some() {
11730            return;
11731        }
11732        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
11733            return;
11734        };
11735        let peer_key = format!("{actor_id} {client_id}");
11736        match kind {
11737            PresenceKind::Leave => {
11738                if let Some(peers) = self.presence.get_mut(&scope_key) {
11739                    peers.remove(&peer_key);
11740                    if peers.is_empty() {
11741                        self.presence.remove(&scope_key);
11742                    }
11743                }
11744            }
11745            _ => {
11746                let doc = match doc {
11747                    Some(Value::Object(_)) => doc.unwrap(),
11748                    _ => return,
11749                };
11750                self.presence.entry(scope_key).or_default().insert(
11751                    peer_key,
11752                    PresencePeer {
11753                        actor_id,
11754                        client_id,
11755                        doc,
11756                    },
11757                );
11758            }
11759        }
11760    }
11761
11762    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
11763    pub fn on_realtime_text(&mut self, text: &str) {
11764        match parse_control(text) {
11765            Ok(ControlMessage::Hello { requires_sync, .. }) => {
11766                if requires_sync {
11767                    // §8.1: pull before trusting the socket for continuity.
11768                    self.set_sync_needed(true, true);
11769                }
11770            }
11771            Ok(ControlMessage::Presence {
11772                scope_key,
11773                kind,
11774                actor_id,
11775                client_id,
11776                doc,
11777                error,
11778                ..
11779            }) => {
11780                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
11781            }
11782            Ok(ControlMessage::Wake { .. }) => {
11783                // §8.3: any wake-up means "run a pull soon", never data.
11784                self.set_sync_needed(true, true);
11785            }
11786            _ => {}
11787        }
11788    }
11789
11790    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
11791    /// a pull response per section; an unapplied delta is a wake-up.
11792    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
11793        if self.stopped {
11794            return;
11795        }
11796        #[cfg(feature = "bench-internals")]
11797        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
11798        let message = match decode_message(bytes) {
11799            Ok(m) if m.msg_kind == MsgKind::Response => m,
11800            _ => {
11801                self.set_sync_needed(true, true);
11802                return;
11803            }
11804        };
11805        #[cfg(feature = "bench-internals")]
11806        drop(decode_phase);
11807        let mut frames = message.frames.into_iter();
11808        let mut applied_cursor: Option<i64> = None;
11809        let mut any_covered = false;
11810        let mut dropped = false;
11811        while let Some(frame) = frames.next() {
11812            let Frame::SubStart {
11813                id,
11814                status,
11815                effective_scopes,
11816                ..
11817            } = frame
11818            else {
11819                continue;
11820            };
11821            let mut body = Vec::new();
11822            let mut next_cursor: Option<i64> = None;
11823            for inner in frames.by_ref() {
11824                match inner {
11825                    Frame::SubEnd {
11826                        next_cursor: nc, ..
11827                    } => {
11828                        next_cursor = Some(nc);
11829                        break;
11830                    }
11831                    Frame::Unknown { .. } => {}
11832                    other => body.push(other),
11833                }
11834            }
11835            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
11836                dropped = true;
11837                continue;
11838            };
11839            let sub = &self.subs[sub_index];
11840            // §8.2: only locally active, not mid-bootstrap subscriptions
11841            // apply; skipped sections are repaired by the next pull.
11842            if status != SubStatus::Active
11843                || sub.state != SubState::Active
11844                || sub.bootstrap_state.is_some()
11845                || !sub.synced_once
11846            {
11847                dropped = true;
11848                continue;
11849            }
11850            if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
11851                // Idempotent redelivery of an already-covered window.
11852                any_covered = true;
11853                continue;
11854            }
11855            let previous_effective = self.subs[sub_index].effective.clone();
11856            let previous_cursor = self.subs[sub_index].cursor;
11857            self.subs[sub_index].effective = Some(effective_scopes);
11858            let mut failed = false;
11859            for inner in body {
11860                match inner {
11861                    Frame::Commit {
11862                        tables, changes, ..
11863                    } => {
11864                        if self.apply_commit_frame(&tables, &changes).is_err() {
11865                            failed = true;
11866                            break;
11867                        }
11868                    }
11869                    _ => {
11870                        failed = true;
11871                        break;
11872                    }
11873                }
11874            }
11875            let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
11876                self.apply_sub_end(sub_index, cursor, None)
11877                    .ok()
11878                    .map(|()| cursor)
11879            });
11880            let Some(next_cursor) = committed else {
11881                self.subs[sub_index].effective = previous_effective;
11882                self.subs[sub_index].cursor = previous_cursor;
11883                dropped = true;
11884                continue;
11885            };
11886            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
11887        }
11888        if let Some(cursor) = applied_cursor {
11889            // §8.2 ack point: the highest applied SUB_END.nextCursor.
11890            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
11891            let _ = transport.realtime_send(&ack);
11892        } else if !any_covered || dropped {
11893            // §8.2: a delta not applied at all is treated as a wake-up.
11894            self.set_sync_needed(true, true);
11895        }
11896    }
11897
11898    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
11899    /// cursor across active, non-bootstrapping subscriptions that have
11900    /// synced at least once. No such subscription, no ack.
11901    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
11902        if !self.realtime_connected {
11903            return;
11904        }
11905        let floor = self
11906            .subs
11907            .iter()
11908            .filter(|s| {
11909                s.state == SubState::Active
11910                    && s.bootstrap_state.is_none()
11911                    && s.synced_once
11912                    && s.cursor >= 0
11913            })
11914            .map(|s| s.cursor)
11915            .min();
11916        if let Some(cursor) = floor {
11917            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
11918            let _ = transport.realtime_send(&ack);
11919        }
11920    }
11921}
11922
11923#[cfg(test)]
11924mod previous_version_wiring_tests {
11925    use super::*;
11926    use serde_json::{json, Value};
11927
11928    fn previous_version_schema(version: i32) -> Value {
11929        json!({
11930            "version": version,
11931            "tables": [{
11932                "name": "tasks",
11933                "primaryKey": "id",
11934                "columns": [
11935                    { "name": "id", "type": "string", "nullable": false },
11936                    { "name": "note", "type": "string", "nullable": true }
11937                ],
11938                "scopes": []
11939            }]
11940        })
11941    }
11942
11943    fn previous_version_limits() -> crate::api::ClientLimits {
11944        crate::api::ClientLimits {
11945            previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
11946                enabled: true,
11947                ..Default::default()
11948            }),
11949            ..Default::default()
11950        }
11951    }
11952
11953    /// The configuration the live client carries, for a direct capture call.
11954    fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
11955        previous_version_limits()
11956            .previous_version_context
11957            .expect("configured")
11958    }
11959
11960    /// Capture the container the way a bump would, so a read test starts from a
11961    /// container this client's own state will then decide about.
11962    fn recapture_previous_version(client: &SyncClient) {
11963        let replica_path = client
11964            .previous_version_replica_path()
11965            .expect("file-backed replica");
11966        let outcome = crate::previous_version::capture_previous_version_from_replica(
11967            &client.conn,
11968            &replica_path,
11969            2,
11970            2,
11971            &previous_version_config(),
11972            1,
11973        )
11974        .expect("capture");
11975        assert!(matches!(
11976            outcome,
11977            crate::previous_version::CaptureOutcome::Captured(_)
11978        ));
11979    }
11980
11981    fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
11982        Subscription {
11983            id: "wiring-sub".to_owned(),
11984            table: "tasks".to_owned(),
11985            requested: Vec::new(),
11986            params: None,
11987            cursor,
11988            bootstrap_state: bootstrap_state.map(str::to_owned),
11989            state,
11990            reason_code: None,
11991            effective: None,
11992            synced_once: false,
11993        }
11994    }
11995
11996    #[test]
11997    fn previous_version_read_reasons_come_from_live_client_state() {
11998        use crate::previous_version::{
11999            PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
12000        };
12001        let path = std::env::temp_dir()
12002            .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
12003            .to_string_lossy()
12004            .into_owned();
12005        let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
12006        {
12007            let mut setup = SyncClient::open_path(
12008                "wiring".to_owned(),
12009                &previous_version_schema(1),
12010                previous_version_limits(),
12011                &path,
12012            )
12013            .expect("v1 install");
12014            setup
12015                .mutate(vec![Mutation::Upsert {
12016                    table: "tasks".to_owned(),
12017                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
12018                    base_version: None,
12019                }])
12020                .expect("seed a row");
12021        }
12022        let mut client = SyncClient::open_path(
12023            "wiring".to_owned(),
12024            &previous_version_schema(2),
12025            previous_version_limits(),
12026            &path,
12027        )
12028        .expect("v2 bump captures");
12029        assert!(std::path::Path::new(&container).exists());
12030        let spec = PreviousVersionReadSpec {
12031            table: "tasks".to_owned(),
12032            ..Default::default()
12033        };
12034
12035        // §7.3.5 lease error ⇒ lease-inactive, and the read discards.
12036        recapture_previous_version(&client);
12037        client.lease_state = Some(LeaseState {
12038            lease_id: None,
12039            expires_at_ms: None,
12040            error_code: Some("lease.expired".to_owned()),
12041        });
12042        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12043        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
12044        assert!(!snapshot.available);
12045        assert!(!std::path::Path::new(&container).exists());
12046
12047        // An expired lease uses the client clock (injected, no sleeps).
12048        recapture_previous_version(&client);
12049        client.set_now_ms(1_000);
12050        client.lease_state = Some(LeaseState {
12051            lease_id: Some("lease".to_owned()),
12052            expires_at_ms: Some(1_000),
12053            error_code: None,
12054        });
12055        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12056        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
12057        assert!(!std::path::Path::new(&container).exists());
12058        client.lease_state = None;
12059
12060        // A revoked subscription ⇒ scope-revoked.
12061        recapture_previous_version(&client);
12062        client.subs.push(subscription(SubState::Revoked, 0, None));
12063        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12064        assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
12065        assert!(!std::path::Path::new(&container).exists());
12066        client.subs.clear();
12067
12068        // Coverage completion: every ACTIVE subscription has a cursor and no
12069        // resume token — a still-resuming subscription is not completion.
12070        recapture_previous_version(&client);
12071        client
12072            .subs
12073            .push(subscription(SubState::Active, 0, Some("resume")));
12074        assert!(!client.previous_version_coverage_complete());
12075        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12076        assert!(snapshot.available);
12077        assert_eq!(snapshot.previous_version, Some(2));
12078        assert_eq!(snapshot.rows.len(), 1);
12079
12080        recapture_previous_version(&client);
12081        client.subs[0].bootstrap_state = None;
12082        assert!(client.previous_version_coverage_complete());
12083        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
12084        assert_eq!(
12085            snapshot.reason,
12086            Some(PreviousVersionReason::CoverageComplete)
12087        );
12088        assert!(!std::path::Path::new(&container).exists());
12089        client.subs.clear();
12090
12091        // With no active subscription at all, coverage is NOT complete.
12092        assert!(!client.previous_version_coverage_complete());
12093
12094        drop(client);
12095        let _ = std::fs::remove_file(&path);
12096    }
12097
12098    /// RFC 0005 D7 lifetime drops. The read surface already proves the reasons
12099    /// come from live state; this proves the OTHER caller —
12100    /// `previous_version_lifetime_check`, the once-per-sync-round trigger —
12101    /// actually removes the container for lease-inactivity, coverage
12102    /// completion and scope revocation, and leaves it alone while the
12103    /// replacement bootstrap is still resuming.
12104    #[test]
12105    fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
12106        let path = std::env::temp_dir()
12107            .join(format!(
12108                "syncular-prev-lifetime-{}.db",
12109                uuid::Uuid::new_v4()
12110            ))
12111            .to_string_lossy()
12112            .into_owned();
12113        let container = format!(
12114            "{path}{}",
12115            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
12116        );
12117        let exists = || std::path::Path::new(&container).exists();
12118        {
12119            let mut setup = SyncClient::open_path(
12120                "lifetime".to_owned(),
12121                &previous_version_schema(1),
12122                previous_version_limits(),
12123                &path,
12124            )
12125            .expect("v1 install");
12126            setup
12127                .mutate(vec![Mutation::Upsert {
12128                    table: "tasks".to_owned(),
12129                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
12130                    base_version: None,
12131                }])
12132                .expect("seed a row");
12133        }
12134        let mut client = SyncClient::open_path(
12135            "lifetime".to_owned(),
12136            &previous_version_schema(2),
12137            previous_version_limits(),
12138            &path,
12139        )
12140        .expect("v2 bump captures");
12141        // Pin the clock so the 24h TTL never fires in this test (the TTL has
12142        // its own coverage); the capture record's createdAtMs is 1.
12143        client.set_now_ms(1_000);
12144        assert!(exists());
12145
12146        // §7.3.5 lease error ⇒ the lifetime check drops the container.
12147        client.lease_state = Some(LeaseState {
12148            lease_id: None,
12149            expires_at_ms: None,
12150            error_code: Some("lease.expired".to_owned()),
12151        });
12152        client.previous_version_lifetime_check();
12153        assert!(!exists());
12154        client.lease_state = None;
12155
12156        // §7.4.5 coverage completion (every active sub has a cursor and no
12157        // resume token) ⇒ drop.
12158        recapture_previous_version(&client);
12159        client.subs.push(subscription(SubState::Active, 0, None));
12160        client.previous_version_lifetime_check();
12161        assert!(!exists());
12162        client.subs.clear();
12163
12164        // §3.3 scope revocation ⇒ drop.
12165        recapture_previous_version(&client);
12166        client.subs.push(subscription(SubState::Revoked, 0, None));
12167        client.previous_version_lifetime_check();
12168        assert!(!exists());
12169        client.subs.clear();
12170
12171        // Negative control: a still-resuming active subscription is NOT
12172        // coverage completion, so the container survives the same call.
12173        recapture_previous_version(&client);
12174        client
12175            .subs
12176            .push(subscription(SubState::Active, 0, Some("resume")));
12177        client.previous_version_lifetime_check();
12178        assert!(exists());
12179        client.subs.clear();
12180
12181        drop(client);
12182        let _ = std::fs::remove_file(&path);
12183        let _ = std::fs::remove_file(&container);
12184        let _ = std::fs::remove_file(format!("{container}-wal"));
12185        let _ = std::fs::remove_file(format!("{container}-shm"));
12186    }
12187}