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, DiagnosticRoundCounters, DiagnosticSubscription, FetchedBlob, LeaseState,
35    LocalDataPurgeInput, LocalDataPurgeResult, LocalDataPurgeTarget, LocalDataRebootstrapInput,
36    LocalDataRebootstrapResult, Mutation, PresencePeer, QueryRow, QuerySnapshot, QueryValue,
37    RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RowState, SchemaFloor,
38    SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport, SyncStatusSnapshot, TableChange,
39    WindowBase, WindowChange, WindowCoverage, WindowState, WindowUnitRef,
40    CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
41};
42#[cfg(feature = "bench-internals")]
43use crate::bench::{Phase, Recorder};
44use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
45use crate::transport::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
46use crate::values::{
47    bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
48    encode_sparse_row_json, full_row_values, json_to_column_value, json_to_scope_map,
49    normalize_values_casing, render_row_id_json, scope_map_to_json, sort_scope_map,
50};
51
52/// §4.2 default: rows and SQLite images, both applied in committed chunks.
53/// Bit 3 (signed URLs, §5.4) is
54/// added per transport capability at request-build time.
55const DEFAULT_ACCEPT: u8 = 0b0111;
56const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
57const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
58const ACCEPT_SQLITE: u8 = 1 << 2;
59const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
60const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
61
62/// §7.4.1 persisted local schema-version marker (`_syncular_meta` key).
63const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
64const LOCAL_REVISION_KEY: &str = "localRevision";
65const CLIENT_ID_KEY: &str = "clientId";
66const LEASE_STATE_KEY: &str = "leaseState";
67const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
68const LOG_EPOCH_KEY: &str = "logEpoch";
69/// Persisted fail-closed quarantine marker: present while a security preflight
70/// is pending and `activateSecurity` has yet to run. Storing it in the replica
71/// keeps the gate with the data it protects, so a rebuilt host handle reopening
72/// the same file returns in preflight even though its in-memory state is fresh.
73const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
74const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
75const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
76/// §7.4.4 client-local code: a pending outbox commit cannot re-encode under
77/// the new schema after a bump. Never a wire code (§10.3).
78const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
79/// §5.11 client-local code: an encode at the push seam resolved no usable key
80/// id or named an unknown key. Never a wire code (§10.3).
81const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
82
83#[derive(Debug, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase", deny_unknown_fields)]
85struct PersistedLocalDataRebootstrapReceipt {
86    version: u8,
87    retained_commits: u64,
88    reset_subscriptions: u64,
89}
90
91fn invalid_local_rebootstrap_receipt() -> String {
92    "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
93}
94
95fn encode_local_rebootstrap_receipt(
96    retained_commits: usize,
97    reset_subscriptions: usize,
98) -> Result<String, String> {
99    let retained_commits =
100        u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
101    let reset_subscriptions =
102        u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
103    if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
104        return Err(invalid_local_rebootstrap_receipt());
105    }
106    serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
107        version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
108        retained_commits,
109        reset_subscriptions,
110    })
111    .map_err(|_| invalid_local_rebootstrap_receipt())
112}
113
114fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
115    // Pre-0.15.36 markers proved application but did not retain the receipt.
116    if value == "v1" {
117        return Ok((0, 0));
118    }
119    let receipt: PersistedLocalDataRebootstrapReceipt =
120        serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
121    if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
122        || receipt.retained_commits > MAX_JS_SAFE_INTEGER
123        || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
124    {
125        return Err(invalid_local_rebootstrap_receipt());
126    }
127    Ok((
128        usize::try_from(receipt.retained_commits)
129            .map_err(|_| invalid_local_rebootstrap_receipt())?,
130        usize::try_from(receipt.reset_subscriptions)
131            .map_err(|_| invalid_local_rebootstrap_receipt())?,
132    ))
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136enum SubState {
137    Active,
138    Revoked,
139    Failed,
140}
141
142#[cfg(test)]
143mod observation_tests {
144    use super::*;
145    use crate::native_transport::HostTransport;
146    use crate::values::encode_row_json;
147    use serde_json::json;
148
149    fn client() -> SyncClient {
150        SyncClient::new(
151            "retry-test".to_owned(),
152            &json!({
153                "version": 1,
154                "tables": [{
155                    "name": "tasks",
156                    "primaryKey": "id",
157                    "columns": [
158                        { "name": "id", "type": "string", "nullable": false },
159                        { "name": "project_id", "type": "string", "nullable": false }
160                    ],
161                    "scopes": [{ "pattern": "project:{project_id}" }]
162                }]
163            }),
164            ClientLimits::default(),
165        )
166        .expect("test client")
167    }
168
169    #[cfg(feature = "e2ee")]
170    #[test]
171    fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
172        let schema = json!({
173            "version": 1,
174            "tables": [{
175                "name": "secrets",
176                "primaryKey": "id",
177                "columns": [
178                    { "name": "id", "type": "string", "nullable": false },
179                    { "name": "encryption_key_id", "type": "string", "nullable": true },
180                    { "name": "note", "type": "bytes", "nullable": true,
181                      "encrypted": true, "declaredType": "string" }
182                ],
183                "scopes": []
184            }]
185        });
186        let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
187            .expect("test client");
188        let mut config = crate::values::EncryptionConfig::default();
189        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
190        config
191            .key_id_columns
192            .insert("secrets".to_owned(), "encryption_key_id".to_owned());
193        client.set_encryption(config);
194        // A patch on a locally absent row presents an encrypted column with no
195        // stored key id to fall back to. Authoring succeeds...
196        let commit_id = client
197            .patch(
198                "secrets",
199                "ghost",
200                Map::from_iter([("note".to_owned(), json!("x"))]),
201                None,
202            )
203            .expect("patch records without an author-time encrypt failure");
204        assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
205        // ...and the push seam raises a durable local rejection, not a throw.
206        assert!(client.drop_unencodable_outbox().unwrap());
207        assert!(client.pending_commit_ids().is_empty());
208        let rejections = client.rejections();
209        assert_eq!(rejections.len(), 1);
210        assert_eq!(rejections[0].code, "client.encrypt_failed");
211        assert_eq!(rejections[0].client_commit_id, commit_id);
212        assert_eq!(
213            client
214                .commit_outcome(&commit_id)
215                .unwrap()
216                .map(|outcome| outcome.status),
217            Some(CommitOutcomeStatus::Rejected)
218        );
219    }
220
221    #[test]
222    fn patch_with_a_scope_column_matches_the_stored_local_row() {
223        let mut client = client();
224        client.create_synced_tables().unwrap();
225        client
226            .conn
227            .execute(
228                "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
229                [],
230            )
231            .unwrap();
232
233        // §3.4 rule 5: a value equal to the stored local row is a no-op, so
234        // the scope column leaves the presence set.
235        client
236            .patch(
237                "tasks",
238                "t1",
239                Map::from_iter([
240                    ("project_id".to_owned(), json!("p1")),
241                    ("id".to_owned(), json!("t1")),
242                ]),
243                None,
244            )
245            .expect("an equal scope column is a no-op");
246        let values = client.outbox[0].ops[0]
247            .values
248            .clone()
249            .expect("the patch records values");
250        assert_eq!(values.get("id"), Some(&json!("t1")));
251        assert!(!values.contains_key("project_id"));
252
253        // A differing value stays rejected.
254        let differing = client.patch(
255            "tasks",
256            "t1",
257            Map::from_iter([("project_id".to_owned(), json!("p2"))]),
258            None,
259        );
260        assert!(differing
261            .expect_err("a differing scope column is rejected")
262            .contains("patch cannot write scope column"));
263
264        // An absent local row leaves nothing to prove equality against, so
265        // the patch fails closed.
266        let absent = client.patch(
267            "tasks",
268            "ghost",
269            Map::from_iter([("project_id".to_owned(), json!("p1"))]),
270            None,
271        );
272        assert!(absent
273            .expect_err("an absent local row is rejected")
274            .contains("patch cannot write scope column"));
275        assert_eq!(client.pending_commit_ids().len(), 1);
276    }
277
278    #[test]
279    fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
280        let schema = json!({
281            "version": 1,
282            "tables": [
283                {
284                    "name": "tenants",
285                    "primaryKey": "tenant_id",
286                    "columns": [
287                        { "name": "tenant_id", "type": "string", "nullable": false },
288                        { "name": "body", "type": "string", "nullable": false }
289                    ],
290                    "scopes": [{ "pattern": "tenant:{tenant_id}" }]
291                },
292                {
293                    "name": "buckets",
294                    "primaryKey": "id",
295                    "columns": [
296                        { "name": "id", "type": "string", "nullable": false },
297                        { "name": "bucket", "type": "float", "nullable": false },
298                        { "name": "body", "type": "string", "nullable": false }
299                    ],
300                    "scopes": [{ "pattern": "bucket:{bucket}" }]
301                }
302            ]
303        });
304        let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
305            .expect("test client");
306        client.create_synced_tables().unwrap();
307
308        // A primary key that is also a scope column with no local row: the
309        // sparse payload's key is the row id being patched, so its value is
310        // proven equal without a stored-row read.
311        client
312            .patch(
313                "tenants",
314                "t1",
315                Map::from_iter([
316                    ("tenant_id".to_owned(), json!("t1")),
317                    ("body".to_owned(), json!("created")),
318                ]),
319                None,
320            )
321            .expect("the primary-key scope column is proven equal by construction");
322
323        // A stored REAL scope value coerces from a supplied integer.
324        client
325            .conn
326            .execute(
327                "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
328                [],
329            )
330            .unwrap();
331        client
332            .patch(
333                "buckets",
334                "b1",
335                Map::from_iter([
336                    ("bucket".to_owned(), json!(2)),
337                    ("body".to_owned(), json!("coerced")),
338                ]),
339                None,
340            )
341            .expect("the stored REAL value coerces from a supplied integer");
342        let values = client.outbox[1].ops[0]
343            .values
344            .clone()
345            .expect("the patch records values");
346        assert!(!values.contains_key("bucket"));
347
348        // A differing value stays rejected.
349        let differing = client.patch(
350            "buckets",
351            "b1",
352            Map::from_iter([("bucket".to_owned(), json!(3))]),
353            None,
354        );
355        assert!(differing
356            .expect_err("a differing scope value is rejected")
357            .contains("patch cannot write scope column"));
358    }
359
360    #[cfg(feature = "e2ee")]
361    #[test]
362    fn stored_key_fallback_resolves_an_integer_primary_key() {
363        let schema = json!({
364            "version": 1,
365            "tables": [{
366                "name": "patients",
367                "primaryKey": "id",
368                "columns": [
369                    { "name": "id", "type": "integer", "nullable": false },
370                    { "name": "encryption_key_id", "type": "string", "nullable": true },
371                    { "name": "note", "type": "bytes", "nullable": true,
372                      "encrypted": true, "declaredType": "string" }
373                ],
374                "scopes": []
375            }]
376        });
377        let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
378            .expect("test client");
379        client.create_synced_tables().unwrap();
380        client
381            .conn
382            .execute(
383                "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
384                 VALUES (5, 'k1', NULL, 1)",
385                [],
386            )
387            .unwrap();
388        let mut config = crate::values::EncryptionConfig::default();
389        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
390        config
391            .key_id_columns
392            .insert("patients".to_owned(), "encryption_key_id".to_owned());
393        client.set_encryption(config);
394        let table = client.schema.table("patients").unwrap();
395        let fallback = client
396            .stored_key_fallback(table, "5")
397            .expect("the stored integer key resolves the fallback");
398        assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
399    }
400
401    #[test]
402    fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
403        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
404            {"name":"id","type":"string","nullable":false},
405            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
406        for state in ["absent", "cached", "pinned"] {
407            let path = std::env::temp_dir().join(format!(
408                "syncular-blob-stage-{}.sqlite",
409                uuid::Uuid::new_v4()
410            ));
411            let mut client = SyncClient::open_path(
412                "stage-test".into(),
413                &schema,
414                Default::default(),
415                path.to_str().unwrap(),
416            )
417            .unwrap();
418            let bytes = b"atomic stage";
419            if state != "absent" {
420                client
421                    .upload_blob(bytes, Some("text/plain".into()), None)
422                    .unwrap();
423                if state == "cached" {
424                    client
425                        .conn
426                        .execute("DELETE FROM _syncular_blob_uploads", [])
427                        .unwrap();
428                }
429            }
430            let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
431            client
432                .conn
433                .execute_batch(
434                    "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
435                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
436                 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
437                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
438                )
439                .unwrap();
440            let error = client
441                .upload_blob(bytes, Some("application/octet-stream".into()), None)
442                .unwrap_err();
443            assert!(error.contains("injected stage failure"), "{state}: {error}");
444            assert!(client.conn.is_autocommit());
445            let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
446            drop(client);
447            let mut reopened = SyncClient::open_path(
448                "stage-test".into(),
449                &schema,
450                Default::default(),
451                path.to_str().unwrap(),
452            )
453            .unwrap();
454            let durable = reopened
455                .query("SELECT * FROM _syncular_blobs", &[])
456                .unwrap();
457            reopened
458                .conn
459                .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
460                .unwrap();
461            let retried = reopened.upload_blob(bytes, None, None).unwrap();
462            assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
463            assert_eq!(
464                reopened
465                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
466                    .unwrap(),
467                vec![Map::from_iter([(
468                    "blob_id".into(),
469                    Value::from(blob_id_for(bytes)),
470                )])]
471            );
472            drop(reopened);
473            std::fs::remove_file(&path).unwrap();
474            assert_eq!(durable, bodies, "{state}: reopened body state");
475            assert_eq!(immediate, bodies, "{state}: immediate body state");
476        }
477    }
478
479    #[test]
480    fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
481        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
482            {"name":"id","type":"string","nullable":false},
483            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
484        for fault in [
485            "missing",
486            "body-type",
487            "length",
488            "hash",
489            "pin-metadata",
490            "read",
491            "pin-delete",
492        ] {
493            let path = std::env::temp_dir().join(format!(
494                "syncular-blob-upload-{}.sqlite",
495                uuid::Uuid::new_v4()
496            ));
497            let mut client = SyncClient::open_path(
498                "upload-test".into(),
499                &schema,
500                Default::default(),
501                path.to_str().unwrap(),
502            )
503            .unwrap();
504            let bytes = b"durable upload";
505            let ref_value = client
506                .upload_blob(bytes, Some("text/plain".into()), None)
507                .unwrap();
508            let commit = client
509                .mutate(vec![Mutation::Upsert {
510                    table: "attachments".into(),
511                    values: Map::from_iter([
512                        ("id".into(), Value::from("pending")),
513                        (
514                            "file".into(),
515                            Value::from(format!(
516                                "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
517                                blob_id_for(bytes),
518                                bytes.len()
519                            )),
520                        ),
521                    ]),
522                    base_version: None,
523                }])
524                .unwrap();
525            client
526                .conn
527                .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
528                .unwrap();
529            client.conn.execute_batch(match fault {
530                "missing" => "DELETE FROM _syncular_blobs",
531                "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
532                "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
533                "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
534                "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
535                "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
536                _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
537            }).unwrap();
538            let state = if fault == "read" {
539                Vec::new()
540            } else {
541                client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
542            };
543            let mut transport = CountingRealtimeTransport::default();
544            let error = client.flush_blob_uploads(&mut transport).unwrap_err();
545            assert_eq!(
546                error.code,
547                if matches!(fault, "read" | "pin-delete") {
548                    "client.failed"
549                } else {
550                    "sync.local_corrupt"
551                },
552                "{fault}"
553            );
554            assert_eq!(
555                transport.blob_uploads.len(),
556                usize::from(fault == "pin-delete"),
557                "{fault}: corrupt bytes must never be uploaded"
558            );
559            assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
560            if fault != "read" {
561                assert_eq!(
562                    client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
563                    state
564                );
565            } else {
566                client
567                    .conn
568                    .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
569                    .unwrap();
570            }
571            drop(client);
572            let mut reopened = SyncClient::open_path(
573                "upload-test".into(),
574                &schema,
575                Default::default(),
576                path.to_str().unwrap(),
577            )
578            .unwrap();
579            assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
580            if fault != "read" {
581                assert_eq!(
582                    reopened
583                        .query("SELECT * FROM _syncular_blobs", &[])
584                        .unwrap(),
585                    state
586                );
587            }
588            if fault == "pin-delete" {
589                reopened
590                    .conn
591                    .execute_batch("DROP TRIGGER fail_pin_delete")
592                    .unwrap();
593            }
594            if fault == "pin-metadata" {
595                reopened
596                    .conn
597                    .execute(
598                        "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
599                        [],
600                    )
601                    .unwrap();
602            }
603            reopened
604                .conn
605                .execute_batch(
606                    "DELETE FROM _syncular_blobs;
607                     INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
608                )
609                .unwrap();
610            transport.blob_uploads.clear();
611            reopened.flush_blob_uploads(&mut transport).unwrap();
612            assert_eq!(
613                transport.blob_uploads,
614                [(blob_id_for(bytes), bytes.to_vec())]
615            );
616            assert_eq!(
617                reopened
618                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
619                    .unwrap(),
620                Vec::<Map<String, Value>>::new()
621            );
622            assert_eq!(reopened.pending_commit_ids(), [commit]);
623            assert_eq!(
624                reopened
625                    .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
626                    .unwrap()
627                    .unwrap()
628                    .bytes,
629                bytes
630            );
631            drop(reopened);
632            std::fs::remove_file(path).unwrap();
633        }
634    }
635
636    #[test]
637    fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
638        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
639            {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
640            "scopes":[{"pattern":"project:{project_id}"}]}]});
641        for realtime in [false, true] {
642            for fault in [
643                "none",
644                "row",
645                "revision",
646                "commit",
647                "cursor",
648                "error",
649                "missing-end",
650            ] {
651                if realtime && fault == "missing-end" {
652                    continue;
653                }
654                let path = std::env::temp_dir()
655                    .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
656                let mut client = SyncClient::open_path(
657                    "frame-test".into(),
658                    &schema,
659                    Default::default(),
660                    path.to_str().unwrap(),
661                )
662                .unwrap();
663                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
664                client
665                    .subscribe(
666                        "tasks".into(),
667                        "tasks".into(),
668                        vec![("project_id".into(), vec!["p1".into()])],
669                        None,
670                    )
671                    .unwrap();
672                client.subs[0].cursor = 0;
673                client.subs[0].synced_once = true;
674                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
675                client.persist_sub(&client.subs[0]).unwrap();
676                client.realtime_connected = true;
677                client.drain_change_batches();
678                let mut transport = CountingRealtimeTransport::default();
679                let (_, meta) = client.build_request(false);
680                let mut response = Message {
681                    wire_version: WIRE_VERSION,
682                    msg_kind: MsgKind::Response,
683                    frames: vec![
684                        Frame::RespHeader {
685                            required_schema_version: None,
686                            latest_schema_version: None,
687                            log_epoch: Some("epoch-1".into()),
688                            reset_required: Some(false),
689                        },
690                        Frame::SubStart {
691                            id: "tasks".into(),
692                            status: SubStatus::Active,
693                            reason_code: "".into(),
694                            effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
695                            bootstrap: false,
696                        },
697                    ],
698                };
699                for (index, id) in ["first", "second"].iter().enumerate() {
700                    let values = Map::from_iter([
701                        ("id".into(), Value::from(*id)),
702                        ("project_id".into(), Value::from("p1")),
703                    ]);
704                    response.frames.push(Frame::Commit {
705                        commit_seq: index as i64 + 1,
706                        actor_id: "writer".into(),
707                        created_at_ms: 1,
708                        tables: vec!["tasks".into()],
709                        changes: vec![ssp2::model::Change {
710                            table_index: 0,
711                            row_id: (*id).into(),
712                            op: Op::Upsert,
713                            row_version: Some(index as i64 + 1),
714                            scopes: vec![("project_id".into(), "p1".into())],
715                            row: Some(
716                                encode_row_json(
717                                    client.schema.table("tasks").unwrap(),
718                                    id,
719                                    &values,
720                                    &client.encryption,
721                                )
722                                .unwrap(),
723                            ),
724                        }],
725                    });
726                }
727                response.frames.push(Frame::SubEnd {
728                    next_cursor: 2,
729                    bootstrap_state: None,
730                });
731                let valid = response.clone();
732                match fault {
733                    "row" => {
734                        let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
735                        let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
736                    },
737                    "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(),
738                    "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(),
739                    "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
740                    "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});},
741                    "missing-end" => {response.frames.truncate(3);},
742                    _ => {}
743                }
744                if realtime {
745                    client.on_realtime_binary(&mut transport, &encode_message(&response));
746                } else {
747                    assert_eq!(
748                        matches!(
749                            client.process_response(&mut transport, response, &meta),
750                            SyncOutcome::Ok(_)
751                        ),
752                        fault == "none",
753                        "{fault}"
754                    );
755                }
756                assert!(
757                    client.conn.is_autocommit(),
758                    "no open transaction after {realtime}/{fault}"
759                );
760                let count = if matches!(fault, "none" | "cursor") {
761                    2
762                } else {
763                    1
764                };
765                let expected_revision = count + u64::from(realtime && fault != "none");
766                assert_eq!(
767                    client.local_revision(),
768                    expected_revision,
769                    "{realtime}/{fault}"
770                );
771                let batches = client.drain_change_batches();
772                assert_eq!(
773                    batches.len(),
774                    expected_revision as usize,
775                    "{realtime}/{fault}"
776                );
777                let row_batches: Vec<_> = batches
778                    .iter()
779                    .filter(|batch| !batch.tables.is_empty())
780                    .collect();
781                assert_eq!(row_batches.len(), count as usize);
782                assert_eq!(
783                    row_batches
784                        .iter()
785                        .map(|b| b.revision.clone())
786                        .collect::<Vec<_>>(),
787                    (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
788                );
789                for table in ["tasks", "_syncular_base_tasks"] {
790                    assert_eq!(
791                        client
792                            .conn
793                            .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
794                                .get::<_, u64>(0))
795                            .unwrap(),
796                        count,
797                        "{realtime}/{fault}/{table}"
798                    );
799                }
800                assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
801                assert_eq!(
802                    transport
803                        .messages
804                        .iter()
805                        .filter(|m| m.contains("\"ack\""))
806                        .count(),
807                    usize::from(fault == "none")
808                );
809                if matches!(fault, "revision" | "commit" | "cursor") {
810                    client
811                        .conn
812                        .execute_batch("DROP TRIGGER fail_frame")
813                        .unwrap();
814                }
815                drop(client);
816                let mut reopened = SyncClient::open_path(
817                    "frame-test".into(),
818                    &schema,
819                    Default::default(),
820                    path.to_str().unwrap(),
821                )
822                .unwrap();
823                assert_eq!(reopened.local_revision(), expected_revision);
824                assert_eq!(
825                    reopened
826                        .query("SELECT id FROM tasks ORDER BY id", &[])
827                        .unwrap()
828                        .len(),
829                    count as usize
830                );
831                assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
832                let (_, meta) = reopened.build_request(false);
833                assert!(matches!(
834                    reopened.process_response(&mut transport, valid, &meta),
835                    SyncOutcome::Ok(_)
836                ));
837                assert_eq!(
838                    reopened
839                        .query("SELECT id FROM tasks ORDER BY id", &[])
840                        .unwrap()
841                        .len(),
842                    2
843                );
844                assert_eq!(reopened.subs[0].cursor, 2);
845                drop(reopened);
846                std::fs::remove_file(path).unwrap();
847            }
848        }
849    }
850
851    #[test]
852    fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
853        for status in [SubStatus::Reset, SubStatus::Revoked] {
854            for fault in ["subscription", "revision"] {
855                let mut client = client();
856                client
857                    .subscribe(
858                        "tasks".into(),
859                        "tasks".into(),
860                        vec![("project_id".into(), vec!["p1".into()])],
861                        None,
862                    )
863                    .unwrap();
864                client.subs[0].cursor = 41;
865                client.subs[0].synced_once = true;
866                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
867                client.persist_sub(&client.subs[0]).unwrap();
868                client
869                    .mutate(vec![Mutation::Upsert {
870                        table: "tasks".into(),
871                        values: Map::from_iter([
872                            ("id".into(), Value::from("pending")),
873                            ("project_id".into(), Value::from("p1")),
874                        ]),
875                        base_version: None,
876                    }])
877                    .unwrap();
878                let ids = client.pending_commit_ids();
879                let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
880                let revision = client.local_revision();
881                client.drain_change_batches();
882                let (_, meta) = client.build_request(false);
883                client.conn.execute_batch(if fault == "subscription" {
884                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
885                } else {
886                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
887                }).unwrap();
888                let mut transport = CountingRealtimeTransport::default();
889                let mut report = SyncReport::default();
890                // Reset changes window completeness and therefore needs a revision.
891                if status == SubStatus::Reset {
892                    client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
893                }
894                let result = client.process_section(
895                    &mut transport,
896                    "tasks",
897                    status,
898                    "sync.scope_revoked",
899                    vec![],
900                    vec![],
901                    Some((42, None)),
902                    &meta,
903                    &mut report,
904                );
905                assert!(result.is_err(), "{status:?}/{fault}");
906                assert!(client.conn.is_autocommit());
907                assert_eq!(client.subs[0].cursor, 41);
908                assert_eq!(client.subs[0].state, SubState::Active);
909                assert_eq!(client.pending_commit_ids(), ids);
910                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
911                assert!(client.rejections.is_empty());
912                assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
913                assert_eq!(client.local_revision(), revision);
914                assert!(client.drain_change_batches().is_empty());
915                assert!(report.revoked.is_empty() && report.resets.is_empty());
916            }
917        }
918    }
919
920    #[test]
921    fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
922        let mut client = client();
923        client
924            .subscribe(
925                "tasks".into(),
926                "tasks".into(),
927                vec![("project_id".into(), vec!["p1".into()])],
928                None,
929            )
930            .unwrap();
931        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
932        client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
933            CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
934        let row = |id: &str| ssp2::segment::SegmentRow {
935            server_version: 1,
936            values: vec![
937                Some(ColumnValue::String(id.into())),
938                Some(ColumnValue::String("p1".into())),
939            ],
940        };
941        let segment = RowsSegment {
942            table: "tasks".into(),
943            schema_version: 1,
944            columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
945            blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
946        };
947        assert!(client.apply_segment(0, &segment, true).is_err());
948        assert!(client.conn.is_autocommit());
949        assert_eq!(
950            client
951                .query("SELECT id FROM tasks ORDER BY id", &[])
952                .unwrap(),
953            vec![Map::from_iter([("id".into(), Value::from("first"))])]
954        );
955        assert_eq!(client.local_revision(), 1);
956        assert_eq!(client.drain_change_batches().len(), 1);
957        assert_eq!(client.subs[0].cursor, -1);
958        client
959            .conn
960            .execute_batch("DROP TRIGGER fail_block")
961            .unwrap();
962        assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
963        assert_eq!(client.local_revision(), 3);
964        assert_eq!(client.drain_change_batches().len(), 2);
965    }
966
967    #[test]
968    fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
969        assert_eq!(
970            client().diagnostics_storage().blob_cache_bytes_approx,
971            Some(0)
972        );
973        let client = SyncClient::new(
974            "diagnostic-storage".to_owned(),
975            &json!({
976                "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
977                    "columns": [{"name": "id", "type": "string", "nullable": false},
978                        {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
979            }),
980            ClientLimits {
981                blob_cache_max_bytes: Some(5),
982                ..Default::default()
983            },
984        )
985        .unwrap();
986        let empty = client.diagnostics_storage();
987        assert_eq!(empty.status, "healthy");
988        assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
989        assert_eq!(empty.retained_outcome_entries, Some(0));
990        assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
991        assert_eq!(empty.blob_cache_bytes_approx, Some(0));
992        client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
993            INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
994                VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
995            INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
996                VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
997        let written = client.diagnostics_storage();
998        assert_eq!(written.status, "pressure");
999        assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1000        assert_eq!(written.retained_outcome_entries, Some(2));
1001        assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1002        assert_eq!(written.blob_cache_bytes_approx, Some(8));
1003        assert_eq!(
1004            written.pressure_reason_code.as_deref(),
1005            Some("client.blob_cache_over_limit")
1006        );
1007        client
1008            .conn
1009            .execute_batch(
1010                "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1011            DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1012            )
1013            .unwrap();
1014        let staged = client.diagnostics_storage();
1015        assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1016        assert_eq!(staged.retained_outcome_entries, Some(0));
1017        assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1018        assert_eq!(staged.status, "healthy");
1019        client.conn.execute_batch("ROLLBACK").unwrap();
1020        assert_eq!(client.diagnostics_storage(), written);
1021        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1022        let unreadable = client.diagnostics_storage();
1023        assert_eq!(unreadable.status, "unreadable");
1024        assert!(unreadable.database_bytes_approx.is_none());
1025        assert!(unreadable.pending_outbox_bytes_approx.is_none());
1026        assert!(unreadable.retained_outcome_entries.is_none());
1027        assert!(unreadable.retained_outcome_bytes_approx.is_none());
1028        assert!(unreadable.blob_cache_bytes_approx.is_none());
1029        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1030        let restored = client.diagnostics_storage();
1031        assert_eq!(restored.status, written.status);
1032        assert_eq!(
1033            restored.retained_outcome_bytes_approx,
1034            written.retained_outcome_bytes_approx
1035        );
1036        client.conn.set_prepared_statement_cache_capacity(0);
1037        assert_eq!(client.diagnostics_storage(), restored);
1038    }
1039
1040    #[test]
1041    fn background_retry_deadlines_back_off_and_reset() {
1042        let mut client = client();
1043        client.schedule_background_retry();
1044        assert!(matches!(
1045            client.drain_sync_intents().as_slice(),
1046            [SyncIntent::Background { delay_ms: 250 }]
1047        ));
1048        client.schedule_background_retry();
1049        assert!(matches!(
1050            client.drain_sync_intents().as_slice(),
1051            [SyncIntent::Background { delay_ms: 500 }]
1052        ));
1053        client.reset_background_retry();
1054        client.schedule_background_retry();
1055        assert!(matches!(
1056            client.drain_sync_intents().as_slice(),
1057            [SyncIntent::Background { delay_ms: 250 }]
1058        ));
1059    }
1060
1061    #[test]
1062    fn log_epoch_reset_requests_an_immediate_follow_up_round() {
1063        let mut client = client();
1064        client
1065            .subscribe(
1066                "epoch-tasks".to_owned(),
1067                "tasks".to_owned(),
1068                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1069                None,
1070            )
1071            .expect("subscribe");
1072        client.drain_sync_intents();
1073
1074        client
1075            .run_log_epoch_reset("epoch-2")
1076            .expect("reset partition log epoch");
1077
1078        assert!(matches!(
1079            client.drain_sync_intents().as_slice(),
1080            [SyncIntent::Interactive]
1081        ));
1082    }
1083
1084    #[derive(Default)]
1085    struct CountingRealtimeTransport {
1086        connects: usize,
1087        closes: usize,
1088        messages: Vec<String>,
1089        blob_uploads: Vec<(String, Vec<u8>)>,
1090        blob_download: Option<Vec<u8>>,
1091    }
1092
1093    impl Transport for CountingRealtimeTransport {
1094        fn blob_upload(
1095            &mut self,
1096            blob_id: &str,
1097            bytes: &[u8],
1098            _media_type: Option<&str>,
1099        ) -> Result<(), TransportError> {
1100            self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1101            Ok(())
1102        }
1103
1104        fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1105            self.blob_download
1106                .clone()
1107                .map(BlobDownload::Bytes)
1108                .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1109        }
1110
1111        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1112            Err(TransportError::new("sync.transport_failed", "offline"))
1113        }
1114
1115        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1116            Err(TransportError::new("sync.transport_failed", "offline"))
1117        }
1118
1119        fn download_segment(
1120            &mut self,
1121            _request: &SegmentRequest,
1122            _on_progress: &mut dyn FnMut(u64),
1123        ) -> Result<Vec<u8>, TransportError> {
1124            Err(TransportError::new("sync.transport_failed", "offline"))
1125        }
1126
1127        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1128            self.connects += 1;
1129            Ok(())
1130        }
1131
1132        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1133            self.messages.push(text.to_owned());
1134            Ok(())
1135        }
1136
1137        fn realtime_close(&mut self) -> Result<(), TransportError> {
1138            self.closes += 1;
1139            Ok(())
1140        }
1141    }
1142
1143    #[test]
1144    fn blob_results_own_downloaded_bytes() {
1145        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1146            {"name":"id","type":"string","nullable":false},
1147            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1148        let bytes = b"owned typed result".to_vec();
1149        let blob_id = blob_id_for(&bytes);
1150        let mut transport = CountingRealtimeTransport {
1151            blob_download: Some(bytes.clone()),
1152            ..Default::default()
1153        };
1154        let result = {
1155            let mut client =
1156                SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1157                    .unwrap();
1158            let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1159            assert_eq!(result.blob_id, blob_id);
1160            assert_eq!(result.byte_length, bytes.len() as i64);
1161            assert_eq!(result.bytes, bytes);
1162            assert_eq!(result.media_type, None);
1163            assert_eq!(
1164                client
1165                    .conn
1166                    .query_row(
1167                        "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1168                        rusqlite::params![blob_id],
1169                        |row| row.get::<_, i64>(0),
1170                    )
1171                    .unwrap(),
1172                0
1173            );
1174            result
1175        };
1176        assert_eq!(result.bytes, bytes);
1177
1178        let mut client =
1179            SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1180        assert_eq!(
1181            client
1182                .fetch_blob_bytes(&mut transport, "not json")
1183                .unwrap_err(),
1184            (
1185                "client.failed".to_owned(),
1186                "blob ref is not JSON".to_owned()
1187            )
1188        );
1189    }
1190
1191    #[test]
1192    fn visible_blob_references_survive_cache_trimming() {
1193        let schema = json!({"version":1,"tables":[
1194            {"name":"attachments","primaryKey":"id","columns":[
1195                {"name":"id","type":"string","nullable":false},
1196                {"name":"body","type":"blob_ref","nullable":true},
1197                {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
1198            {"name":"avatars","primaryKey":"id","columns":[
1199                {"name":"id","type":"string","nullable":false},
1200                {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
1201        ]});
1202        let bytes = b"referenced above cap".to_vec();
1203        let blob_id = blob_id_for(&bytes);
1204        let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
1205        let mut client = SyncClient::new(
1206            "visible-blob-refs".to_owned(),
1207            &schema,
1208            ClientLimits {
1209                blob_cache_max_bytes: Some(1),
1210                ..Default::default()
1211            },
1212        )
1213        .unwrap();
1214        client.conn.execute(
1215            "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
1216            rusqlite::params![reference, reference, reference, reference],
1217        ).unwrap();
1218        client
1219            .conn
1220            .execute(
1221                "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
1222                rusqlite::params![reference],
1223            )
1224            .unwrap();
1225        client.conn.execute(
1226            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
1227            [],
1228        ).unwrap();
1229        let mut transport = CountingRealtimeTransport {
1230            blob_download: Some(bytes.clone()),
1231            ..Default::default()
1232        };
1233
1234        assert_eq!(
1235            client
1236                .fetch_blob_bytes(&mut transport, &blob_id)
1237                .unwrap()
1238                .bytes,
1239            bytes
1240        );
1241        let rows = client
1242            .conn
1243            .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1244            .unwrap()
1245            .query_map([], |row| row.get::<_, String>(0))
1246            .unwrap()
1247            .collect::<Result<Vec<_>, _>>()
1248            .unwrap();
1249        assert_eq!(rows, vec![blob_id]);
1250    }
1251
1252    #[test]
1253    fn realtime_connection_ownership_is_idempotent() {
1254        let mut client = client();
1255        let mut transport = CountingRealtimeTransport {
1256            connects: 0,
1257            closes: 0,
1258            ..Default::default()
1259        };
1260        client
1261            .connect_realtime(&mut transport)
1262            .expect("first connect");
1263        client
1264            .connect_realtime(&mut transport)
1265            .expect("idempotent connect");
1266        assert_eq!(transport.connects, 1);
1267        client.disconnect_realtime(&mut transport);
1268        client.disconnect_realtime(&mut transport);
1269        assert_eq!(transport.closes, 1);
1270        client
1271            .connect_realtime(&mut transport)
1272            .expect("deliberate reconnect");
1273        assert_eq!(transport.connects, 2);
1274    }
1275
1276    #[test]
1277    fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1278        let mut client = client();
1279        client
1280            .subscribe(
1281                "repair-tasks".to_owned(),
1282                "tasks".to_owned(),
1283                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1284                None,
1285            )
1286            .expect("subscribe");
1287        {
1288            let sub = client
1289                .subs
1290                .iter_mut()
1291                .find(|sub| sub.id == "repair-tasks")
1292                .expect("subscription");
1293            sub.cursor = 42;
1294            sub.synced_once = true;
1295            let persisted = sub.clone();
1296            client.persist_sub(&persisted).unwrap();
1297        }
1298        for table in ["_syncular_base_tasks", "tasks"] {
1299            client
1300                .conn
1301                .execute(
1302                    &format!(
1303                        "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1304                    ),
1305                    rusqlite::params!["server-row", "p1"],
1306                )
1307                .expect("seed server row");
1308        }
1309        let pending = client
1310            .mutate(vec![Mutation::Upsert {
1311                table: "tasks".to_owned(),
1312                values: Map::from_iter([
1313                    ("id".to_owned(), Value::from("offline-row")),
1314                    ("project_id".to_owned(), Value::from("p1")),
1315                ]),
1316                base_version: None,
1317            }])
1318            .expect("queue offline work");
1319        client.drain_change_batches();
1320        client.drain_sync_intents();
1321
1322        assert_eq!(
1323            client
1324                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1325                    rebootstrap_id: "support-case-001".to_owned(),
1326                })
1327                .expect("rebootstrap"),
1328            LocalDataRebootstrapResult {
1329                already_applied: false,
1330                retained_commits: 1,
1331                reset_subscriptions: 1,
1332            }
1333        );
1334        let visible_ids = client
1335            .conn
1336            .prepare("SELECT id FROM tasks ORDER BY id")
1337            .expect("prepare visible ids")
1338            .query_map([], |row| row.get::<_, String>(0))
1339            .expect("query visible ids")
1340            .collect::<Result<Vec<_>, _>>()
1341            .expect("collect visible ids");
1342        assert_eq!(visible_ids, vec!["offline-row"]);
1343        assert_eq!(client.pending_commit_ids(), vec![pending]);
1344        assert_eq!(
1345            client
1346                .subscription_state("repair-tasks")
1347                .expect("subscription")
1348                .cursor,
1349            -1
1350        );
1351        assert!(client.upgrading());
1352        assert!(client.sync_needed());
1353        assert!(matches!(
1354            client.drain_sync_intents().as_slice(),
1355            [SyncIntent::Interactive]
1356        ));
1357        assert_eq!(client.drain_change_batches().len(), 1);
1358
1359        assert_eq!(
1360            client
1361                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1362                    rebootstrap_id: "support-case-001".to_owned(),
1363                })
1364                .expect("idempotent retry"),
1365            LocalDataRebootstrapResult {
1366                already_applied: true,
1367                retained_commits: 1,
1368                reset_subscriptions: 1,
1369            }
1370        );
1371        assert!(client.drain_change_batches().is_empty());
1372    }
1373
1374    #[test]
1375    fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1376        let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1377        assert_eq!(
1378            decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1379            (3, 4)
1380        );
1381        assert_eq!(
1382            decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1383            (0, 0)
1384        );
1385        for malformed in [
1386            "",
1387            "{}",
1388            r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1389            r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1390            r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1391        ] {
1392            assert_eq!(
1393                decode_local_rebootstrap_receipt(malformed)
1394                    .expect_err("malformed receipt must fail"),
1395                "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1396            );
1397        }
1398    }
1399
1400    #[test]
1401    fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1402        let path = std::env::temp_dir().join(format!(
1403            "syncular-rebootstrap-receipt-{}.db",
1404            uuid::Uuid::new_v4()
1405        ));
1406        let schema = json!({
1407            "version": 1,
1408            "tables": [{
1409                "name": "tasks",
1410                "primaryKey": "id",
1411                "columns": [
1412                    { "name": "id", "type": "string", "nullable": false },
1413                    { "name": "project_id", "type": "string", "nullable": false }
1414                ],
1415                "scopes": [{ "pattern": "project:{project_id}" }]
1416            }]
1417        });
1418        let path_string = path.to_str().expect("UTF-8 temp path");
1419
1420        {
1421            let mut first = SyncClient::open_path(
1422                "repair-restart-client".to_owned(),
1423                &schema,
1424                ClientLimits::default(),
1425                path_string,
1426            )
1427            .expect("first open");
1428            first
1429                .subscribe(
1430                    "repair-tasks".to_owned(),
1431                    "tasks".to_owned(),
1432                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1433                    None,
1434                )
1435                .expect("subscribe");
1436            first
1437                .mutate(vec![Mutation::Upsert {
1438                    table: "tasks".to_owned(),
1439                    values: Map::from_iter([
1440                        ("id".to_owned(), Value::from("offline-row")),
1441                        ("project_id".to_owned(), Value::from("p1")),
1442                    ]),
1443                    base_version: None,
1444                }])
1445                .expect("queue offline work");
1446            assert_eq!(
1447                first
1448                    .rebootstrap_local_data(&LocalDataRebootstrapInput {
1449                        rebootstrap_id: "restart-receipt".to_owned(),
1450                    })
1451                    .expect("first rebootstrap"),
1452                LocalDataRebootstrapResult {
1453                    already_applied: false,
1454                    retained_commits: 1,
1455                    reset_subscriptions: 1,
1456                }
1457            );
1458        }
1459
1460        let mut reopened = SyncClient::open_path(
1461            "repair-restart-client".to_owned(),
1462            &schema,
1463            ClientLimits::default(),
1464            path_string,
1465        )
1466        .expect("reopen");
1467        reopened.drain_change_batches();
1468        reopened.drain_sync_intents();
1469        assert_eq!(
1470            reopened
1471                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1472                    rebootstrap_id: "restart-receipt".to_owned(),
1473                })
1474                .expect("receipt replay"),
1475            LocalDataRebootstrapResult {
1476                already_applied: true,
1477                retained_commits: 1,
1478                reset_subscriptions: 1,
1479            }
1480        );
1481        assert!(reopened.drain_change_batches().is_empty());
1482        assert!(reopened.drain_sync_intents().is_empty());
1483        drop(reopened);
1484        std::fs::remove_file(path).expect("remove temp database");
1485    }
1486
1487    #[test]
1488    fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1489        let mut malformed = client();
1490        malformed
1491            .subscribe(
1492                "repair-tasks".to_owned(),
1493                "tasks".to_owned(),
1494                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1495                None,
1496            )
1497            .expect("subscribe");
1498        malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1499        malformed.drain_change_batches();
1500        malformed.drain_sync_intents();
1501        let malformed_error = malformed
1502            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1503                rebootstrap_id: "malformed".to_owned(),
1504            })
1505            .expect_err("malformed receipt must fail closed");
1506        assert_eq!(
1507            malformed_error,
1508            "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1509        );
1510        assert!(!malformed.upgrading());
1511        assert_eq!(
1512            malformed
1513                .subscription_state("repair-tasks")
1514                .expect("unchanged subscription")
1515                .cursor,
1516            -1
1517        );
1518        assert!(malformed.drain_change_batches().is_empty());
1519        assert!(malformed.drain_sync_intents().is_empty());
1520
1521        let mut unreadable = client();
1522        unreadable
1523            .conn
1524            .execute(
1525                "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1526                rusqlite::params!["server-row", "p1"],
1527            )
1528            .expect("seed visible row");
1529        unreadable
1530            .conn
1531            .execute("DROP TABLE _syncular_meta", [])
1532            .expect("break marker storage");
1533        let unreadable_error = unreadable
1534            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1535                rebootstrap_id: "unreadable".to_owned(),
1536            })
1537            .expect_err("unreadable marker storage must fail closed");
1538        assert_eq!(
1539            unreadable_error,
1540            "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1541        );
1542        let visible_rows = unreadable
1543            .conn
1544            .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1545            .expect("visible projection remains");
1546        assert_eq!(visible_rows, 1);
1547        assert!(!unreadable.upgrading());
1548        assert!(unreadable.drain_change_batches().is_empty());
1549        assert!(unreadable.drain_sync_intents().is_empty());
1550    }
1551
1552    #[test]
1553    fn local_rebootstrap_cannot_bypass_schema_floor() {
1554        let mut client = client();
1555        client.set_schema_floor(Some(SchemaFloor {
1556            required_schema_version: Some(2),
1557            latest_schema_version: Some(2),
1558        }));
1559        let error = client
1560            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1561                rebootstrap_id: "blocked-floor".to_owned(),
1562            })
1563            .expect_err("schema floor must block repair");
1564        assert!(error.contains("cannot bypass an active schema-floor stop"));
1565    }
1566
1567    #[test]
1568    fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
1569        let mut client = client();
1570        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1571        const COMMIT_COUNT: usize = 32;
1572
1573        for index in 0..COMMIT_COUNT {
1574            client
1575                .mutate(vec![Mutation::Upsert {
1576                    table: "tasks".to_owned(),
1577                    values: Map::from_iter([
1578                        ("id".to_owned(), Value::from(format!("task-{index}"))),
1579                        ("project_id".to_owned(), Value::from("project-1")),
1580                    ]),
1581                    base_version: None,
1582                }])
1583                .expect("queue commit");
1584        }
1585
1586        let (_, request_meta) = client.build_request(false);
1587        assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
1588        let mut frames = vec![Frame::RespHeader {
1589            required_schema_version: None,
1590            latest_schema_version: None,
1591            log_epoch: Some("epoch-1".to_owned()),
1592            reset_required: Some(false),
1593        }];
1594        frames.extend(request_meta.pushed_ids.iter().enumerate().map(
1595            |(index, client_commit_id)| Frame::PushResult {
1596                client_commit_id: client_commit_id.clone(),
1597                status: PushStatus::Applied,
1598                commit_seq: Some(index as i64 + 1),
1599                results: vec![OpResult::Applied { op_index: 0 }],
1600            },
1601        ));
1602        let response = Message {
1603            wire_version: WIRE_VERSION,
1604            msg_kind: MsgKind::Response,
1605            frames,
1606        };
1607        let mut transport =
1608            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
1609
1610        client.overlay_rebuild_count.set(0);
1611        client.outcome_prune_count.set(0);
1612        let outcome = client.process_response(&mut transport, response, &request_meta);
1613        assert!(matches!(outcome, SyncOutcome::Ok(_)));
1614        assert!(client.pending_commit_ids().is_empty());
1615        assert_eq!(
1616            client.overlay_rebuild_count.get(),
1617            1,
1618            "one response must reconcile its acknowledged commits with one overlay rebuild"
1619        );
1620        assert_eq!(
1621            client.outcome_prune_count.get(),
1622            1,
1623            "one response must enforce outcome retention once"
1624        );
1625    }
1626
1627    #[test]
1628    fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
1629        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
1630            "columns": [
1631                {"name":"id", "type":"string", "nullable":false},
1632                {"name":"project_id", "type":"string", "nullable":false},
1633                {"name":"title", "type":"string", "nullable":false}
1634            ], "scopes": [{"pattern":"project:{project_id}"}],
1635            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1636            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
1637        }] });
1638        let mut incremental =
1639            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1640        let mut reference =
1641            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1642        let tables = vec!["tasks".to_owned()];
1643        for index in 0..32 {
1644            let id = format!("task-{}", index % 8);
1645            let values = Map::from_iter([
1646                ("id".to_owned(), Value::from(id.clone())),
1647                (
1648                    "project_id".to_owned(),
1649                    Value::from(format!("p{}", index % 2)),
1650                ),
1651                ("title".to_owned(), Value::from(format!("needle{index}"))),
1652            ]);
1653            let payload = encode_row_json(
1654                incremental.schema.table("tasks").unwrap(),
1655                &id,
1656                &values,
1657                &incremental.encryption,
1658            )
1659            .unwrap();
1660            let change = ssp2::model::Change {
1661                table_index: 0,
1662                row_id: id,
1663                op: if index % 5 == 0 {
1664                    Op::Delete
1665                } else {
1666                    Op::Upsert
1667                },
1668                row_version: Some(index + 1),
1669                scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
1670                row: Some(payload),
1671            };
1672            reference.overlay_dirty.set(true);
1673            for instance in [&mut incremental, &mut reference] {
1674                instance.begin_observation("mirror_test").unwrap();
1675                let mut batch = ChangeAccumulator::default();
1676                instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
1677                instance
1678                    .apply_commit_changes(&tables, std::slice::from_ref(&change))
1679                    .unwrap();
1680                instance.rebuild_overlay_if_dirty();
1681                instance.finish_observation("mirror_test", batch).unwrap();
1682            }
1683            for sql in [
1684                "SELECT * FROM tasks ORDER BY id",
1685                "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
1686            ] {
1687                assert_eq!(
1688                    incremental.query(sql, &[]).unwrap(),
1689                    reference.query(sql, &[]).unwrap()
1690                );
1691            }
1692            assert_eq!(incremental.local_revision(), reference.local_revision());
1693            assert_eq!(
1694                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1695                serde_json::to_value(reference.drain_change_batches()).unwrap()
1696            );
1697        }
1698        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
1699        assert_eq!(reference.overlay_rebuild_count.get(), 32);
1700    }
1701
1702    #[test]
1703    fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
1704        let cases = [
1705            (
1706                "string",
1707                vec![
1708                    json!("1"),
1709                    json!("01"),
1710                    json!(""),
1711                    json!("nul\u{0}id"),
1712                    json!("é"),
1713                ],
1714            ),
1715            (
1716                "integer",
1717                vec![
1718                    json!(i64::MIN),
1719                    json!(-1),
1720                    json!(0),
1721                    json!(1),
1722                    json!(i64::MAX),
1723                ],
1724            ),
1725            ("boolean", vec![json!(false), json!(true)]),
1726            ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
1727        ];
1728        for (kind, ids) in cases {
1729            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1730                "columns":[{"name":"id","type":kind,"nullable":false},
1731                    {"name":"project_id","type":"string","nullable":false}],
1732                "scopes":[{"pattern":"project:{project_id}"}]}]});
1733            let mut client =
1734                SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
1735            for (index, id) in ids.iter().enumerate() {
1736                let table = client.schema.table("tasks").unwrap();
1737                client
1738                    .write_base_row(
1739                        "tasks",
1740                        &vec![
1741                            json_to_column_value(&table.columns[0], Some(id)).unwrap(),
1742                            Some(ColumnValue::String(format!("p{index}"))),
1743                        ],
1744                        1,
1745                    )
1746                    .unwrap();
1747            }
1748            client.rebuild_overlay_if_dirty();
1749            for base in [true, false] {
1750                let full = if base {
1751                    base_table("tasks")
1752                } else {
1753                    visible_table("tasks")
1754                };
1755                let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
1756                for row_id in [
1757                    "1",
1758                    "01",
1759                    "1.0",
1760                    "1.0000000000000002",
1761                    "+1",
1762                    "1e0",
1763                    " 1",
1764                    "-1",
1765                    "0",
1766                    "9223372036854775807",
1767                    "-9223372036854775808",
1768                    "true",
1769                    "false",
1770                    "",
1771                    "nul\u{0}id",
1772                    "é",
1773                    "null",
1774                    "{\"a\":1}",
1775                ] {
1776                    let old_sql =
1777                        format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
1778                    let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
1779                    let old = client
1780                        .conn
1781                        .prepare(&old_sql)
1782                        .unwrap()
1783                        .query_map([row_id], |row| row.get::<_, String>(0))
1784                        .unwrap()
1785                        .collect::<Result<Vec<_>, _>>()
1786                        .unwrap();
1787                    let new = client
1788                        .conn
1789                        .prepare_cached(&new_sql)
1790                        .unwrap()
1791                        .query_map([row_id], |row| row.get::<_, String>(0))
1792                        .unwrap()
1793                        .collect::<Result<Vec<_>, _>>()
1794                        .unwrap();
1795                    assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
1796                    let mut batch = ChangeAccumulator::default();
1797                    assert_eq!(
1798                        client.record_row_scopes(&mut batch, "tasks", row_id, base),
1799                        !old.is_empty()
1800                    );
1801                    let expected = old.first().map(|scope| {
1802                        BTreeMap::from([(
1803                            "tasks".to_owned(),
1804                            Some(BTreeSet::from([format!("project:{scope}")])),
1805                        )])
1806                    });
1807                    assert_eq!(batch.tables, expected.unwrap_or_default());
1808                    client
1809                        .conn
1810                        .execute_batch("SAVEPOINT lookup_delete")
1811                        .unwrap();
1812                    if base {
1813                        client.delete_base_row("tasks", row_id).unwrap();
1814                    } else {
1815                        client.apply_outbox_ops(&[OutboxOp {
1816                            upsert: false,
1817                            table: "tasks".into(),
1818                            row_id: row_id.into(),
1819                            base_version: None,
1820                            values: None,
1821                        }]);
1822                    }
1823                    let remaining: i64 = client
1824                        .conn
1825                        .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
1826                            row.get(0)
1827                        })
1828                        .unwrap();
1829                    assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
1830                    client
1831                        .conn
1832                        .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
1833                        .unwrap();
1834                    client.overlay_dirty.set(false);
1835                }
1836                for (predicate, expected) in [
1837                    ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
1838                    (predicate, "SEARCH"),
1839                ] {
1840                    let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
1841                    let details = client
1842                        .conn
1843                        .prepare(&sql)
1844                        .unwrap()
1845                        .query_map(["1"], |row| row.get::<_, String>(3))
1846                        .unwrap()
1847                        .collect::<Result<Vec<_>, _>>()
1848                        .unwrap();
1849                    assert!(
1850                        details.iter().any(|detail| detail.contains(expected)),
1851                        "{kind}: {details:?}"
1852                    );
1853                }
1854            }
1855        }
1856    }
1857
1858    #[test]
1859    fn primary_keys_reject_every_ineligible_column_type() {
1860        // §2.4: a `rowId` is a string, so a primary key type whose string form
1861        // differs per renderer, or cannot be reproduced by local storage
1862        // comparison, is a schema error rather than a silent lookup miss.
1863        for ty in ["float", "bytes", "crdt", "blob_ref"] {
1864            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1865                "columns":[{"name":"id","type":ty,"nullable":false},
1866                    {"name":"project_id","type":"string","nullable":false}],
1867                "scopes":[{"pattern":"project:{project_id}"}]}]});
1868            let error =
1869                match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
1870                    Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
1871                    Err(error) => error,
1872                };
1873            assert!(
1874                error.contains("has an ineligible column type (§2.4)"),
1875                "{ty}: {error}"
1876            );
1877        }
1878        for ty in ["string", "integer", "boolean", "json"] {
1879            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1880                "columns":[{"name":"id","type":ty,"nullable":false},
1881                    {"name":"project_id","type":"string","nullable":false}],
1882                "scopes":[{"pattern":"project:{project_id}"}]}]});
1883            SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
1884                .unwrap_or_else(|error| panic!("{ty}: {error}"));
1885        }
1886    }
1887
1888    #[test]
1889    fn pending_row_reconciliation_matches_full_fifo_replay() {
1890        for unique in [false, true] {
1891            let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
1892                "name": name, "primaryKey": "id", "columns": [
1893                    {"name":"id", "type":"string", "nullable":false},
1894                    {"name":"project_id", "type":"string", "nullable":false},
1895                    {"name":"title", "type":"string", "nullable":false}],
1896                "scopes": [{"pattern":"project:{project_id}"}],
1897                "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
1898                "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
1899            })))});
1900            let mut incremental =
1901                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
1902            let mut reference =
1903                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
1904            let tables = vec!["tasks".to_owned(), "notes".to_owned()];
1905            for instance in [&mut incremental, &mut reference] {
1906                for table in &tables {
1907                    for index in 0..12 {
1908                        instance
1909                            .write_base_row(
1910                                table,
1911                                &vec![
1912                                    Some(ColumnValue::String(format!("row{index}"))),
1913                                    Some(ColumnValue::String("p0".into())),
1914                                    Some(ColumnValue::String(format!("base{index}"))),
1915                                ],
1916                                1,
1917                            )
1918                            .unwrap();
1919                    }
1920                }
1921                instance.rebuild_overlay_if_dirty();
1922            }
1923            for index in 0..80 {
1924                let mutations = (0..3)
1925                    .map(|offset| {
1926                        let table = tables[(index + offset) % 2].clone();
1927                        let id = format!("row{}", (index + offset) % 16);
1928                        if (index + offset) % 7 == 0 {
1929                            Mutation::Delete {
1930                                table,
1931                                row_id: id,
1932                                base_version: None,
1933                            }
1934                        } else {
1935                            Mutation::Upsert {
1936                                table,
1937                                values: Map::from_iter([
1938                                    ("id".into(), Value::from(id)),
1939                                    ("project_id".into(), Value::from(format!("p{}", index % 3))),
1940                                    (
1941                                        "title".into(),
1942                                        Value::from(format!("needle{}", (index + offset) % 5)),
1943                                    ),
1944                                ]),
1945                                base_version: None,
1946                            }
1947                        }
1948                    })
1949                    .collect::<Vec<_>>();
1950                for instance in [&mut incremental, &mut reference] {
1951                    instance.mutate(mutations.clone()).unwrap();
1952                }
1953                // Most frames touch only the table without unique constraints.
1954                // Mixed frames must retain full replay for both tables.
1955                let changes = (0..3)
1956                    .map(|offset| {
1957                        let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
1958                        let id = format!("row{}", (index + offset) % 16);
1959                        let values = Map::from_iter([
1960                            ("id".into(), Value::from(id.clone())),
1961                            (
1962                                "project_id".into(),
1963                                Value::from(format!("p{}", (index + 1) % 3)),
1964                            ),
1965                            (
1966                                "title".into(),
1967                                Value::from(format!("remote{index}_{offset}")),
1968                            ),
1969                        ]);
1970                        ssp2::model::Change {
1971                            table_index: table_index as u16,
1972                            row_id: id.clone(),
1973                            op: if (index + offset) % 5 == 0 {
1974                                Op::Delete
1975                            } else {
1976                                Op::Upsert
1977                            },
1978                            row_version: Some(index as i64 + 2),
1979                            scopes: vec![],
1980                            row: Some(
1981                                encode_row_json(
1982                                    incremental.schema.table(&tables[table_index]).unwrap(),
1983                                    &id,
1984                                    &values,
1985                                    &incremental.encryption,
1986                                )
1987                                .unwrap(),
1988                            ),
1989                        }
1990                    })
1991                    .collect::<Vec<_>>();
1992                reference.overlay_dirty.set(true);
1993                incremental.apply_commit_frame(&tables, &changes).unwrap();
1994                reference.apply_commit_frame(&tables, &changes).unwrap();
1995                for table in &tables {
1996                    for sql in [format!("SELECT * FROM {table} ORDER BY id"),
1997                        format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
1998                        format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
1999                        format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
2000                        assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
2001                    }
2002                }
2003                assert_eq!(incremental.local_revision(), reference.local_revision());
2004                assert_eq!(
2005                    serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2006                    serde_json::to_value(reference.drain_change_batches()).unwrap()
2007                );
2008            }
2009            assert_eq!(
2010                incremental.overlay_rebuild_count.get(),
2011                if unique { 81 } else { 21 }
2012            );
2013            assert_eq!(reference.overlay_rebuild_count.get(), 81);
2014        }
2015    }
2016
2017    #[test]
2018    fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
2019        let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
2020            "columns":[{"name":"id", "type":"string", "nullable":false},
2021                {"name":"title", "type":"string", "nullable":false}],
2022            "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
2023        let mut client =
2024            SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
2025        for (id, title) in [("a", "original"), ("b", "occupied")] {
2026            client
2027                .write_base_row(
2028                    "tasks",
2029                    &vec![
2030                        Some(ColumnValue::String(id.into())),
2031                        Some(ColumnValue::String(title.into())),
2032                    ],
2033                    1,
2034                )
2035                .unwrap();
2036        }
2037        client.rebuild_overlay_if_dirty();
2038        let pending = client
2039            .mutate(vec![Mutation::Upsert {
2040                table: "tasks".into(),
2041                values: Map::from_iter([
2042                    ("id".into(), Value::from("a")),
2043                    ("title".into(), Value::from("occupied")),
2044                ]),
2045                base_version: None,
2046            }])
2047            .unwrap();
2048        assert_eq!(
2049            client
2050                .conn
2051                .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2052                    .get::<_, String>(
2053                    0
2054                ))
2055                .unwrap(),
2056            "original"
2057        );
2058        for (title, expected) in [("available", "occupied"), ("occupied", "original")] {
2059            let change = ssp2::model::Change {
2060                table_index: 0,
2061                row_id: "b".into(),
2062                op: Op::Upsert,
2063                row_version: Some(2),
2064                scopes: vec![],
2065                row: Some(
2066                    encode_row_json(
2067                        client.schema.table("tasks").unwrap(),
2068                        "b",
2069                        &Map::from_iter([
2070                            ("id".into(), Value::from("b")),
2071                            ("title".into(), Value::from(title)),
2072                        ]),
2073                        &client.encryption,
2074                    )
2075                    .unwrap(),
2076                ),
2077            };
2078            client
2079                .apply_commit_frame(&["tasks".into()], &[change])
2080                .unwrap();
2081            assert_eq!(
2082                client
2083                    .conn
2084                    .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2085                        .get::<_, String>(
2086                        0
2087                    ))
2088                    .unwrap(),
2089                expected
2090            );
2091            assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
2092        }
2093        assert_eq!(client.overlay_rebuild_count.get(), 3);
2094    }
2095
2096    #[test]
2097    fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
2098        for failure in ["visible", "revision", "commit"] {
2099            let mut client = client();
2100            let id = client
2101                .mutate(vec![Mutation::Upsert {
2102                    table: "tasks".into(),
2103                    values: Map::from_iter([
2104                        ("id".into(), Value::from("t1")),
2105                        ("project_id".into(), Value::from("pending")),
2106                    ]),
2107                    base_version: None,
2108                }])
2109                .unwrap();
2110            client.drain_change_batches();
2111            let revision = client.local_revision();
2112            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
2113            let trigger = match failure {
2114                "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
2115                "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"),
2116                _ => "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(),
2117            };
2118            client.conn.execute_batch(&trigger).unwrap();
2119            let change = ssp2::model::Change {
2120                table_index: 0,
2121                row_id: "t1".into(),
2122                op: Op::Upsert,
2123                row_version: Some(2),
2124                scopes: vec![],
2125                row: Some(
2126                    encode_row_json(
2127                        client.schema.table("tasks").unwrap(),
2128                        "t1",
2129                        &Map::from_iter([
2130                            ("id".into(), Value::from("t1")),
2131                            ("project_id".into(), Value::from("remote")),
2132                        ]),
2133                        &client.encryption,
2134                    )
2135                    .unwrap(),
2136                ),
2137            };
2138            assert!(client
2139                .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change))
2140                .is_err());
2141            assert!(client.conn.is_autocommit());
2142            assert!(!client.overlay_dirty.get());
2143            assert_eq!(client.local_revision(), revision);
2144            assert!(client.drain_change_batches().is_empty());
2145            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
2146            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
2147            assert!(client
2148                .query("SELECT * FROM _syncular_base_tasks", &[])
2149                .unwrap()
2150                .is_empty());
2151            client
2152                .conn
2153                .execute_batch("DROP TRIGGER fail_pending")
2154                .unwrap();
2155            client
2156                .apply_commit_frame(&["tasks".into()], &[change])
2157                .unwrap();
2158            assert_eq!(client.local_revision(), revision + 1);
2159            assert_eq!(client.pending_commit_ids(), vec![id]);
2160            // The overlay keeps the pending write visible; the row now carries
2161            // the server row's version it is layered on (§7.1).
2162            let mut expected = before[0].clone();
2163            expected.insert("_syncular_version".to_owned(), Value::from(2));
2164            assert_eq!(
2165                client.query("SELECT * FROM tasks", &[]).unwrap(),
2166                vec![expected]
2167            );
2168            assert_eq!(
2169                client
2170                    .conn
2171                    .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
2172                        .get::<_, String>(0))
2173                    .unwrap(),
2174                "remote"
2175            );
2176        }
2177    }
2178
2179    #[test]
2180    fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
2181        let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
2182            "columns": [
2183                {"name":"id", "type":"string", "nullable":false},
2184                {"name":"project_id", "type":"string", "nullable":false},
2185                {"name":"title", "type":"string", "nullable":false}],
2186            "scopes": [{"pattern":"project:{project_id}"}],
2187            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2188            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
2189        let mut incremental =
2190            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2191        let mut reference =
2192            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2193        for instance in [&mut incremental, &mut reference] {
2194            for index in 0..16 {
2195                instance
2196                    .write_base_row(
2197                        "tasks",
2198                        &vec![
2199                            Some(ColumnValue::String(format!("task{index}"))),
2200                            Some(ColumnValue::String("p0".to_owned())),
2201                            Some(ColumnValue::String(format!("base{index}"))),
2202                        ],
2203                        1,
2204                    )
2205                    .unwrap();
2206            }
2207            instance.rebuild_overlay_if_dirty();
2208            instance.overlay_rebuild_count.set(0);
2209        }
2210        for index in 0..100 {
2211            let mutations = (0..1 + index % 4)
2212                .map(|offset| {
2213                    let id = format!("task{}", (index + offset) % 24);
2214                    if (index + offset) % 7 == 0 {
2215                        Mutation::Delete {
2216                            table: "tasks".to_owned(),
2217                            row_id: id,
2218                            base_version: None,
2219                        }
2220                    } else {
2221                        Mutation::Upsert {
2222                            table: "tasks".to_owned(),
2223                            values: Map::from_iter([
2224                                ("id".to_owned(), Value::from(id)),
2225                                (
2226                                    "project_id".to_owned(),
2227                                    Value::from(format!("p{}", index % 2)),
2228                                ),
2229                                (
2230                                    "title".to_owned(),
2231                                    Value::from(format!("needle{}", (index + offset) % 5)),
2232                                ),
2233                            ]),
2234                            base_version: None,
2235                        }
2236                    }
2237                })
2238                .collect::<Vec<_>>();
2239            reference.overlay_dirty.set(true);
2240            incremental.mutate(mutations.clone()).unwrap();
2241            reference.mutate(mutations).unwrap();
2242            for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2243                "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
2244                assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
2245            }
2246            assert_eq!(incremental.local_revision(), reference.local_revision());
2247            assert_eq!(
2248                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2249                serde_json::to_value(reference.drain_change_batches()).unwrap()
2250            );
2251        }
2252        assert_eq!(incremental.outbox.len(), 100);
2253        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2254        assert_eq!(reference.overlay_rebuild_count.get(), 100);
2255    }
2256
2257    #[test]
2258    fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
2259        for fail_at in ["outbox", "revision"] {
2260            let mut client = client();
2261            let values = Map::from_iter([
2262                ("id".to_owned(), Value::from("t1")),
2263                ("project_id".to_owned(), Value::from("p1")),
2264            ]);
2265            let mutation = Mutation::Upsert {
2266                table: "tasks".to_owned(),
2267                values,
2268                base_version: None,
2269            };
2270            let trigger = if fail_at == "outbox" {
2271                "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
2272            } else {
2273                format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
2274            };
2275            client.conn.execute_batch(&trigger).unwrap();
2276            let revision = client.local_revision();
2277            assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
2278            assert_eq!(client.local_revision(), revision);
2279            assert!(client.outbox.is_empty());
2280            assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2281            assert_eq!(
2282                client
2283                    .conn
2284                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
2285                        .get::<_, i64>(0))
2286                    .unwrap(),
2287                0
2288            );
2289            assert!(client.drain_change_batches().is_empty());
2290            assert!(!client.overlay_dirty.get());
2291            client
2292                .conn
2293                .execute_batch("DROP TRIGGER fail_append")
2294                .unwrap();
2295            client.mutate(vec![mutation]).unwrap();
2296            assert_eq!(client.outbox.len(), 1);
2297            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
2298        }
2299    }
2300
2301    #[test]
2302    fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
2303        let mut client = client();
2304        let tables = vec!["tasks".to_owned(), "missing".to_owned()];
2305        let values = Map::from_iter([
2306            ("id".to_owned(), Value::from("t1")),
2307            ("project_id".to_owned(), Value::from("p1")),
2308        ]);
2309        let payload = encode_row_json(
2310            client.schema.table("tasks").unwrap(),
2311            "t1",
2312            &values,
2313            &client.encryption,
2314        )
2315        .unwrap();
2316        let first = ssp2::model::Change {
2317            table_index: 0,
2318            row_id: "t1".to_owned(),
2319            op: Op::Upsert,
2320            row_version: Some(1),
2321            scopes: vec![("project_id".to_owned(), "p1".to_owned())],
2322            row: Some(payload),
2323        };
2324        let mut invalid = first.clone();
2325        invalid.table_index = 1;
2326        let revision = client.local_revision();
2327        client.begin_observation("failed_mirror").unwrap();
2328        assert!(client
2329            .apply_commit_changes(&tables, &[first, invalid])
2330            .is_err());
2331        client.rollback_observation("failed_mirror");
2332        client.rebuild_overlay_if_dirty();
2333        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2334        assert_eq!(
2335            client
2336                .conn
2337                .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
2338                    .get::<_, i64>(
2339                    0
2340                ))
2341                .unwrap(),
2342            0
2343        );
2344        assert_eq!(client.local_revision(), revision);
2345        assert!(client.drain_change_batches().is_empty());
2346    }
2347
2348    #[test]
2349    fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
2350        for failure in ["journal", "revision", "commit", "later-frame"] {
2351            let mut client = client();
2352            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2353            for id in ["first", "second"] {
2354                client
2355                    .mutate(vec![Mutation::Upsert {
2356                        table: "tasks".to_owned(),
2357                        values: Map::from_iter([
2358                            ("id".to_owned(), Value::from(id)),
2359                            ("project_id".to_owned(), Value::from("p1")),
2360                        ]),
2361                        base_version: None,
2362                    }])
2363                    .unwrap();
2364            }
2365            let (_, meta) = client.build_request(false);
2366            // A malicious or stale response must not drain an unsent local commit.
2367            client
2368                .mutate(vec![Mutation::Upsert {
2369                    table: "tasks".to_owned(),
2370                    values: Map::from_iter([
2371                        ("id".to_owned(), Value::from("later")),
2372                        ("project_id".to_owned(), Value::from("p1")),
2373                    ]),
2374                    base_version: None,
2375                }])
2376                .unwrap();
2377            let ids = client.pending_commit_ids();
2378            let rows = client
2379                .query("SELECT * FROM tasks ORDER BY id", &[])
2380                .unwrap();
2381            let revision = client.local_revision();
2382            client.drain_change_batches();
2383            if failure != "later-frame" {
2384                client.conn.execute_batch(match failure {
2385                    "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",
2386                    "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",
2387                    _ => "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",
2388                }).unwrap();
2389            }
2390            let mut response = Message {
2391                wire_version: WIRE_VERSION,
2392                msg_kind: MsgKind::Response,
2393                frames: vec![Frame::RespHeader {
2394                    required_schema_version: None,
2395                    latest_schema_version: None,
2396                    log_epoch: Some("epoch-1".to_owned()),
2397                    reset_required: Some(false),
2398                }],
2399            };
2400            for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
2401                response.frames.push(Frame::PushResult {
2402                    client_commit_id: id.to_owned(),
2403                    status: PushStatus::Applied,
2404                    commit_seq: Some(1),
2405                    results: vec![OpResult::Applied { op_index: 0 }],
2406                });
2407            }
2408            if failure == "later-frame" {
2409                response.frames.push(Frame::Error {
2410                    code: "sync.invalid_request".to_owned(),
2411                    message: "later error".to_owned(),
2412                    category: "protocol".to_owned(),
2413                    retryable: false,
2414                    recommended_action: "retry".to_owned(),
2415                    details: None,
2416                });
2417            }
2418            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2419            let outcome = client.process_response(&mut transport, response.clone(), &meta);
2420            assert!(
2421                matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
2422            );
2423            if failure == "later-frame" {
2424                assert_eq!(client.pending_commit_ids(), ids[2..]);
2425                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2426                assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
2427                let batches = client.drain_change_batches();
2428                assert_eq!(batches.len(), 1);
2429                assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
2430                assert_eq!(client.local_revision(), revision + 1);
2431                response.frames.pop();
2432            } else {
2433                assert_eq!(client.pending_commit_ids(), ids);
2434                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2435                assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
2436                assert_eq!(client.local_revision(), revision);
2437                assert!(client.drain_change_batches().is_empty());
2438                assert_eq!(
2439                    client
2440                        .query("SELECT * FROM tasks ORDER BY id", &[])
2441                        .unwrap(),
2442                    rows
2443                );
2444                client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
2445            }
2446            for frame in &mut response.frames {
2447                if let Frame::PushResult { status, .. } = frame {
2448                    *status = PushStatus::Cached;
2449                }
2450            }
2451            let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
2452            else {
2453                panic!("retry failed");
2454            };
2455            assert_eq!(
2456                report.applied,
2457                if failure == "later-frame" {
2458                    vec![]
2459                } else {
2460                    ids[..2].to_vec()
2461                }
2462            );
2463            assert_eq!(client.pending_commit_ids(), ids[2..]);
2464            let batches = client.drain_change_batches();
2465            assert_eq!(batches.len(), usize::from(failure != "later-frame"));
2466            if let Some(batch) = batches.first() {
2467                assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
2468            }
2469            assert_eq!(client.local_revision(), revision + 1);
2470        }
2471    }
2472
2473    #[test]
2474    fn failed_push_result_preserves_memory_disk_and_retry_identity() {
2475        for failure in ["revision", "journal", "commit"] {
2476            for status in ["applied", "cached", "rejected", "conflict"] {
2477                let mut client = client();
2478                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2479                for id in ["first", "later"] {
2480                    client
2481                        .mutate(vec![Mutation::Upsert {
2482                            table: "tasks".to_owned(),
2483                            values: Map::from_iter([
2484                                ("id".to_owned(), Value::from(id)),
2485                                ("project_id".to_owned(), Value::from("p1")),
2486                            ]),
2487                            base_version: None,
2488                        }])
2489                        .unwrap();
2490                }
2491                let (_, meta) = client.build_request(false);
2492                let ids = client.pending_commit_ids();
2493                let rows = client
2494                    .query("SELECT * FROM tasks ORDER BY id", &[])
2495                    .unwrap();
2496                let revision = client.local_revision();
2497                client.drain_change_batches();
2498                let (push_status, results) = match status {
2499                    "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
2500                    "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
2501                    "conflict" => (
2502                        PushStatus::Rejected,
2503                        vec![OpResult::Conflict {
2504                            op_index: 0,
2505                            code: "sync.version_conflict".to_owned(),
2506                            message: "conflict".to_owned(),
2507                            server_version: 1,
2508                            server_row: encode_row_json(
2509                                client.schema.table("tasks").unwrap(),
2510                                "first",
2511                                &Map::from_iter([
2512                                    ("id".to_owned(), Value::from("first")),
2513                                    ("project_id".to_owned(), Value::from("p1")),
2514                                ]),
2515                                &client.encryption,
2516                            )
2517                            .unwrap(),
2518                            conflict_columns: Vec::new(),
2519                        }],
2520                    ),
2521                    _ => (
2522                        PushStatus::Rejected,
2523                        vec![OpResult::Error {
2524                            op_index: 0,
2525                            code: "sync.validation_failed".to_owned(),
2526                            message: "rejected".to_owned(),
2527                            retryable: false,
2528                        }],
2529                    ),
2530                };
2531                let response = Message {
2532                    wire_version: WIRE_VERSION,
2533                    msg_kind: MsgKind::Response,
2534                    frames: vec![
2535                        Frame::RespHeader {
2536                            required_schema_version: None,
2537                            latest_schema_version: None,
2538                            log_epoch: Some("epoch-1".to_owned()),
2539                            reset_required: Some(false),
2540                        },
2541                        Frame::PushResult {
2542                            client_commit_id: ids[0].clone(),
2543                            status: push_status,
2544                            commit_seq: Some(1),
2545                            results,
2546                        },
2547                    ],
2548                };
2549                client.conn.execute_batch(match failure {
2550                    "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
2551                    "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
2552                    _ => "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",
2553                }).unwrap();
2554                let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2555                let outcome = client.process_response(&mut transport, response.clone(), &meta);
2556                assert_eq!(
2557                    client.pending_commit_ids(),
2558                    ids,
2559                    "{failure}/{status}: pending identity"
2560                );
2561                assert_eq!(
2562                    client.local_revision(),
2563                    revision,
2564                    "{failure}/{status}: revision"
2565                );
2566                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2567                assert!(client.conflicts().is_empty());
2568                assert!(client.rejections().is_empty());
2569                assert!(client.drain_change_batches().is_empty());
2570                assert_eq!(
2571                    client
2572                        .query("SELECT * FROM tasks ORDER BY id", &[])
2573                        .unwrap(),
2574                    rows
2575                );
2576                assert!(
2577                    matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
2578                    "{failure}/{status}: failed round"
2579                );
2580                client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
2581                assert!(matches!(
2582                    client.process_response(&mut transport, response, &meta),
2583                    SyncOutcome::Ok(_)
2584                ));
2585                assert_eq!(client.pending_commit_ids(), ids[1..]);
2586                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2587                assert_eq!(client.drain_change_batches().len(), 1);
2588            }
2589        }
2590    }
2591
2592    #[test]
2593    fn mixed_push_results_reconcile_and_prune_once_per_response() {
2594        let mut client = client();
2595        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2596        for index in 0..4 {
2597            client
2598                .mutate(vec![Mutation::Upsert {
2599                    table: "tasks".to_owned(),
2600                    values: Map::from_iter([
2601                        ("id".to_owned(), Value::from(format!("task-{index}"))),
2602                        ("project_id".to_owned(), Value::from("project-1")),
2603                    ]),
2604                    base_version: None,
2605                }])
2606                .expect("queue commit");
2607        }
2608
2609        let (_, request_meta) = client.build_request(false);
2610        let ids = &request_meta.pushed_ids;
2611        assert_eq!(ids.len(), 4);
2612        let response = Message {
2613            wire_version: WIRE_VERSION,
2614            msg_kind: MsgKind::Response,
2615            frames: vec![
2616                Frame::RespHeader {
2617                    required_schema_version: None,
2618                    latest_schema_version: None,
2619                    log_epoch: Some("epoch-1".to_owned()),
2620                    reset_required: Some(false),
2621                },
2622                Frame::PushResult {
2623                    client_commit_id: ids[0].clone(),
2624                    status: PushStatus::Applied,
2625                    commit_seq: Some(1),
2626                    results: vec![OpResult::Applied { op_index: 0 }],
2627                },
2628                Frame::PushResult {
2629                    client_commit_id: ids[1].clone(),
2630                    status: PushStatus::Cached,
2631                    commit_seq: Some(2),
2632                    results: vec![OpResult::Applied { op_index: 0 }],
2633                },
2634                Frame::PushResult {
2635                    client_commit_id: ids[2].clone(),
2636                    status: PushStatus::Rejected,
2637                    commit_seq: None,
2638                    results: vec![OpResult::Error {
2639                        op_index: 0,
2640                        code: "sync.validation_failed".to_owned(),
2641                        message: "rejected".to_owned(),
2642                        retryable: false,
2643                    }],
2644                },
2645                Frame::PushResult {
2646                    client_commit_id: ids[3].clone(),
2647                    status: PushStatus::Rejected,
2648                    commit_seq: None,
2649                    results: vec![OpResult::Error {
2650                        op_index: 0,
2651                        code: "sync.idempotency_cache_miss".to_owned(),
2652                        message: "retry".to_owned(),
2653                        retryable: true,
2654                    }],
2655                },
2656            ],
2657        };
2658        let mut transport =
2659            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
2660
2661        client.overlay_rebuild_count.set(0);
2662        client.outcome_prune_count.set(0);
2663        let outcome = client.process_response(&mut transport, response, &request_meta);
2664        let SyncOutcome::Ok(report) = outcome else {
2665            panic!("mixed push-result response failed");
2666        };
2667
2668        assert_eq!(report.applied, ids[..2]);
2669        assert_eq!(report.rejected, ids[2..3]);
2670        assert_eq!(report.retryable, ids[3..4]);
2671        assert_eq!(client.pending_commit_ids(), ids[3..4]);
2672        assert_eq!(
2673            client
2674                .query("SELECT id FROM tasks ORDER BY id", &[])
2675                .expect("query visible overlay"),
2676            vec![Map::from_iter([("id".to_owned(), Value::from("task-3"))])]
2677        );
2678        assert_eq!(client.overlay_rebuild_count.get(), 1);
2679        assert_eq!(client.outcome_prune_count.get(), 1);
2680    }
2681
2682    #[test]
2683    fn secondary_unique_collision_preserves_existing_synced_row() {
2684        let client = SyncClient::new(
2685            "unique-upsert-test".to_owned(),
2686            &json!({
2687                "version": 1,
2688                "tables": [{
2689                    "name": "tasks",
2690                    "primaryKey": "id",
2691                    "columns": [
2692                        { "name": "id", "type": "string", "nullable": false },
2693                        { "name": "project_id", "type": "string", "nullable": false },
2694                        { "name": "title", "type": "string", "nullable": false }
2695                    ],
2696                    "scopes": [{ "pattern": "project:{project_id}" }],
2697                    "indexes": [{
2698                        "name": "tasks_by_project_title",
2699                        "columns": ["project_id", "title"],
2700                        "unique": true
2701                    }]
2702                }]
2703            }),
2704            ClientLimits::default(),
2705        )
2706        .expect("test client");
2707        let table = client.schema.table("tasks").expect("tasks table");
2708        let sql = client.insert_row_sql(&base_table("tasks"), table);
2709
2710        client
2711            .conn
2712            .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
2713            .expect("insert first row");
2714        client
2715            .conn
2716            .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
2717            .expect("update same primary key");
2718        client
2719            .conn
2720            .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
2721            .expect("insert second row");
2722        assert!(client
2723            .conn
2724            .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
2725            .is_err());
2726
2727        let rows = client
2728            .conn
2729            .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
2730            .expect("prepare rows")
2731            .query_map([], |row| {
2732                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2733            })
2734            .expect("query rows")
2735            .collect::<Result<Vec<_>, _>>()
2736            .expect("collect rows");
2737        assert_eq!(
2738            rows,
2739            vec![
2740                ("t1".to_owned(), "updated".to_owned()),
2741                ("t2".to_owned(), "original".to_owned())
2742            ]
2743        );
2744    }
2745
2746    #[test]
2747    fn reopening_active_subscriptions_emits_a_catch_up_intent() {
2748        let path = std::env::temp_dir().join(format!(
2749            "syncular-startup-intent-{}.db",
2750            uuid::Uuid::new_v4()
2751        ));
2752        let schema = json!({
2753            "version": 1,
2754            "tables": [{
2755                "name": "tasks",
2756                "primaryKey": "id",
2757                "columns": [
2758                    { "name": "id", "type": "string", "nullable": false },
2759                    { "name": "project_id", "type": "string", "nullable": false }
2760                ],
2761                "scopes": [{ "pattern": "project:{project_id}" }]
2762            }]
2763        });
2764
2765        {
2766            let mut first = SyncClient::open_path_with_identity(
2767                None,
2768                &schema,
2769                ClientLimits::default(),
2770                path.to_str().expect("UTF-8 temp path"),
2771            )
2772            .expect("first open");
2773            first
2774                .set_window(
2775                    &WindowBase {
2776                        table: "tasks".to_owned(),
2777                        variable: "project_id".to_owned(),
2778                        fixed_scopes: Vec::new(),
2779                        params: None,
2780                    },
2781                    &["persisted".to_owned()],
2782                )
2783                .expect("persist window");
2784        }
2785
2786        let mut reopened = SyncClient::open_path_with_identity(
2787            None,
2788            &schema,
2789            ClientLimits::default(),
2790            path.to_str().expect("UTF-8 temp path"),
2791        )
2792        .expect("reopen");
2793        assert!(reopened.sync_needed());
2794        assert!(matches!(
2795            reopened.drain_sync_intents().as_slice(),
2796            [SyncIntent::Interactive]
2797        ));
2798        drop(reopened);
2799        std::fs::remove_file(path).expect("remove temp database");
2800    }
2801
2802    #[test]
2803    fn reopening_preserves_immutable_subscription_identity_and_progress() {
2804        let path = std::env::temp_dir().join(format!(
2805            "syncular-subscription-identity-{}.db",
2806            uuid::Uuid::new_v4()
2807        ));
2808        let schema = json!({
2809            "version": 1,
2810            "tables": [
2811                {
2812                    "name": "tasks",
2813                    "primaryKey": "id",
2814                    "columns": [
2815                        { "name": "id", "type": "string", "nullable": false },
2816                        { "name": "project_id", "type": "string", "nullable": false }
2817                    ],
2818                    "scopes": [{ "pattern": "project:{project_id}" }]
2819                },
2820                {
2821                    "name": "docs",
2822                    "primaryKey": "id",
2823                    "columns": [
2824                        { "name": "id", "type": "string", "nullable": false },
2825                        { "name": "org_id", "type": "string", "nullable": false },
2826                        { "name": "project_id", "type": "string", "nullable": false }
2827                    ],
2828                    "scopes": [
2829                        { "pattern": "org:{org_id}" },
2830                        { "pattern": "project:{projectId}", "column": "project_id" }
2831                    ]
2832                }
2833            ]
2834        });
2835
2836        {
2837            let mut first = SyncClient::open_path_with_identity(
2838                None,
2839                &schema,
2840                ClientLimits::default(),
2841                path.to_str().expect("UTF-8 temp path"),
2842            )
2843            .expect("first open");
2844            first
2845                .subscribe(
2846                    "stable-subscription".to_owned(),
2847                    "tasks".to_owned(),
2848                    vec![(
2849                        "project_id".to_owned(),
2850                        vec!["p2".to_owned(), "p1".to_owned()],
2851                    )],
2852                    Some(r#"{"view":"v1"}"#.to_owned()),
2853                )
2854                .expect("persist subscription");
2855            let persisted = {
2856                let subscription = first
2857                    .subs
2858                    .iter_mut()
2859                    .find(|subscription| subscription.id == "stable-subscription")
2860                    .expect("subscription");
2861                subscription.cursor = 41;
2862                subscription.bootstrap_state = Some("resume-token".to_owned());
2863                subscription.effective = Some(vec![(
2864                    "project_id".to_owned(),
2865                    vec!["p1".to_owned(), "p2".to_owned()],
2866                )]);
2867                subscription.synced_once = true;
2868                subscription.clone()
2869            };
2870            first.persist_sub(&persisted).unwrap();
2871        }
2872
2873        let mut reopened = SyncClient::open_path_with_identity(
2874            None,
2875            &schema,
2876            ClientLimits::default(),
2877            path.to_str().expect("UTF-8 temp path"),
2878        )
2879        .expect("reopen");
2880        let progress = reopened
2881            .subscription_state("stable-subscription")
2882            .expect("persisted state");
2883        assert_eq!(progress.cursor, 41);
2884        assert!(progress.has_resume_token);
2885
2886        reopened
2887            .subscribe(
2888                "stable-subscription".to_owned(),
2889                "tasks".to_owned(),
2890                vec![(
2891                    "project_id".to_owned(),
2892                    vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
2893                )],
2894                Some(r#"{"view":"v1"}"#.to_owned()),
2895            )
2896            .expect("canonical intent is idempotent");
2897        assert_eq!(
2898            reopened
2899                .subscription_state("stable-subscription")
2900                .expect("unchanged state")
2901                .cursor,
2902            progress.cursor
2903        );
2904
2905        for (table, scopes, params) in [
2906            (
2907                "tasks",
2908                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2909                Some(r#"{"view":"v1"}"#.to_owned()),
2910            ),
2911            (
2912                "tasks",
2913                vec![(
2914                    "project_id".to_owned(),
2915                    vec!["p2".to_owned(), "p1".to_owned()],
2916                )],
2917                Some(r#"{"view":"v2"}"#.to_owned()),
2918            ),
2919            (
2920                "docs",
2921                vec![
2922                    ("org_id".to_owned(), vec!["o1".to_owned()]),
2923                    ("projectId".to_owned(), vec!["p1".to_owned()]),
2924                ],
2925                Some(r#"{"view":"v1"}"#.to_owned()),
2926            ),
2927        ] {
2928            let error = reopened
2929                .subscribe(
2930                    "stable-subscription".to_owned(),
2931                    table.to_owned(),
2932                    scopes,
2933                    params,
2934                )
2935                .expect_err("identity rebind must fail");
2936            assert!(error.starts_with("client.subscription_intent_mismatch:"));
2937            assert_eq!(
2938                reopened
2939                    .subscription_state("stable-subscription")
2940                    .expect("unchanged state")
2941                    .cursor,
2942                progress.cursor
2943            );
2944        }
2945
2946        drop(reopened);
2947        std::fs::remove_file(path).expect("remove temp database");
2948    }
2949
2950    #[test]
2951    fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
2952        let path = std::env::temp_dir().join(format!(
2953            "syncular-satisfied-schema-floor-{}.db",
2954            uuid::Uuid::new_v4()
2955        ));
2956        let schema = json!({
2957            "version": 23,
2958            "tables": [{
2959                "name": "tasks",
2960                "primaryKey": "id",
2961                "columns": [
2962                    { "name": "id", "type": "string", "nullable": false },
2963                    { "name": "project_id", "type": "string", "nullable": false }
2964                ],
2965                "scopes": [{ "pattern": "project:{project_id}" }]
2966            }]
2967        });
2968
2969        {
2970            let mut first = SyncClient::open_path_with_identity(
2971                None,
2972                &schema,
2973                ClientLimits::default(),
2974                path.to_str().expect("UTF-8 temp path"),
2975            )
2976            .expect("first open");
2977            first
2978                .subscribe(
2979                    "tasks".to_owned(),
2980                    "tasks".to_owned(),
2981                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2982                    None,
2983                )
2984                .expect("persist subscription");
2985            first.set_schema_floor(Some(SchemaFloor {
2986                required_schema_version: Some(22),
2987                latest_schema_version: Some(22),
2988            }));
2989        }
2990
2991        let mut reopened = SyncClient::open_path_with_identity(
2992            None,
2993            &schema,
2994            ClientLimits::default(),
2995            path.to_str().expect("UTF-8 temp path"),
2996        )
2997        .expect("reopen");
2998        assert!(reopened.schema_floor().is_none());
2999        assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
3000        assert!(reopened.sync_needed());
3001        assert!(matches!(
3002            reopened.drain_sync_intents().as_slice(),
3003            [SyncIntent::Interactive]
3004        ));
3005        drop(reopened);
3006        std::fs::remove_file(path).expect("remove temp database");
3007    }
3008
3009    #[test]
3010    fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
3011        let path = std::env::temp_dir().join(format!(
3012            "syncular-unsatisfied-schema-floor-{}.db",
3013            uuid::Uuid::new_v4()
3014        ));
3015        let schema = json!({
3016            "version": 1,
3017            "tables": [{
3018                "name": "tasks",
3019                "primaryKey": "id",
3020                "columns": [
3021                    { "name": "id", "type": "string", "nullable": false },
3022                    { "name": "project_id", "type": "string", "nullable": false }
3023                ],
3024                "scopes": [{ "pattern": "project:{project_id}" }]
3025            }]
3026        });
3027
3028        {
3029            let mut first = SyncClient::open_path_with_identity(
3030                None,
3031                &schema,
3032                ClientLimits::default(),
3033                path.to_str().expect("UTF-8 temp path"),
3034            )
3035            .expect("first open");
3036            first
3037                .subscribe(
3038                    "tasks".to_owned(),
3039                    "tasks".to_owned(),
3040                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3041                    None,
3042                )
3043                .expect("persist subscription");
3044            first.set_schema_floor(Some(SchemaFloor {
3045                required_schema_version: Some(2),
3046                latest_schema_version: Some(2),
3047            }));
3048        }
3049
3050        let reopened = SyncClient::open_path_with_identity(
3051            None,
3052            &schema,
3053            ClientLimits::default(),
3054            path.to_str().expect("UTF-8 temp path"),
3055        )
3056        .expect("reopen");
3057        assert_eq!(
3058            reopened.schema_floor(),
3059            Some(&SchemaFloor {
3060                required_schema_version: Some(2),
3061                latest_schema_version: Some(2),
3062            })
3063        );
3064        assert!(!reopened.sync_needed());
3065        drop(reopened);
3066        std::fs::remove_file(path).expect("remove temp database");
3067    }
3068
3069    #[test]
3070    fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
3071        let path = std::env::temp_dir().join(format!(
3072            "syncular-indexed-column-bump-{}.db",
3073            uuid::Uuid::new_v4()
3074        ));
3075        let old_schema = json!({
3076            "version": 1,
3077            "tables": [
3078                {
3079                    "name": "tasks",
3080                    "primaryKey": "id",
3081                    "columns": [
3082                        { "name": "id", "type": "string", "nullable": false },
3083                        { "name": "project_id", "type": "string", "nullable": false }
3084                    ],
3085                    "scopes": [{ "pattern": "project:{project_id}" }]
3086                },
3087                {
3088                    "name": "legacy",
3089                    "primaryKey": "id",
3090                    "columns": [
3091                        { "name": "id", "type": "string", "nullable": false },
3092                        { "name": "project_id", "type": "string", "nullable": false }
3093                    ],
3094                    "scopes": [{ "pattern": "project:{project_id}" }]
3095                }
3096            ]
3097        });
3098        {
3099            let mut first = SyncClient::open_path_with_identity(
3100                None,
3101                &old_schema,
3102                ClientLimits::default(),
3103                path.to_str().expect("UTF-8 temp path"),
3104            )
3105            .expect("open old schema");
3106            first
3107                .subscribe(
3108                    "legacy-sub".to_owned(),
3109                    "legacy".to_owned(),
3110                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3111                    None,
3112                )
3113                .expect("persist legacy subscription");
3114        }
3115
3116        let new_schema = json!({
3117            "version": 2,
3118            "tables": [{
3119                "name": "tasks",
3120                "primaryKey": "id",
3121                "columns": [
3122                    { "name": "id", "type": "string", "nullable": false },
3123                    { "name": "project_id", "type": "string", "nullable": false },
3124                    { "name": "facility_membership_id", "type": "string", "nullable": true }
3125                ],
3126                "scopes": [{ "pattern": "project:{project_id}" }],
3127                "indexes": [{
3128                    "name": "tasks_by_membership",
3129                    "columns": ["project_id", "facility_membership_id"],
3130                    "unique": false
3131                }]
3132            }]
3133        });
3134        let upgraded = SyncClient::open_path_with_identity(
3135            None,
3136            &new_schema,
3137            ClientLimits::default(),
3138            path.to_str().expect("UTF-8 temp path"),
3139        )
3140        .expect("open upgraded schema");
3141        let columns = upgraded
3142            .conn
3143            .prepare("PRAGMA table_info(tasks)")
3144            .expect("prepare columns")
3145            .query_map([], |row| row.get::<_, String>(1))
3146            .expect("query columns")
3147            .collect::<Result<Vec<_>, _>>()
3148            .expect("collect columns");
3149        assert!(columns.contains(&"facility_membership_id".to_owned()));
3150        let index_count: i64 = upgraded
3151            .conn
3152            .query_row(
3153                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
3154                [],
3155                |row| row.get(0),
3156            )
3157            .expect("query index");
3158        assert_eq!(index_count, 1);
3159        assert!(upgraded.subscription_state("legacy-sub").is_none());
3160        drop(upgraded);
3161        std::fs::remove_file(path).expect("remove temp database");
3162    }
3163
3164    #[test]
3165    fn migrates_pre_envelope_outcome_journal_additively() {
3166        let path = std::env::temp_dir().join(format!(
3167            "syncular-outcome-migration-{}.db",
3168            uuid::Uuid::new_v4()
3169        ));
3170        let conn = Connection::open(&path).expect("open legacy database");
3171        conn.execute_batch(
3172            "CREATE TABLE _syncular_commit_outcomes (
3173               seq INTEGER PRIMARY KEY AUTOINCREMENT,
3174               client_commit_id TEXT NOT NULL UNIQUE,
3175               status TEXT NOT NULL,
3176               recorded_at_ms INTEGER NOT NULL,
3177               results_json TEXT NOT NULL,
3178               resolution TEXT NOT NULL DEFAULT 'active',
3179               resolved_at_ms INTEGER,
3180               replacement_client_commit_id TEXT);",
3181        )
3182        .expect("create legacy outcome journal");
3183        drop(conn);
3184        let schema = json!({
3185            "version": 1,
3186            "tables": [{
3187                "name": "tasks",
3188                "primaryKey": "id",
3189                "columns": [
3190                    { "name": "id", "type": "string", "nullable": false },
3191                    { "name": "project_id", "type": "string", "nullable": false }
3192                ],
3193                "scopes": [{ "pattern": "project:{project_id}" }]
3194            }]
3195        });
3196        let client = SyncClient::open_path(
3197            "migration-native".to_owned(),
3198            &schema,
3199            ClientLimits::default(),
3200            path.to_str().expect("UTF-8 temp path"),
3201        )
3202        .expect("migrate database");
3203        let has_operations = client
3204            .conn
3205            .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
3206            .expect("prepare table info")
3207            .query_map([], |row| row.get::<_, String>(1))
3208            .expect("query table info")
3209            .filter_map(Result::ok)
3210            .any(|column| column == "operations_json");
3211        assert!(has_operations);
3212        drop(client);
3213        std::fs::remove_file(path).expect("remove temp database");
3214    }
3215
3216    #[test]
3217    fn durable_conflict_outcome_and_resolution_survive_reopen() {
3218        let path = std::env::temp_dir().join(format!(
3219            "syncular-durable-outcome-{}.db",
3220            uuid::Uuid::new_v4()
3221        ));
3222        let schema = json!({
3223            "version": 1,
3224            "tables": [{
3225                "name": "tasks",
3226                "primaryKey": "id",
3227                "columns": [
3228                    { "name": "id", "type": "string", "nullable": false },
3229                    { "name": "project_id", "type": "string", "nullable": false }
3230                ],
3231                "scopes": [{ "pattern": "project:{project_id}" }]
3232            }]
3233        });
3234        let conflict = ConflictRecord {
3235            client_commit_id: "losing-commit".to_owned(),
3236            op_index: 0,
3237            table: "tasks".to_owned(),
3238            row_id: "t1".to_owned(),
3239            code: "sync.version_conflict".to_owned(),
3240            message: "stale base version".to_owned(),
3241            server_version: 2,
3242            server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
3243            conflict_columns: vec!["project_id".to_owned()],
3244            operation: Some(CommitOperation {
3245                table: "tasks".to_owned(),
3246                row_id: "t1".to_owned(),
3247                op: "upsert".to_owned(),
3248                base_version: Some(1),
3249                values: None,
3250            }),
3251        };
3252        let failed_operations = vec![
3253            OutboxOp {
3254                upsert: true,
3255                table: "tasks".to_owned(),
3256                row_id: "t1".to_owned(),
3257                base_version: Some(1),
3258                values: None,
3259            },
3260            OutboxOp {
3261                upsert: true,
3262                table: "tasks".to_owned(),
3263                row_id: "status-event-1".to_owned(),
3264                base_version: Some(0),
3265                values: None,
3266            },
3267        ];
3268
3269        {
3270            let mut first = SyncClient::open_path(
3271                "durable-native".to_owned(),
3272                &schema,
3273                ClientLimits::default(),
3274                path.to_str().expect("UTF-8 temp path"),
3275            )
3276            .expect("first open");
3277            first
3278                .begin_observation("test_outcome")
3279                .expect("begin outcome");
3280            first
3281                .persist_commit_outcome(
3282                    "losing-commit",
3283                    CommitOutcomeStatus::Conflict,
3284                    &[CommitOperationOutcome::Conflict {
3285                        conflict: conflict.clone(),
3286                    }],
3287                    Some(&failed_operations),
3288                )
3289                .expect("persist outcome");
3290            first.conflicts.push(conflict);
3291            first
3292                .finish_observation(
3293                    "test_outcome",
3294                    ChangeAccumulator {
3295                        conflicts: true,
3296                        outcomes: true,
3297                        ..ChangeAccumulator::default()
3298                    },
3299                )
3300                .expect("commit outcome");
3301        }
3302
3303        {
3304            let mut reopened = SyncClient::open_path(
3305                "durable-native".to_owned(),
3306                &schema,
3307                ClientLimits::default(),
3308                path.to_str().expect("UTF-8 temp path"),
3309            )
3310            .expect("reopen");
3311            assert_eq!(reopened.conflicts().len(), 1);
3312            assert_eq!(
3313                reopened.conflicts()[0].conflict_columns,
3314                vec!["project_id".to_owned()],
3315                "conflictColumns survives the journal round trip"
3316            );
3317            let outcome = reopened
3318                .commit_outcome("losing-commit")
3319                .expect("read outcome")
3320                .expect("outcome");
3321            assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
3322            let operations = outcome.operations.expect("aggregate envelope");
3323            assert_eq!(operations.len(), 2);
3324            assert_eq!(operations[1].row_id, "status-event-1");
3325            let resolved = reopened
3326                .resolve_commit_outcome(ResolveCommitOutcomeInput {
3327                    client_commit_id: "losing-commit".to_owned(),
3328                    resolution: CommitOutcomeResolution::ResolvedKeepServer,
3329                    replacement_client_commit_id: None,
3330                })
3331                .expect("resolve");
3332            assert_eq!(
3333                resolved.resolution,
3334                CommitOutcomeResolution::ResolvedKeepServer
3335            );
3336            assert!(reopened.conflicts().is_empty());
3337        }
3338
3339        let reopened = SyncClient::open_path(
3340            "durable-native".to_owned(),
3341            &schema,
3342            ClientLimits::default(),
3343            path.to_str().expect("UTF-8 temp path"),
3344        )
3345        .expect("second reopen");
3346        assert!(reopened.conflicts().is_empty());
3347        assert_eq!(
3348            reopened
3349                .commit_outcome("losing-commit")
3350                .expect("read outcome")
3351                .expect("outcome")
3352                .resolution,
3353            CommitOutcomeResolution::ResolvedKeepServer
3354        );
3355        drop(reopened);
3356        std::fs::remove_file(path).expect("remove temp database");
3357    }
3358
3359    #[test]
3360    fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
3361        let path =
3362            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
3363        let schema = json!({
3364            "version": 1,
3365            "tables": [{
3366                "name": "tasks",
3367                "primaryKey": "id",
3368                "columns": [
3369                    { "name": "id", "type": "string", "nullable": false },
3370                    { "name": "project_id", "type": "string", "nullable": false }
3371                ],
3372                "scopes": [{ "pattern": "project:{project_id}" }]
3373            }]
3374        });
3375        let mut client = SyncClient::open_path_with_identity(
3376            Some("sidecar-client".to_owned()),
3377            &schema,
3378            ClientLimits::default(),
3379            path.to_str().expect("UTF-8 temp path"),
3380        )
3381        .expect("open owner");
3382        let base = WindowBase {
3383            table: "tasks".to_owned(),
3384            variable: "project_id".to_owned(),
3385            fixed_scopes: Vec::new(),
3386            params: None,
3387        };
3388        client
3389            .set_window(&base, &["one".to_owned()])
3390            .expect("set window");
3391        client
3392            .mutate(vec![Mutation::Upsert {
3393                table: "tasks".to_owned(),
3394                values: Map::from_iter([
3395                    ("id".to_owned(), Value::from("t1")),
3396                    ("project_id".to_owned(), Value::from("one")),
3397                ]),
3398                base_version: None,
3399            }])
3400            .expect("local mutate");
3401
3402        let coverage = [WindowCoverage {
3403            base,
3404            units: vec!["one".to_owned(), "missing".to_owned()],
3405        }];
3406        let owner = client
3407            .query_snapshot(
3408                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3409                &[],
3410                &coverage,
3411            )
3412            .expect("owner snapshot");
3413        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
3414        let sidecar = reader
3415            .query_snapshot(
3416                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3417                &[],
3418                &coverage,
3419            )
3420            .expect("sidecar snapshot");
3421
3422        assert_eq!(sidecar.revision, owner.revision);
3423        assert_eq!(sidecar.rows, owner.rows);
3424        assert_eq!(
3425            serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
3426            serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
3427        );
3428        assert_eq!(sidecar.revision, "2");
3429        assert_eq!(sidecar.rows[0]["id"], "t1");
3430        assert_eq!(sidecar.rows[0]["server_version"], -1);
3431        assert!(!sidecar.coverage.complete);
3432        assert_eq!(sidecar.coverage.pending.len(), 1);
3433        assert_eq!(sidecar.coverage.missing.len(), 1);
3434
3435        drop(reader);
3436        drop(client);
3437        std::fs::remove_file(path).expect("remove temp database");
3438    }
3439
3440    #[test]
3441    fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
3442        let mut client = client();
3443        let image = Connection::open_in_memory().unwrap();
3444        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
3445            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3446            INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
3447            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3448            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
3449        let table = client.schema.table("tasks").unwrap().clone();
3450        let scopes = vec![("project_id".into(), vec!["p1".into()])];
3451        assert!(client
3452            .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
3453            .is_err());
3454        assert!(client.conn.is_autocommit());
3455        assert_eq!(
3456            client
3457                .query("SELECT count(*) AS n FROM tasks", &[])
3458                .unwrap()[0]["n"],
3459            1024
3460        );
3461        assert_eq!(client.drain_change_batches().len(), 1);
3462        image
3463            .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
3464            .unwrap();
3465        assert!(matches!(
3466            client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
3467            Ok(2050)
3468        ));
3469        assert_eq!(
3470            client
3471                .query("SELECT count(*) AS n FROM tasks", &[])
3472                .unwrap()[0]["n"],
3473            2050
3474        );
3475        assert_eq!(client.drain_change_batches().len(), 3);
3476    }
3477
3478    #[test]
3479    fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
3480        let mut client = client();
3481        let base = WindowBase {
3482            table: "tasks".into(),
3483            variable: "project_id".into(),
3484            fixed_scopes: Vec::new(),
3485            params: None,
3486        };
3487        client
3488            .set_window(&base, &["p1".into(), "p2".into()])
3489            .unwrap();
3490        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3491            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3492            INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
3493            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3494            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3495        client.drain_change_batches();
3496        assert!(client.set_window(&base, &["p2".into()]).is_err());
3497        assert!(client.conn.is_autocommit());
3498        assert_eq!(
3499            client
3500                .query("SELECT count(*) AS n FROM tasks", &[])
3501                .unwrap()[0]["n"],
3502            1027
3503        );
3504        assert_eq!(client.window_state(&base).units, vec!["p2"]);
3505        assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
3506        assert_eq!(client.drain_change_batches().len(), 1);
3507        client
3508            .conn
3509            .execute_batch("DROP TRIGGER interrupt_evict")
3510            .unwrap();
3511        client.drain_pending_evictions().unwrap();
3512        assert!(client.load_pending_evictions().unwrap().is_empty());
3513        assert_eq!(
3514            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3515            "held"
3516        );
3517        assert_eq!(client.drain_change_batches().len(), 2);
3518    }
3519
3520    #[test]
3521    fn eviction_marker_failures_roll_back_window_changes() {
3522        let mut client = client();
3523        let base = WindowBase {
3524            table: "tasks".into(),
3525            variable: "project_id".into(),
3526            fixed_scopes: Vec::new(),
3527            params: None,
3528        };
3529        client.set_window(&base, &["p1".into()]).unwrap();
3530        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3531            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3532            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3533            CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3534        let revision = client.local_revision();
3535        assert!(client.set_window(&base, &[]).is_err());
3536        assert!(client.conn.is_autocommit());
3537        assert_eq!(client.local_revision(), revision);
3538        assert_eq!(
3539            client
3540                .query("SELECT count(*) AS n FROM tasks", &[])
3541                .unwrap()[0]["n"],
3542            2050
3543        );
3544        assert_eq!(client.window_state(&base).units, vec!["p1"]);
3545        client.conn.execute_batch("DROP TRIGGER fail_marker;
3546            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3547        assert!(client.set_window(&base, &[]).is_err());
3548        client.conn.execute_batch("DROP TRIGGER interrupt_evict;
3549            CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3550        assert!(client.drain_pending_evictions().is_err());
3551        assert!(client.conn.is_autocommit());
3552        assert_eq!(
3553            client
3554                .query("SELECT count(*) AS n FROM tasks", &[])
3555                .unwrap()[0]["n"],
3556            2
3557        );
3558        assert!(client.set_window(&base, &["p1".into()]).is_err());
3559        assert!(client.conn.is_autocommit());
3560        assert!(client.window_state(&base).units.is_empty());
3561        assert!(client.subs.is_empty());
3562        assert_eq!(
3563            client
3564                .query(
3565                    "SELECT count(*) AS n FROM _syncular_window_pending_evict",
3566                    &[]
3567                )
3568                .unwrap()[0]["n"],
3569            1
3570        );
3571        client
3572            .conn
3573            .execute_batch("DROP TRIGGER fail_marker")
3574            .unwrap();
3575        client.set_window(&base, &["p1".into()]).unwrap();
3576        assert_eq!(client.window_state(&base).units, vec!["p1"]);
3577    }
3578
3579    #[test]
3580    fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
3581        let mut client = client();
3582        client
3583            .conn
3584            .execute(
3585                "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
3586                [],
3587            )
3588            .unwrap();
3589        assert_eq!(
3590            client.drain_pending_evictions().unwrap_err(),
3591            "sync.local_corrupt: invalid pending eviction scopes"
3592        );
3593        assert!(client.conn.is_autocommit());
3594        assert!(client.enqueue_startup_sync_if_needed().is_err());
3595    }
3596
3597    #[test]
3598    fn image_chunks_reject_null_primary_keys() {
3599        let mut client = client();
3600        let image = Connection::open_in_memory().unwrap();
3601        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3602            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
3603            INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
3604            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3605            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
3606        let table = client.schema.table("tasks").unwrap().clone();
3607        assert!(client
3608            .apply_sqlite_image(
3609                &image,
3610                &table,
3611                true,
3612                &[("project_id".into(), vec!["p1".into()])],
3613                1025,
3614                7,
3615                "digest"
3616            )
3617            .is_err());
3618        assert!(client.conn.is_autocommit());
3619        assert_eq!(
3620            client
3621                .query("SELECT count(*) AS n FROM tasks", &[])
3622                .unwrap()[0]["n"],
3623            0
3624        );
3625    }
3626
3627    #[test]
3628    fn import_chunks_with_unique_constraints_match_full_pending_replay() {
3629        for images in [false, true] {
3630            let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
3631                "name":name,"primaryKey":"id","columns":[
3632                    {"name":"id","type":"string","nullable":false},
3633                    {"name":"project_id","type":"string","nullable":false},
3634                    {"name":"title","type":"string","nullable":false}],
3635                "scopes":[{"pattern":"project:{project_id}"}],
3636                "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
3637                "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
3638            })))});
3639            let mut actual =
3640                SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
3641            let mut reference =
3642                SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
3643            let scopes = vec![("project_id".into(), vec!["p1".into()])];
3644            for client in [&mut actual, &mut reference] {
3645                client
3646                    .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
3647                    .unwrap();
3648                client.subs[0].effective = Some(scopes.clone());
3649                for (table, id, title) in [
3650                    ("tasks", "a", "original"),
3651                    ("tasks", "b", "occupied"),
3652                    ("tasks", "c", "deleted"),
3653                    ("notes", "n", "untouched"),
3654                ] {
3655                    client
3656                        .write_base_row(
3657                            table,
3658                            &vec![
3659                                Some(ColumnValue::String(id.into())),
3660                                Some(ColumnValue::String("p1".into())),
3661                                Some(ColumnValue::String(title.into())),
3662                            ],
3663                            1,
3664                        )
3665                        .unwrap();
3666                }
3667                client.rebuild_overlay_if_dirty();
3668                client
3669                    .mutate(vec![
3670                        Mutation::Upsert {
3671                            table: "tasks".into(),
3672                            values: Map::from_iter([
3673                                ("id".into(), Value::from("a")),
3674                                ("project_id".into(), Value::from("p1")),
3675                                ("title".into(), Value::from("occupied")),
3676                            ]),
3677                            base_version: None,
3678                        },
3679                        Mutation::Delete {
3680                            table: "tasks".into(),
3681                            row_id: "c".into(),
3682                            base_version: None,
3683                        },
3684                        Mutation::Upsert {
3685                            table: "tasks".into(),
3686                            values: Map::from_iter([
3687                                ("id".into(), Value::from("local")),
3688                                ("project_id".into(), Value::from("p1")),
3689                                ("title".into(), Value::from("local value")),
3690                            ]),
3691                            base_version: None,
3692                        },
3693                        Mutation::Upsert {
3694                            table: "notes".into(),
3695                            values: Map::from_iter([
3696                                ("id".into(), Value::from("n")),
3697                                ("project_id".into(), Value::from("p1")),
3698                                ("title".into(), Value::from("pending note")),
3699                            ]),
3700                            base_version: None,
3701                        },
3702                    ])
3703                    .unwrap();
3704            }
3705            let pending = actual.pending_commit_ids();
3706            actual.overlay_rebuild_count.set(0);
3707            for (id, title) in [
3708                ("b", "available"),
3709                ("b", "occupied"),
3710                ("a", "server original"),
3711                ("d", "local value"),
3712                ("d", "released"),
3713                ("c", "redelivered"),
3714            ] {
3715                let table = actual.schema.table("tasks").unwrap().clone();
3716                let values = vec![
3717                    Some(ColumnValue::String(id.into())),
3718                    Some(ColumnValue::String("p1".into())),
3719                    Some(ColumnValue::String(title.into())),
3720                ];
3721                if images {
3722                    let image = Connection::open_in_memory().unwrap();
3723                    image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3724                        CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3725                        INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
3726                    image
3727                        .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
3728                        .unwrap();
3729                    assert!(matches!(
3730                        actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
3731                        Ok(1)
3732                    ));
3733                } else {
3734                    let segment = RowsSegment {
3735                        table: "tasks".into(),
3736                        schema_version: 1,
3737                        columns: table.wire_columns.clone(),
3738                        blocks: vec![vec![ssp2::segment::SegmentRow {
3739                            server_version: 2,
3740                            values: values.clone(),
3741                        }]],
3742                    };
3743                    assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
3744                }
3745                reference.write_base_row("tasks", &values, 2).unwrap();
3746                reference.rebuild_overlay();
3747                for table in ["tasks", "notes"] {
3748                    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")] {
3749                        assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
3750                    }
3751                }
3752                assert_eq!(actual.pending_commit_ids(), pending);
3753            }
3754            assert_eq!(
3755                actual.overlay_rebuild_count.get(),
3756                0,
3757                "each chunk must leave unrelated tables alone"
3758            );
3759            let before = actual
3760                .query("SELECT * FROM tasks ORDER BY id", &[])
3761                .unwrap();
3762            let fts_before = actual
3763                .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
3764                .unwrap();
3765            let revision = actual.local_revision();
3766            actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
3767            let table = actual.schema.table("tasks").unwrap().clone();
3768            let segment = RowsSegment {
3769                table: "tasks".into(),
3770                schema_version: 1,
3771                columns: table.wire_columns.clone(),
3772                blocks: vec![vec![ssp2::segment::SegmentRow {
3773                    server_version: 3,
3774                    values: vec![
3775                        Some(ColumnValue::String("b".into())),
3776                        Some(ColumnValue::String("p1".into())),
3777                        Some(ColumnValue::String("fresh".into())),
3778                    ],
3779                }]],
3780            };
3781            assert!(actual.apply_segment(0, &segment, false).is_err());
3782            assert!(actual.conn.is_autocommit());
3783            assert_eq!(
3784                actual
3785                    .query("SELECT * FROM tasks ORDER BY id", &[])
3786                    .unwrap(),
3787                before
3788            );
3789            assert_eq!(
3790                actual
3791                    .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
3792                    .unwrap(),
3793                fts_before
3794            );
3795            assert_eq!(actual.local_revision(), revision);
3796            assert_eq!(actual.pending_commit_ids(), pending);
3797        }
3798    }
3799
3800    #[test]
3801    fn import_reconciliation_preserves_typed_pending_primary_keys() {
3802        for (kind, a, b) in [
3803            ("string", json!("a"), json!("b")),
3804            ("integer", json!(1), json!(2)),
3805            ("boolean", json!(true), json!(false)),
3806            ("json", json!("1"), json!("2")),
3807        ] {
3808            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}]}]});
3809            for images in [false, true] {
3810                let mut client =
3811                    SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
3812                let table = client.schema.table("tasks").unwrap().clone();
3813                client
3814                    .subscribe("tasks".into(), "tasks".into(), vec![], None)
3815                    .unwrap();
3816                for (id, title) in [(&a, "original"), (&b, "occupied")] {
3817                    client
3818                        .write_base_row(
3819                            "tasks",
3820                            &vec![
3821                                json_to_column_value(&table.columns[0], Some(id)).unwrap(),
3822                                Some(ColumnValue::String(title.into())),
3823                            ],
3824                            1,
3825                        )
3826                        .unwrap();
3827                }
3828                client.rebuild_overlay_if_dirty();
3829                client
3830                    .mutate(vec![Mutation::Upsert {
3831                        table: "tasks".into(),
3832                        values: Map::from_iter([
3833                            ("id".into(), a.clone()),
3834                            ("title".into(), json!("occupied")),
3835                        ]),
3836                        base_version: None,
3837                    }])
3838                    .unwrap();
3839                client.overlay_rebuild_count.set(0);
3840                for (title, expected) in [("available", "occupied"), ("occupied", "original")] {
3841                    let values = vec![
3842                        json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
3843                        Some(ColumnValue::String(title.into())),
3844                    ];
3845                    if images {
3846                        let image = Connection::open_in_memory().unwrap();
3847                        image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3848                            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3849                            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
3850                        image
3851                            .execute(
3852                                "INSERT INTO tasks VALUES (?1,?2,2)",
3853                                rusqlite::params![RowParam::Cell(&values[0]), title],
3854                            )
3855                            .unwrap();
3856                        assert!(
3857                            matches!(
3858                                client.apply_sqlite_image(
3859                                    &image,
3860                                    &table,
3861                                    false,
3862                                    &[],
3863                                    1,
3864                                    7,
3865                                    "digest"
3866                                ),
3867                                Ok(1)
3868                            ),
3869                            "{kind}, images={images}"
3870                        );
3871                    } else {
3872                        let segment = RowsSegment {
3873                            table: "tasks".into(),
3874                            schema_version: 1,
3875                            columns: table.wire_columns.clone(),
3876                            blocks: vec![vec![ssp2::segment::SegmentRow {
3877                                server_version: 2,
3878                                values,
3879                            }]],
3880                        };
3881                        assert!(
3882                            matches!(client.apply_segment(0, &segment, false), Ok(1)),
3883                            "{kind}"
3884                        );
3885                    }
3886                    let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
3887                    let actual: String = client
3888                        .conn
3889                        .query_row(
3890                            "SELECT title FROM tasks WHERE id=?1",
3891                            [RowParam::Cell(&key)],
3892                            |row| row.get(0),
3893                        )
3894                        .unwrap();
3895                    assert_eq!(actual, expected, "{kind}, images={images}");
3896                }
3897                assert_eq!(client.overlay_rebuild_count.get(), 0);
3898            }
3899        }
3900    }
3901
3902    fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
3903        let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
3904            "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
3905            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
3906            "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
3907        })))});
3908        let mut client =
3909            SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
3910        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();
3911        client.rebuild_overlay();
3912        client
3913            .mutate(vec![Mutation::Upsert {
3914                table: "tasks".into(),
3915                values: Map::from_iter([
3916                    ("id".into(), json!("offline")),
3917                    ("project_id".into(), json!("p1")),
3918                    ("title".into(), json!("pending edit")),
3919                ]),
3920                base_version: None,
3921            }])
3922            .unwrap();
3923        let image = Connection::open_in_memory().unwrap();
3924        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3925            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
3926        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();
3927        image
3928            .execute(
3929                "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
3930                [rows],
3931            )
3932            .unwrap();
3933        client.overlay_rebuild_count.set(0);
3934        let table = client.schema.table("tasks").unwrap().clone();
3935        (client, image, table)
3936    }
3937
3938    #[test]
3939    fn large_unique_import_copies_no_unrelated_tables() {
3940        let (mut client, image, table) = unique_import_fixture(10240);
3941        let pending = client.pending_commit_ids();
3942        client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
3943            CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
3944        assert!(matches!(
3945            client.apply_sqlite_image(
3946                &image,
3947                &table,
3948                true,
3949                &[("project_id".into(), vec!["p1".into()])],
3950                10240,
3951                7,
3952                "digest"
3953            ),
3954            Ok(10240)
3955        ));
3956        assert_eq!(client.overlay_rebuild_count.get(), 0);
3957        assert_eq!(client.pending_commit_ids(), pending);
3958        assert_eq!(
3959            client
3960                .query("SELECT count(*) AS n FROM tasks", &[])
3961                .unwrap()[0]["n"],
3962            10241
3963        );
3964        assert_eq!(
3965            client
3966                .query("SELECT count(*) AS n FROM tasks_fts", &[])
3967                .unwrap()[0]["n"],
3968            10241
3969        );
3970        assert_eq!(
3971            client
3972                .query("SELECT count(*) AS n FROM notes_fts", &[])
3973                .unwrap()[0]["n"],
3974            8192
3975        );
3976    }
3977
3978    #[test]
3979    #[ignore = "manual scaling benchmark; see bench/README.md"]
3980    fn benchmark_unique_image_import() {
3981        let mut samples = Vec::new();
3982        for rows in [10240, 51200, 102400] {
3983            for trial in 0..4 {
3984                let (mut client, image, table) = unique_import_fixture(rows);
3985                let start = std::time::Instant::now();
3986                assert!(
3987                    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)
3988                );
3989                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
3990                assert_eq!(
3991                    client
3992                        .query("SELECT count(*) AS n FROM tasks", &[])
3993                        .unwrap()[0]["n"],
3994                    rows + 1
3995                );
3996                assert_eq!(
3997                    client
3998                        .query("SELECT count(*) AS n FROM tasks_fts", &[])
3999                        .unwrap()[0]["n"],
4000                    rows + 1
4001                );
4002                assert_eq!(
4003                    client
4004                        .query("SELECT count(*) AS n FROM notes_fts", &[])
4005                        .unwrap()[0]["n"],
4006                    8192
4007                );
4008                assert_eq!(client.pending_commit_ids().len(), 1);
4009                if trial > 0 {
4010                    samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
4011                }
4012            }
4013        }
4014        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});
4015        let output = serde_json::to_string_pretty(&artifact).unwrap();
4016        if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
4017            use std::io::Write;
4018            let mut file = std::fs::OpenOptions::new()
4019                .write(true)
4020                .create_new(true)
4021                .open(path)
4022                .unwrap();
4023            file.write_all(output.as_bytes()).unwrap();
4024        } else {
4025            println!("{output}");
4026        }
4027    }
4028
4029    #[test]
4030    fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
4031        let schema = json!({
4032            "version": 1,
4033            "tables": [{
4034                "name": "catalogue_codes",
4035                "primaryKey": "id",
4036                "columns": [
4037                    { "name": "id", "type": "string", "nullable": false },
4038                    { "name": "release_id", "type": "string", "nullable": false },
4039                    { "name": "code", "type": "string", "nullable": false },
4040                    { "name": "title", "type": "string", "nullable": false }
4041                ],
4042                "scopes": [{ "pattern": "release:{release_id}" }],
4043                "ftsIndexes": [{
4044                    "name": "catalogue_codes_fts",
4045                    "columns": ["code", "title"],
4046                    "tokenize": "unicode61 remove_diacritics 2"
4047                }]
4048            }]
4049        });
4050        let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
4051            .expect("FTS5 client");
4052        let insert_trigger: String = client
4053            .conn
4054            .query_row(
4055                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
4056                [],
4057                |row| row.get(0),
4058            )
4059            .expect("insert trigger");
4060        let replace_guard: String = client
4061            .conn
4062            .query_row(
4063                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
4064                [],
4065                |row| row.get(0),
4066            )
4067            .expect("replace guard");
4068        assert!(!insert_trigger.contains("DELETE FROM"));
4069        assert!(replace_guard.contains("BEFORE INSERT"));
4070        assert!(replace_guard.contains("WHEN EXISTS"));
4071        let search = |client: &SyncClient, query: &str| {
4072            client
4073                .query(
4074                    "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",
4075                    &[Value::from(query)],
4076                )
4077                .expect("FTS query")
4078        };
4079
4080        client
4081            .mutate(vec![Mutation::Upsert {
4082                table: "catalogue_codes".to_owned(),
4083                values: Map::from_iter([
4084                    ("id".to_owned(), Value::from("c1")),
4085                    ("release_id".to_owned(), Value::from("r1")),
4086                    ("code".to_owned(), Value::from("A01")),
4087                    ("title".to_owned(), Value::from("Cholera")),
4088                ]),
4089                base_version: None,
4090            }])
4091            .expect("insert code");
4092        assert_eq!(search(&client, "cholera").len(), 1);
4093        let prior = client
4094            .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4095            .unwrap();
4096        client
4097            .conn
4098            .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
4099            .unwrap();
4100        client.create_synced_tables().unwrap();
4101        assert_eq!(
4102            client
4103                .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4104                .unwrap(),
4105            prior
4106        );
4107        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();
4108        let rendered = serde_json::to_string(&plan).unwrap();
4109        assert!(rendered.contains("COVERING INDEX"));
4110        assert!(rendered.contains("INDEX 0:="));
4111        client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
4112        assert_eq!(search(&client, "cholera").len(), 1);
4113
4114        client
4115            .mutate(vec![Mutation::Upsert {
4116                table: "catalogue_codes".to_owned(),
4117                values: Map::from_iter([
4118                    ("id".to_owned(), Value::from("c1")),
4119                    ("release_id".to_owned(), Value::from("r1")),
4120                    ("code".to_owned(), Value::from("A01")),
4121                    ("title".to_owned(), Value::from("Enteric infection")),
4122                ]),
4123                base_version: None,
4124            }])
4125            .expect("update code");
4126        assert!(search(&client, "cholera").is_empty());
4127        assert_eq!(search(&client, "enteric").len(), 1);
4128
4129        client
4130            .mutate(vec![Mutation::Delete {
4131                table: "catalogue_codes".to_owned(),
4132                row_id: "c1".to_owned(),
4133                base_version: None,
4134            }])
4135            .expect("delete code");
4136        assert!(search(&client, "enteric").is_empty());
4137    }
4138
4139    #[test]
4140    fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
4141        let schema = json!({
4142            "version": 1,
4143            "tables": [{
4144                "name": "patient_notes",
4145                "primaryKey": "id",
4146                "columns": [
4147                    { "name": "id", "type": "string", "nullable": false },
4148                    { "name": "practice_id", "type": "string", "nullable": false },
4149                    { "name": "encryption_key_id", "type": "string", "nullable": false },
4150                    { "name": "title", "type": "string", "nullable": false }
4151                ],
4152                "scopes": [{ "pattern": "practice:{practice_id}" }],
4153                "ftsIndexes": [{
4154                    "name": "patient_notes_fts",
4155                    "columns": ["title"],
4156                    "tokenize": "unicode61 remove_diacritics 2"
4157                }]
4158            }]
4159        });
4160        let mut client = SyncClient::new(
4161            "local-purge-test".to_owned(),
4162            &schema,
4163            ClientLimits::default(),
4164        )
4165        .expect("local purge client");
4166        client
4167            .conn
4168            .execute(
4169                "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
4170                 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
4171                 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
4172                [],
4173            )
4174            .expect("seed base rows");
4175        client.overlay_dirty.set(true);
4176        client.rebuild_overlay_if_dirty();
4177
4178        let note = |id: &str, key_id: &str, title: &str| {
4179            Map::from_iter([
4180                ("id".to_owned(), Value::from(id)),
4181                ("practice_id".to_owned(), Value::from("practice-1")),
4182                ("encryption_key_id".to_owned(), Value::from(key_id)),
4183                ("title".to_owned(), Value::from(title)),
4184            ])
4185        };
4186        let doomed = client
4187            .mutate(vec![
4188                Mutation::Upsert {
4189                    table: "patient_notes".to_owned(),
4190                    values: note("target", "key-revoked", "Target changed"),
4191                    base_version: None,
4192                },
4193                Mutation::Upsert {
4194                    table: "patient_notes".to_owned(),
4195                    values: note("unrelated", "key-held", "Unrelated changed"),
4196                    base_version: None,
4197                },
4198            ])
4199            .expect("doomed commit");
4200        let kept = client
4201            .mutate(vec![Mutation::Upsert {
4202                table: "patient_notes".to_owned(),
4203                values: note("kept", "key-held", "Kept optimistic"),
4204                base_version: None,
4205            }])
4206            .expect("kept commit");
4207        client.drain_change_batches();
4208
4209        let input = LocalDataPurgeInput {
4210            purge_id: "purge-001".to_owned(),
4211            targets: vec![LocalDataPurgeTarget {
4212                table: "patient_notes".to_owned(),
4213                selectors: BTreeMap::from([(
4214                    "encryption_key_id".to_owned(),
4215                    vec!["key-revoked".to_owned()],
4216                )]),
4217            }],
4218        };
4219        assert_eq!(
4220            client.purge_local_data(&input).expect("apply purge"),
4221            LocalDataPurgeResult {
4222                already_applied: false,
4223                purged_rows: 1,
4224                dropped_commits: 1,
4225            }
4226        );
4227        let rows = client
4228            .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
4229            .expect("visible rows");
4230        assert_eq!(rows.len(), 2);
4231        assert_eq!(rows[0]["id"], "kept");
4232        assert_eq!(rows[1]["id"], "unrelated");
4233        assert_eq!(rows[1]["title"], "Unrelated original");
4234        let fts = client
4235            .query(
4236                "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'",
4237                &[],
4238            )
4239            .expect("fts query");
4240        assert!(fts.is_empty());
4241        assert_eq!(client.outbox.len(), 1);
4242        assert_eq!(client.outbox[0].client_commit_id, kept);
4243        let outcome = client
4244            .commit_outcome(&doomed)
4245            .expect("read doomed outcome")
4246            .expect("doomed outcome");
4247        assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
4248        match &outcome.results[0] {
4249            CommitOperationOutcome::Error { rejection } => {
4250                assert_eq!(rejection.code, "client.local_data_purged");
4251                assert_eq!(rejection.client_commit_id, doomed);
4252            }
4253            other => panic!("expected local purge rejection, got {other:?}"),
4254        }
4255        assert_eq!(client.drain_change_batches().len(), 1);
4256        assert_eq!(
4257            client.purge_local_data(&input).expect("retry purge"),
4258            LocalDataPurgeResult {
4259                already_applied: true,
4260                purged_rows: 0,
4261                dropped_commits: 0,
4262            }
4263        );
4264        let conflicting = LocalDataPurgeInput {
4265            purge_id: input.purge_id.clone(),
4266            targets: vec![LocalDataPurgeTarget {
4267                table: "patient_notes".to_owned(),
4268                selectors: BTreeMap::from([(
4269                    "encryption_key_id".to_owned(),
4270                    vec!["key-held".to_owned()],
4271                )]),
4272            }],
4273        };
4274        assert!(client
4275            .purge_local_data(&conflicting)
4276            .expect_err("id collision must fail")
4277            .contains("already used with a different plan"));
4278    }
4279}
4280
4281impl SubState {
4282    fn name(self) -> &'static str {
4283        match self {
4284            SubState::Active => "active",
4285            SubState::Revoked => "revoked",
4286            SubState::Failed => "failed",
4287        }
4288    }
4289
4290    fn parse(value: &str) -> Self {
4291        match value {
4292            "revoked" => Self::Revoked,
4293            "failed" => Self::Failed,
4294            _ => Self::Active,
4295        }
4296    }
4297}
4298
4299#[derive(Debug, Clone)]
4300struct Subscription {
4301    id: String,
4302    table: String,
4303    requested: Vec<(String, Vec<String>)>,
4304    params: Option<String>,
4305    cursor: i64,
4306    /// §4.7 resume token, round-tripped opaquely.
4307    bootstrap_state: Option<String>,
4308    state: SubState,
4309    reason_code: Option<String>,
4310    /// Last effective scopes echoed while active (§3.3: persisted for the
4311    /// purge contract; each active echo replaces it).
4312    effective: Option<Vec<(String, Vec<String>)>>,
4313    synced_once: bool,
4314}
4315
4316#[derive(Debug, Clone)]
4317struct OutboxOp {
4318    upsert: bool,
4319    table: String,
4320    row_id: String,
4321    base_version: Option<i64>,
4322    /// Schema-agnostic local form (§0): driver JSON values, encoded with
4323    /// the current codec at send time. A key's presence IS the §6.1 sparse
4324    /// presence set — both the overlay and the wire payload derive from it.
4325    values: Option<Map<String, Value>>,
4326}
4327
4328impl From<&OutboxOp> for CommitOperation {
4329    fn from(operation: &OutboxOp) -> Self {
4330        Self {
4331            table: operation.table.clone(),
4332            row_id: operation.row_id.clone(),
4333            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
4334            base_version: operation.base_version,
4335            values: operation.values.clone(),
4336        }
4337    }
4338}
4339
4340#[derive(Debug, Clone)]
4341struct OutboxCommit {
4342    client_commit_id: String,
4343    ops: Vec<OutboxOp>,
4344}
4345
4346#[derive(Debug, Clone)]
4347struct CompiledLocalDataPurgeTarget {
4348    table: String,
4349    selectors: Vec<(String, Vec<String>)>,
4350}
4351
4352struct StoredCommitOutcomeRow {
4353    sequence: i64,
4354    client_commit_id: String,
4355    status: String,
4356    recorded_at_ms: i64,
4357    results_json: String,
4358    operations_json: Option<String>,
4359    resolution: String,
4360    resolved_at_ms: Option<i64>,
4361    replacement_client_commit_id: Option<String>,
4362}
4363
4364/// Section outcome distinguishing the §5.6 subscription-local fail-closed
4365/// path from a round-aborting failure (§1.4 rule 5).
4366enum SectionError {
4367    FailClosed,
4368    Abort(String, String),
4369}
4370
4371struct RequestMeta {
4372    pushed_ids: Vec<String>,
4373    /// Subscription id → the request carried `cursor < 0` and no resume
4374    /// token (§5.6 first-page detection: a *fresh* bootstrap).
4375    fresh: Vec<(String, bool)>,
4376    accept: u8,
4377    /// §6.1 splitBatch: outbox commits held back from THIS request because
4378    /// the running operation count reached the push cap — the next round
4379    /// pushes them (`sync_needed` stays set while any remain).
4380    deferred_commits: usize,
4381}
4382
4383#[derive(Default)]
4384struct ChangeAccumulator {
4385    /// None means table-wide; Some is the exact scoped domain.
4386    tables: BTreeMap<String, Option<BTreeSet<String>>>,
4387    windows: BTreeMap<(String, String), BTreeSet<String>>,
4388    status: bool,
4389    conflicts: bool,
4390    rejections: bool,
4391    outcomes: bool,
4392}
4393
4394impl ChangeAccumulator {
4395    fn table(&mut self, table: &str) {
4396        self.tables.insert(table.to_owned(), None);
4397    }
4398
4399    fn scope(&mut self, table: &str, key: String) {
4400        match self.tables.get_mut(table) {
4401            Some(None) => {}
4402            Some(Some(keys)) => {
4403                keys.insert(key);
4404            }
4405            None => {
4406                self.tables
4407                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
4408            }
4409        }
4410    }
4411
4412    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
4413        self.windows
4414            .entry((base_key.to_owned(), table.to_owned()))
4415            .or_default()
4416            .insert(unit.to_owned());
4417    }
4418
4419    fn touched(&self) -> bool {
4420        !self.tables.is_empty()
4421            || !self.windows.is_empty()
4422            || self.status
4423            || self.conflicts
4424            || self.rejections
4425            || self.outcomes
4426    }
4427}
4428
4429/// §6.1: the server caps total operations per request (reference default
4430/// 500) and rejects the whole batch with `sync.too_many_operations`; the
4431/// client "splits and retries". Splitting happens at build time: commits are
4432/// included IN ORDER until the operation budget is spent, the rest wait for
4433/// the next round.
4434const PUSH_OPS_PER_REQUEST: usize = 500;
4435const MAX_LOCAL_PURGE_TARGETS: usize = 64;
4436const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
4437const MAX_LOCAL_PURGE_VALUES: usize = 128;
4438const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
4439pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
4440
4441pub struct SyncClient {
4442    #[cfg(feature = "bench-internals")]
4443    benchmark_phases: Recorder,
4444    conn: Connection,
4445    schema: ClientSchema,
4446    client_id: String,
4447    limits: ClientLimits,
4448    subs: Vec<Subscription>,
4449    outbox: Vec<OutboxCommit>,
4450    conflicts: Vec<ConflictRecord>,
4451    rejections: Vec<RejectionRecord>,
4452    schema_floor: Option<SchemaFloor>,
4453    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
4454    lease_state: Option<LeaseState>,
4455    /// §1.6: the schema-floor response stops syncing until an upgrade.
4456    stopped: bool,
4457    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
4458    upgrading: bool,
4459    /// §8.4 coalesced sync-needed signal.
4460    sync_needed: bool,
4461    realtime_connected: bool,
4462    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
4463    presence: HashMap<String, HashMap<String, PresencePeer>>,
4464    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
4465    /// host may pin it (conformance runs on a virtual clock).
4466    now_ms: Option<i64>,
4467    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
4468    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
4469    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
4470    encryption: crate::values::EncryptionConfig,
4471    /// Fail-closed host bootstrap gate. While set, command hosts permit only
4472    /// status/lifecycle inspection and an exact authorized local purge.
4473    security_preflight: bool,
4474    /// Per-table primary-key upsert SQL, built once per (full table name) —
4475    /// the row write path runs per row during bootstrap (§5.6), so the SQL
4476    /// string (and, via `prepare_cached`, its compiled statement) is reused
4477    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
4478    /// schema reset (the column lists may have changed).
4479    insert_sql: RefCell<HashMap<String, String>>,
4480    /// §7.1 rebuild gate: true whenever the base tables or the outbox have
4481    /// diverged from the visible overlay since the last rebuild. Lets a
4482    /// no-op sync round skip the full base→visible copy.
4483    overlay_dirty: Cell<bool>,
4484    /// Test-only structural performance signal: response processing must not
4485    /// turn a batch of acknowledgements into one full overlay rebuild each.
4486    #[cfg(test)]
4487    overlay_rebuild_count: Cell<usize>,
4488    /// Test-only structural performance signal: outcome retention is enforced
4489    /// once per response rather than once per acknowledgement.
4490    #[cfg(test)]
4491    outcome_prune_count: Cell<usize>,
4492    /// Exact observer-transaction output drained by command/FFI hosts.
4493    progress: ProgressObserver,
4494    change_queue: VecDeque<ClientChangeBatch>,
4495    sync_intent_queue: VecDeque<SyncIntent>,
4496    /// Explicit exponential retry policy for transient transport failures.
4497    retry_delay_ms: u64,
4498    last_round: Option<DiagnosticLastRound>,
4499    last_change: Option<DiagnosticLastChange>,
4500}
4501
4502fn quote_ident(name: &str) -> String {
4503    format!("\"{}\"", name.replace('"', "\"\""))
4504}
4505
4506/// Seek by the existing key while retaining the exact legacy text match.
4507fn row_id_predicate(table: &TableSchema) -> String {
4508    let key = quote_ident(&table.primary_key);
4509    let text_match = format!("CAST({key} AS TEXT) = ?1");
4510    match table.columns[table.pk_index].ty {
4511        ColumnType::String | ColumnType::Json => {
4512            format!("{key} = ?1 AND {text_match}")
4513        }
4514        // Native columns have no declared SQLite affinity. Match the stored
4515        // integer bind type explicitly. Unary + removes expression affinity so
4516        // SQLite can seek the typeless key; the text check preserves exact IDs.
4517        ColumnType::Integer | ColumnType::Boolean => {
4518            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
4519        }
4520        // REAL-to-text conversion can round distinct values to the same text.
4521        // Narrowing those matches by numeric equality would change behavior.
4522        _ => text_match,
4523    }
4524}
4525
4526fn is_local_operation_code_like(value: &str) -> bool {
4527    let bytes = value.as_bytes();
4528    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
4529        && bytes
4530            .iter()
4531            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
4532}
4533
4534fn base_table(name: &str) -> String {
4535    quote_ident(&format!("_syncular_base_{name}"))
4536}
4537
4538/// §4.8: a stable, server-opaque key for a window base — table + variable +
4539/// canonical fixed scopes. Two `set_window` calls with the same base
4540/// address the same registry rows.
4541/// §4.8 deferred eviction record: (sub id, table, effective scope map).
4542type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
4543
4544fn window_base_key(base: &WindowBase) -> String {
4545    format!(
4546        "{}\0{}\0{}",
4547        base.table,
4548        base.variable,
4549        canonical_scope_json(&base.fixed_scopes)
4550    )
4551}
4552
4553/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
4554fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
4555    let mut scopes = base.fixed_scopes.clone();
4556    scopes.retain(|(k, _)| k != &base.variable);
4557    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
4558    scopes
4559}
4560
4561/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
4562/// echoed not interpreted by the server, so the exact hash is client
4563/// convention; SHA-256 matches the SPEC's worked example.
4564fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
4565    let canonical = canonical_scope_json(&unit_scopes(base, unit));
4566    let digest = Sha256::digest(canonical.as_bytes());
4567    let hex = bytes_to_hex(&digest);
4568    format!("w:{}:{}", base.table, &hex[..16])
4569}
4570
4571fn visible_table(name: &str) -> String {
4572    quote_ident(name)
4573}
4574
4575const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
4576
4577/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
4578/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
4579fn is_synced_table_name(name: &str) -> bool {
4580    if name.starts_with("sqlite_") {
4581        return false;
4582    }
4583    if name.starts_with("_syncular_base_") {
4584        return true; // the base half of a synced table pair
4585    }
4586    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
4587    !name.starts_with("_syncular_")
4588}
4589
4590/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
4591fn blob_id_for(bytes: &[u8]) -> String {
4592    let digest = Sha256::digest(bytes);
4593    format!("sha256:{}", bytes_to_hex(&digest))
4594}
4595
4596/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
4597/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
4598/// per row on the §5.6 bootstrap path), scalars bind owned.
4599enum RowParam<'a> {
4600    Cell(&'a Option<ColumnValue>),
4601    Version(i64),
4602}
4603
4604impl rusqlite::ToSql for RowParam<'_> {
4605    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
4606        Ok(match self {
4607            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
4608            RowParam::Cell(cell) => match cell {
4609                None => ToSqlOutput::Owned(SqlValue::Null),
4610                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
4611                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
4612                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
4613                Some(ColumnValue::Boolean(b)) => {
4614                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
4615                }
4616                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
4617                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
4618                }
4619                // §5.10: crdt bytes store as BLOB, like bytes.
4620                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
4621                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
4622                }
4623            },
4624        })
4625    }
4626}
4627
4628// Retain a typed primary key across the current import chunk without copying row payloads.
4629fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
4630    match param.to_sql().map_err(|error| error.to_string())? {
4631        ToSqlOutput::Owned(value) => Ok(value),
4632        ToSqlOutput::Borrowed(value) => {
4633            SqlValue::try_from(value).map_err(|error| error.to_string())
4634        }
4635        _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
4636    }
4637}
4638
4639/// §5.3 image cell → bind parameter, strict per the declared column type
4640/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
4641/// nullable). Mismatches are image-producer violations. Returns the cell
4642/// borrowed when the stored representation already matches what the row
4643/// codec would write, or the normalized scalar (boolean → 0/1, float from
4644/// INTEGER → REAL) otherwise — the local write is byte-identical to the
4645/// old convert-then-insert path without allocating per cell.
4646fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
4647    use ssp2::segment::ColumnType;
4648    let mismatch = || {
4649        Err(format!(
4650            "image column {:?} holds a value of the wrong type",
4651            column.name
4652        ))
4653    };
4654    match value {
4655        ValueRef::Null => {
4656            if !column.nullable {
4657                return Err(format!(
4658                    "image column {:?} is NULL but not nullable",
4659                    column.name
4660                ));
4661            }
4662            Ok(ToSqlOutput::Owned(SqlValue::Null))
4663        }
4664        ValueRef::Integer(i) => match column.ty {
4665            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
4666            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
4667            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
4668            _ => mismatch(),
4669        },
4670        ValueRef::Real(_) => match column.ty {
4671            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
4672            _ => mismatch(),
4673        },
4674        ValueRef::Text(t) => {
4675            std::str::from_utf8(t)
4676                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
4677            match column.ty {
4678                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
4679                    Ok(ToSqlOutput::Borrowed(value))
4680                }
4681                _ => mismatch(),
4682            }
4683        }
4684        ValueRef::Blob(_) => match column.ty {
4685            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
4686            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
4687            _ => mismatch(),
4688        },
4689    }
4690}
4691
4692fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
4693    use rusqlite::types::ValueRef;
4694    match value {
4695        ValueRef::Null => Value::Null,
4696        ValueRef::Integer(i) => match column.ty {
4697            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
4698            ssp2::segment::ColumnType::Float => {
4699                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
4700            }
4701            _ => Value::from(i),
4702        },
4703        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
4704        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
4705        ValueRef::Blob(b) => {
4706            let mut map = Map::new();
4707            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
4708            Value::Object(map)
4709        }
4710    }
4711}
4712
4713/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
4714/// Objects are accepted in lossless `{"$bytes": hex}` and
4715/// `{"$bigint": decimal}` envelope forms.
4716fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
4717    Ok(match value {
4718        Value::Null => SqlValue::Null,
4719        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
4720        Value::Number(n) => {
4721            if let Some(i) = n.as_i64() {
4722                SqlValue::Integer(i)
4723            } else if let Some(f) = n.as_f64() {
4724                SqlValue::Real(f)
4725            } else {
4726                return Err(format!("query param number {n} is out of range"));
4727            }
4728        }
4729        Value::String(s) => SqlValue::Text(s.clone()),
4730        Value::Object(_) => {
4731            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
4732                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
4733            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
4734                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
4735                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
4736                })?)
4737            } else {
4738                return Err(
4739                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
4740                        .to_owned(),
4741                );
4742            }
4743        }
4744        Value::Array(_) => return Err("query array params are not supported".to_owned()),
4745    })
4746}
4747
4748/// Map a rusqlite value with no schema column to consult (arbitrary query
4749/// output): integers/reals/text pass through by stored affinity, blobs ride
4750/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
4751/// schema column type to recover booleans/floats/json.
4752fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
4753    use rusqlite::types::ValueRef;
4754    match value {
4755        ValueRef::Null => Value::Null,
4756        ValueRef::Integer(i) => {
4757            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
4758            // ordinary UI-sized integers ergonomic and envelope only values
4759            // beyond JavaScript's safe range.
4760            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
4761            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
4762                Value::from(i)
4763            } else {
4764                let mut map = Map::new();
4765                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
4766                Value::Object(map)
4767            }
4768        }
4769        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
4770        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
4771        ValueRef::Blob(b) => {
4772            let mut map = Map::new();
4773            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
4774            Value::Object(map)
4775        }
4776    }
4777}
4778
4779fn query_connection(
4780    conn: &Connection,
4781    sql: &str,
4782    params: &[QueryValue],
4783) -> Result<Vec<QueryRow>, String> {
4784    crate::query_guard::assert_read_only_query(sql)?;
4785    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
4786    let bound: Vec<SqlValue> = params
4787        .iter()
4788        .map(json_param_to_sql)
4789        .collect::<Result<_, _>>()?;
4790    let mut stmt = conn.prepare(&lowered_sql).map_err(|e| e.to_string())?;
4791    let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
4792    let bound_refs: Vec<&dyn rusqlite::ToSql> =
4793        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
4794    let mut sql_rows = stmt
4795        .query(bound_refs.as_slice())
4796        .map_err(|e| e.to_string())?;
4797    let mut out = Vec::new();
4798    while let Some(row) = sql_rows.next().map_err(|e| e.to_string())? {
4799        let mut record = Map::new();
4800        for (i, name) in column_names.iter().enumerate() {
4801            let value = row.get_ref(i).map_err(|e| e.to_string())?;
4802            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
4803        }
4804        out.push(record);
4805    }
4806    Ok(out)
4807}
4808
4809fn persisted_window_state(conn: &Connection, base: &WindowBase) -> Result<WindowState, String> {
4810    let mut stmt = conn
4811        .prepare(
4812            "SELECT windows.unit, subscriptions.state_json
4813               FROM _syncular_windows AS windows
4814               JOIN _syncular_subscriptions AS subscriptions
4815                 ON subscriptions.id = windows.sub_id
4816              WHERE windows.base = ?1
4817              ORDER BY windows.unit ASC",
4818        )
4819        .map_err(|error| error.to_string())?;
4820    let rows = stmt
4821        .query_map(rusqlite::params![window_base_key(base)], |row| {
4822            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
4823        })
4824        .map_err(|error| error.to_string())?;
4825    let mut units = Vec::new();
4826    let mut pending = Vec::new();
4827    for row in rows {
4828        let (unit, raw) = row.map_err(|error| error.to_string())?;
4829        let state: Value = serde_json::from_str(&raw)
4830            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
4831        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
4832            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
4833            || state
4834                .get("bootstrapState")
4835                .is_some_and(|value| !value.is_null());
4836        if is_pending {
4837            pending.push(unit.clone());
4838        }
4839        units.push(unit);
4840    }
4841    Ok(WindowState { units, pending })
4842}
4843
4844fn snapshot_connection(
4845    conn: &Connection,
4846    sql: &str,
4847    params: &[Value],
4848    coverage: &[WindowCoverage],
4849) -> Result<QuerySnapshot, String> {
4850    conn.execute_batch("SAVEPOINT syncular_snapshot_read")
4851        .map_err(|error| error.to_string())?;
4852    let result = (|| {
4853        let revision = conn
4854            .query_row(
4855                "SELECT value FROM _syncular_meta WHERE key = ?1",
4856                rusqlite::params![LOCAL_REVISION_KEY],
4857                |row| row.get::<_, String>(0),
4858            )
4859            .ok()
4860            .and_then(|value| value.parse::<u64>().ok())
4861            .unwrap_or(0);
4862        let rows = query_connection(conn, sql, params)?;
4863        let mut pending = Vec::new();
4864        let mut missing = Vec::new();
4865        for requested in coverage {
4866            let base_key = window_base_key(&requested.base);
4867            let state = persisted_window_state(conn, &requested.base)?;
4868            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
4869                let reference = WindowUnitRef {
4870                    base_key: base_key.clone(),
4871                    unit: unit.clone(),
4872                };
4873                if !state.units.iter().any(|held| held == &unit) {
4874                    missing.push(reference);
4875                } else if state.pending.iter().any(|held| held == &unit) {
4876                    pending.push(reference);
4877                }
4878            }
4879        }
4880        Ok(QuerySnapshot {
4881            revision: revision.to_string(),
4882            rows,
4883            coverage: CoverageSnapshot {
4884                complete: pending.is_empty() && missing.is_empty(),
4885                pending,
4886                missing,
4887            },
4888        })
4889    })();
4890    match result {
4891        Ok(snapshot) => {
4892            conn.execute_batch("RELEASE syncular_snapshot_read")
4893                .map_err(|error| error.to_string())?;
4894            Ok(snapshot)
4895        }
4896        Err(error) => {
4897            let _ = conn.execute_batch(
4898                "ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read",
4899            );
4900            Err(error)
4901        }
4902    }
4903}
4904
4905/// A long-lived read-only SQLite sidecar for latency-critical native views.
4906/// Network rounds stay serialized on the mutable core owner, while atomic
4907/// query snapshots use this independent connection and therefore never queue
4908/// behind HTTP/WebSocket latency.
4909pub struct FileQuerySnapshotReader {
4910    path: String,
4911    conn: Option<Connection>,
4912}
4913
4914impl FileQuerySnapshotReader {
4915    #[must_use]
4916    pub fn new(path: impl Into<String>) -> Self {
4917        Self {
4918            path: path.into(),
4919            conn: None,
4920        }
4921    }
4922
4923    fn connection(&mut self) -> Result<&Connection, String> {
4924        if self.conn.is_none() {
4925            let conn = Connection::open_with_flags(
4926                &self.path,
4927                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
4928            )
4929            .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
4930            conn.busy_timeout(std::time::Duration::from_millis(250))
4931                .map_err(|error| error.to_string())?;
4932            self.conn = Some(conn);
4933        }
4934        self.conn
4935            .as_ref()
4936            .ok_or_else(|| "read sidecar connection missing".to_owned())
4937    }
4938
4939    pub fn query_snapshot(
4940        &mut self,
4941        sql: &str,
4942        params: &[Value],
4943        coverage: &[WindowCoverage],
4944    ) -> Result<QuerySnapshot, String> {
4945        snapshot_connection(self.connection()?, sql, params, coverage)
4946    }
4947}
4948
4949impl SyncClient {
4950    pub fn new_with_identity(
4951        client_id: Option<String>,
4952        schema_json: &Value,
4953        limits: ClientLimits,
4954    ) -> Result<Self, String> {
4955        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
4956        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
4957        Self::with_connection(resolved, schema_json, limits, conn)
4958    }
4959
4960    pub fn new(
4961        client_id: String,
4962        schema_json: &Value,
4963        limits: ClientLimits,
4964    ) -> Result<Self, String> {
4965        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
4966        Self::with_connection(client_id, schema_json, limits, conn)
4967    }
4968
4969    /// Build a client backed by an on-disk SQLite database at `path` — the
4970    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
4971    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
4972    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
4973    /// of the command router's dependency set (the router only holds a path).
4974    pub fn open_path(
4975        client_id: String,
4976        schema_json: &Value,
4977        limits: ClientLimits,
4978        path: &str,
4979    ) -> Result<Self, String> {
4980        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
4981        Self::with_connection(client_id, schema_json, limits, conn)
4982    }
4983
4984    pub fn open_path_with_identity(
4985        client_id: Option<String>,
4986        schema_json: &Value,
4987        limits: ClientLimits,
4988        path: &str,
4989    ) -> Result<Self, String> {
4990        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
4991        // File-backed native clients use an independent read connection for
4992        // latency-critical snapshots. WAL is SQLite's intended reader/writer
4993        // concurrency mode: a view read never holds a rollback-journal lock
4994        // that delays the mutable client's next commit, and a short busy
4995        // timeout absorbs the tiny checkpoint/schema-lock windows.
4996        conn.busy_timeout(std::time::Duration::from_millis(250))
4997            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
4998        conn.pragma_update(None, "journal_mode", "WAL")
4999            .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
5000        let persisted = conn
5001            .query_row(
5002                "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
5003                [],
5004                |row| row.get::<_, String>(0),
5005            )
5006            .ok();
5007        let resolved = persisted
5008            .clone()
5009            .or(client_id.clone())
5010            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5011        if let (Some(existing), Some(requested)) = (persisted, client_id) {
5012            if existing != requested {
5013                return Err(format!(
5014                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
5015                ));
5016            }
5017        }
5018        Self::with_connection(resolved, schema_json, limits, conn)
5019    }
5020
5021    #[must_use]
5022    pub fn client_id(&self) -> &str {
5023        &self.client_id
5024    }
5025
5026    /// Build a client over a caller-supplied rusqlite connection — the seam a
5027    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
5028    /// with an on-disk database (`Connection::open(path)`) rather than the
5029    /// default `:memory:`. The connection MUST be fresh (no pre-existing
5030    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
5031    /// the same file across process restarts reuses the persisted rows.
5032    pub fn with_connection(
5033        client_id: String,
5034        schema_json: &Value,
5035        limits: ClientLimits,
5036        conn: Connection,
5037    ) -> Result<Self, String> {
5038        if limits.outcome_retention_max_entries == Some(0) {
5039            return Err(
5040                "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
5041            );
5042        }
5043        let schema = parse_schema_json(schema_json)?;
5044        let mut client = SyncClient {
5045            #[cfg(feature = "bench-internals")]
5046            benchmark_phases: Recorder::default(),
5047            conn,
5048            schema,
5049            client_id,
5050            limits,
5051            subs: Vec::new(),
5052            outbox: Vec::new(),
5053            conflicts: Vec::new(),
5054            rejections: Vec::new(),
5055            schema_floor: None,
5056            lease_state: None,
5057            stopped: false,
5058            upgrading: false,
5059            sync_needed: false,
5060            realtime_connected: false,
5061            presence: HashMap::new(),
5062            now_ms: None,
5063            encryption: crate::values::EncryptionConfig::default(),
5064            security_preflight: false,
5065            insert_sql: RefCell::new(HashMap::new()),
5066            overlay_dirty: Cell::new(false),
5067            #[cfg(test)]
5068            overlay_rebuild_count: Cell::new(0),
5069            #[cfg(test)]
5070            outcome_prune_count: Cell::new(0),
5071            progress: ProgressObserver::default(),
5072            change_queue: VecDeque::new(),
5073            sync_intent_queue: VecDeque::new(),
5074            retry_delay_ms: 250,
5075            last_round: None,
5076            last_change: None,
5077        };
5078        // The row write path leans on the prepared-statement cache (two
5079        // insert statements per synced table, plus the bookkeeping
5080        // statements); size it so a multi-table schema never thrashes.
5081        client
5082            .conn
5083            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
5084        // Protected bookkeeping must exist before inspecting the persisted
5085        // schema marker. New app indexes may reference columns that only
5086        // exist after a version-bump reset.
5087        client.create_bookkeeping_tables()?;
5088        match client.get_meta(CLIENT_ID_KEY) {
5089            Some(existing) if existing != client.client_id => {
5090                return Err(format!(
5091                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
5092                    client.client_id
5093                ));
5094            }
5095            None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
5096            _ => {}
5097        }
5098        client.restore_persisted_state()?;
5099        let marker = client
5100            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
5101            .and_then(|value| value.parse::<i32>().ok());
5102        match marker {
5103            None => {
5104                client.create_synced_tables()?;
5105                client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
5106            }
5107            Some(version) if version == client.schema.version => {
5108                client.create_synced_tables()?;
5109            }
5110            Some(_) => client.run_schema_reset()?,
5111        }
5112        client.clear_satisfied_persisted_schema_floor();
5113        client.prune_unknown_subscriptions()?;
5114        if marker == Some(client.schema.version) && !client.outbox.is_empty() {
5115            // Reconstruct the visible optimistic overlay from the durable base
5116            // plus outbox instead of trusting a process-interrupted mirror.
5117            client.overlay_dirty.set(true);
5118            client.rebuild_overlay();
5119        }
5120        // Every persisted active subscription needs one catch-up pull on open:
5121        // realtime only covers changes after connection, while an idempotent
5122        // setWindow correctly creates no fresh command effect. Pending outbox
5123        // work has the same restart requirement. The core owns this intent so
5124        // native hosts never poll or require an application-issued sync().
5125        client.enqueue_startup_sync_if_needed()?;
5126        Ok(client)
5127    }
5128
5129    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
5130    /// against this instead of system time (conformance virtual clock).
5131    pub fn set_now_ms(&mut self, now_ms: i64) {
5132        self.now_ms = Some(now_ms);
5133    }
5134
5135    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
5136    /// The command router parses these from the `create` command's
5137    /// `encryption` config (keys as `{$bytes: hex}`).
5138    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
5139        self.encryption = encryption;
5140    }
5141
5142    #[must_use]
5143    pub fn security_lifecycle(&self) -> &'static str {
5144        if self.security_preflight {
5145            "preflight"
5146        } else {
5147            "active"
5148        }
5149    }
5150
5151    #[must_use]
5152    pub fn security_preflight(&self) -> bool {
5153        self.security_preflight
5154    }
5155
5156    /// Quarantine this replica: enter the fail-closed gate, release all
5157    /// core-owned key material, and record the gate in the database so
5158    /// reopening re-enters preflight until `activate_security` clears it.
5159    ///
5160    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
5161    /// which must NOT leave that durable mark.
5162    pub fn begin_security_preflight(&mut self) {
5163        self.seal_security_on_teardown();
5164        // Persist the quarantine so it survives handle teardown and restart:
5165        // reopening this replica re-enters preflight until activation clears it.
5166        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
5167    }
5168
5169    /// Release core-owned key material as a host tears the client down,
5170    /// WITHOUT recording a quarantine.
5171    ///
5172    /// Shutting an activated client down is not a quarantine event. Persisting
5173    /// the gate here marks every cleanly closed replica as pending, and the
5174    /// next plain `create` is then refused permanently: the reopen path
5175    /// restores the flag from the marker and the create guard rejects it. The
5176    /// in-memory flag still closes the gate for anything still holding this
5177    /// instance, which is all a teardown barrier needs.
5178    pub fn seal_security_on_teardown(&mut self) {
5179        self.security_preflight = true;
5180        self.encryption = crate::values::EncryptionConfig::default();
5181        self.sync_intent_queue.clear();
5182    }
5183
5184    /// Install the post-authentication keyring and release the host loop.
5185    pub fn activate_security(
5186        &mut self,
5187        encryption: crate::values::EncryptionConfig,
5188    ) -> Result<(), String> {
5189        if !self.security_preflight {
5190            return Err(
5191                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
5192            );
5193        }
5194        self.enqueue_startup_sync_if_needed()?;
5195        self.encryption = encryption;
5196        self.security_preflight = false;
5197        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
5198        Ok(())
5199    }
5200
5201    fn clock_now_ms(&self) -> i64 {
5202        self.now_ms.unwrap_or_else(|| {
5203            std::time::SystemTime::now()
5204                .duration_since(std::time::UNIX_EPOCH)
5205                .map(|d| d.as_millis() as i64)
5206                .unwrap_or(0)
5207        })
5208    }
5209
5210    fn create_bookkeeping_tables(&self) -> Result<(), String> {
5211        // Durable client bookkeeping (outbox + subscription + meta).
5212        self.conn
5213            .execute_batch(
5214                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
5215                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
5216                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
5217                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
5218                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
5219                   client_commit_id TEXT NOT NULL UNIQUE,
5220                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
5221                   recorded_at_ms INTEGER NOT NULL,
5222                   results_json TEXT NOT NULL,
5223                   operations_json TEXT,
5224                   resolution TEXT NOT NULL DEFAULT 'active'
5225                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
5226                   resolved_at_ms INTEGER,
5227                   replacement_client_commit_id TEXT);
5228                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
5229                   ON _syncular_commit_outcomes(resolution, seq);
5230                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
5231                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
5232                 CREATE TABLE IF NOT EXISTS _syncular_meta (
5233                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
5234                 CREATE TABLE IF NOT EXISTS _syncular_windows (
5235                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
5236                   PRIMARY KEY (base, unit));
5237                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
5238                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
5239                   effective_scopes TEXT NOT NULL);
5240                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
5241                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
5242                   PRIMARY KEY(commit_id, blob_id));
5243                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
5244                   ON _syncular_blob_commit_refs(blob_id);",
5245            )
5246            .map_err(|e| e.to_string())?;
5247        // Migrate an outcome journal created before failed aggregate
5248        // envelopes were retained. Historical rows intentionally stay NULL.
5249        let _ = self
5250            .conn
5251            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
5252        if self.get_meta(LOCAL_REVISION_KEY).is_none() {
5253            self.set_meta(LOCAL_REVISION_KEY, "0");
5254        }
5255        // §5.9.7 blob cache. Mutable upload state stays off the body row so
5256        // completing a large upload does not rewrite its BLOB record.
5257        if self.schema_has_blobs() {
5258            let exists = self
5259                .conn
5260                .query_row(
5261                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
5262                    [],
5263                    |row| row.get::<_, bool>(0),
5264                )
5265                .map_err(|e| e.to_string())?;
5266            if exists {
5267                let mut statement = self
5268                    .conn
5269                    .prepare("PRAGMA table_info(_syncular_blobs)")
5270                    .map_err(|e| e.to_string())?;
5271                let mut columns = statement
5272                    .query_map([], |row| row.get::<_, String>(1))
5273                    .map_err(|e| e.to_string())?
5274                    .collect::<Result<Vec<_>, _>>()
5275                    .map_err(|e| e.to_string())?;
5276                columns.sort();
5277                let mut expected = vec![
5278                    "blob_id",
5279                    "byte_length",
5280                    "bytes",
5281                    "created_at_ms",
5282                    "media_type",
5283                ];
5284                expected.sort_unstable();
5285                if columns != expected {
5286                    return Err(
5287                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
5288                    );
5289                }
5290            } else {
5291                self.conn
5292                    .execute_batch(
5293                        "CREATE TABLE _syncular_blobs(
5294                           blob_id TEXT PRIMARY KEY,
5295                           bytes BLOB NOT NULL,
5296                           byte_length INTEGER NOT NULL,
5297                           media_type TEXT,
5298                           created_at_ms INTEGER NOT NULL);",
5299                    )
5300                    .map_err(|e| e.to_string())?;
5301            }
5302            self.conn
5303                .execute_batch(
5304                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
5305                       blob_id TEXT PRIMARY KEY,
5306                       media_type TEXT,
5307                       created_at_ms INTEGER NOT NULL);",
5308                )
5309                .map_err(|e| e.to_string())?;
5310        }
5311        Ok(())
5312    }
5313
5314    /// True iff any synced table declares a `blob_ref` column (§5.9).
5315    fn schema_has_blobs(&self) -> bool {
5316        self.schema
5317            .tables
5318            .iter()
5319            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
5320    }
5321
5322    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
5323
5324    fn get_meta(&self, key: &str) -> Option<String> {
5325        self.conn
5326            .query_row(
5327                "SELECT value FROM _syncular_meta WHERE key = ?1",
5328                rusqlite::params![key],
5329                |row| row.get::<_, String>(0),
5330            )
5331            .ok()
5332    }
5333
5334    fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
5335        self.conn
5336            .query_row(
5337                "SELECT value FROM _syncular_meta WHERE key = ?1",
5338                rusqlite::params![key],
5339                |row| row.get::<_, String>(0),
5340            )
5341            .optional()
5342            .map_err(|_| {
5343                "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
5344            })
5345    }
5346
5347    fn set_meta(&self, key: &str, value: &str) {
5348        let _ = self.conn.execute(
5349            "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
5350            rusqlite::params![key, value],
5351        );
5352    }
5353
5354    fn delete_meta(&self, key: &str) {
5355        let _ = self.conn.execute(
5356            "DELETE FROM _syncular_meta WHERE key = ?1",
5357            rusqlite::params![key],
5358        );
5359    }
5360
5361    fn restore_persisted_state(&mut self) -> Result<(), String> {
5362        self.subs = {
5363            let mut stmt = self
5364                .conn
5365                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
5366                .map_err(|error| error.to_string())?;
5367            let rows = stmt
5368                .query_map([], |row| {
5369                    Ok((
5370                        row.get::<_, String>(0)?,
5371                        row.get::<_, String>(1)?,
5372                        row.get::<_, String>(2)?,
5373                    ))
5374                })
5375                .map_err(|error| error.to_string())?;
5376            let mut subscriptions = Vec::new();
5377            for row in rows {
5378                let (id, table, raw) = row.map_err(|error| error.to_string())?;
5379                let state: Value = serde_json::from_str(&raw)
5380                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
5381                let requested = json_to_scope_map(
5382                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
5383                )?;
5384                let effective = state
5385                    .get("effectiveScopes")
5386                    .filter(|value| !value.is_null())
5387                    .map(json_to_scope_map)
5388                    .transpose()?;
5389                subscriptions.push(Subscription {
5390                    id,
5391                    table,
5392                    requested,
5393                    params: state
5394                        .get("params")
5395                        .and_then(Value::as_str)
5396                        .map(str::to_owned),
5397                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
5398                    bootstrap_state: state
5399                        .get("bootstrapState")
5400                        .and_then(Value::as_str)
5401                        .map(str::to_owned),
5402                    state: SubState::parse(
5403                        state
5404                            .get("status")
5405                            .and_then(Value::as_str)
5406                            .unwrap_or("active"),
5407                    ),
5408                    reason_code: state
5409                        .get("reasonCode")
5410                        .and_then(Value::as_str)
5411                        .map(str::to_owned),
5412                    effective,
5413                    synced_once: state
5414                        .get("syncedOnce")
5415                        .and_then(Value::as_bool)
5416                        .unwrap_or(false),
5417                });
5418            }
5419            subscriptions
5420        };
5421
5422        self.outbox = {
5423            let mut stmt = self
5424                .conn
5425                .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
5426                .map_err(|error| error.to_string())?;
5427            let rows = stmt
5428                .query_map([], |row| {
5429                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5430                })
5431                .map_err(|error| error.to_string())?;
5432            let mut commits = Vec::new();
5433            for row in rows {
5434                let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
5435                let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
5436                    format!("invalid persisted outbox {client_commit_id:?}: {error}")
5437                })?;
5438                let mut ops = Vec::with_capacity(entries.len());
5439                for entry in entries {
5440                    let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
5441                    ops.push(OutboxOp {
5442                        upsert: op == "upsert",
5443                        table: entry
5444                            .get("table")
5445                            .and_then(Value::as_str)
5446                            .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
5447                            .to_owned(),
5448                        row_id: entry
5449                            .get("rowId")
5450                            .and_then(Value::as_str)
5451                            .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
5452                            .to_owned(),
5453                        base_version: entry.get("baseVersion").and_then(Value::as_i64),
5454                        // A legacy entry's `changedFields` key is ignored:
5455                        // the values map is the presence set (§6.1).
5456                        values: entry.get("values").and_then(Value::as_object).cloned(),
5457                    });
5458                }
5459                commits.push(OutboxCommit {
5460                    client_commit_id,
5461                    ops,
5462                });
5463            }
5464            commits
5465        };
5466
5467        self.prune_commit_outcomes()?;
5468        let active = self.commit_outcomes(CommitOutcomeQuery {
5469            active_only: true,
5470            ..CommitOutcomeQuery::default()
5471        })?;
5472        self.conflicts = active
5473            .iter()
5474            .flat_map(|outcome| outcome.results.iter())
5475            .filter_map(|result| match result {
5476                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
5477                _ => None,
5478            })
5479            .collect();
5480        self.rejections = active
5481            .iter()
5482            .flat_map(|outcome| outcome.results.iter())
5483            .filter_map(|result| match result {
5484                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
5485                _ => None,
5486            })
5487            .collect();
5488
5489        self.lease_state = self
5490            .get_meta(LEASE_STATE_KEY)
5491            .map(|raw| serde_json::from_str(&raw))
5492            .transpose()
5493            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
5494        self.schema_floor = self
5495            .get_meta(SCHEMA_FLOOR_KEY)
5496            .map(|raw| serde_json::from_str(&raw))
5497            .transpose()
5498            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
5499        self.stopped = self.schema_floor.is_some();
5500        // A replica left in unactivated preflight reopens gated: the quarantine
5501        // decision persists with the data across handle teardown and restart.
5502        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
5503            self.security_preflight = true;
5504        }
5505        Ok(())
5506    }
5507
5508    /// Remove registrations for tables the running schema no longer knows.
5509    /// Otherwise the server rejects every pull with `sync.unknown_table`.
5510    fn prune_unknown_subscriptions(&mut self) -> Result<(), String> {
5511        let valid_tables: BTreeSet<String> = self
5512            .schema
5513            .tables
5514            .iter()
5515            .map(|table| table.name.clone())
5516            .collect();
5517        let stale_ids: Vec<String> = self
5518            .subs
5519            .iter()
5520            .filter(|sub| !valid_tables.contains(&sub.table))
5521            .map(|sub| sub.id.clone())
5522            .collect();
5523        for id in &stale_ids {
5524            self.conn
5525                .execute(
5526                    "DELETE FROM _syncular_windows WHERE sub_id = ?1",
5527                    rusqlite::params![id],
5528                )
5529                .map_err(|error| error.to_string())?;
5530            self.conn
5531                .execute(
5532                    "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
5533                    rusqlite::params![id],
5534                )
5535                .map_err(|error| error.to_string())?;
5536            self.conn
5537                .execute(
5538                    "DELETE FROM _syncular_subscriptions WHERE id = ?1",
5539                    rusqlite::params![id],
5540                )
5541                .map_err(|error| error.to_string())?;
5542        }
5543        self.subs.retain(|sub| valid_tables.contains(&sub.table));
5544        Ok(())
5545    }
5546
5547    #[must_use]
5548    pub fn local_revision(&self) -> u64 {
5549        self.get_meta(LOCAL_REVISION_KEY)
5550            .and_then(|value| value.parse().ok())
5551            .unwrap_or(0)
5552    }
5553
5554    #[must_use]
5555    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
5556        SyncStatusSnapshot {
5557            current_schema_version: self.schema.version,
5558            outbox: self.outbox.len(),
5559            upgrading: self.upgrading,
5560            lease_state: self.lease_state.clone(),
5561            schema_floor: self.schema_floor.clone(),
5562            sync_needed: self.sync_needed,
5563        }
5564    }
5565
5566    pub fn diagnostics_snapshot(
5567        &self,
5568        request: &ClientDiagnosticsRequest,
5569    ) -> Result<ClientDiagnosticsSnapshot, String> {
5570        if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
5571            return Err(format!(
5572                "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
5573            ));
5574        }
5575        let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
5576        for sub in &self.subs {
5577            let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
5578            let complete =
5579                sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
5580            let state = match sub.state {
5581                SubState::Revoked => "revoked",
5582                SubState::Failed => "failed",
5583                SubState::Active if reset => "reset",
5584                SubState::Active if complete => "complete",
5585                SubState::Active => "bootstrapping",
5586            };
5587            subscriptions.insert(
5588                sub.id.clone(),
5589                DiagnosticSubscription {
5590                    id: sub.id.clone(),
5591                    table: sub.table.clone(),
5592                    state: state.to_owned(),
5593                    complete,
5594                    cursor: Some(sub.cursor),
5595                    reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
5596                },
5597            );
5598        }
5599        for expected in &request.expected_subscriptions {
5600            if expected.id.is_empty() || expected.table.is_empty() {
5601                return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
5602            }
5603            if subscriptions
5604                .get(&expected.id)
5605                .is_some_and(|registered| registered.table != expected.table)
5606            {
5607                subscriptions.insert(
5608                    expected.id.clone(),
5609                    DiagnosticSubscription {
5610                        id: expected.id.clone(),
5611                        table: expected.table.clone(),
5612                        state: "failed".to_owned(),
5613                        complete: false,
5614                        cursor: None,
5615                        reason_code: Some("client.subscription_intent_mismatch".to_owned()),
5616                    },
5617                );
5618            } else {
5619                subscriptions.entry(expected.id.clone()).or_insert_with(|| {
5620                    DiagnosticSubscription {
5621                        id: expected.id.clone(),
5622                        table: expected.table.clone(),
5623                        state: "unregistered".to_owned(),
5624                        complete: false,
5625                        cursor: None,
5626                        reason_code: None,
5627                    }
5628                });
5629            }
5630        }
5631        let mut ordered_subscriptions = Vec::new();
5632        let mut included = BTreeSet::new();
5633        for expected in &request.expected_subscriptions {
5634            if included.insert(expected.id.clone()) {
5635                if let Some(subscription) = subscriptions.get(&expected.id) {
5636                    ordered_subscriptions.push(subscription.clone());
5637                }
5638            }
5639        }
5640        for (id, subscription) in &subscriptions {
5641            if included.insert(id.clone()) {
5642                ordered_subscriptions.push(subscription.clone());
5643            }
5644        }
5645        let subscriptions_truncated =
5646            ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
5647        ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
5648        let captured_at_ms = self.clock_now_ms();
5649        let lease = if let Some(error_code) = self
5650            .lease_state
5651            .as_ref()
5652            .and_then(|state| state.error_code.clone())
5653        {
5654            ClientDiagnosticsLease {
5655                state: "stopped".to_owned(),
5656                expires_at_ms: self
5657                    .lease_state
5658                    .as_ref()
5659                    .and_then(|state| state.expires_at_ms),
5660                error_code: Some(Self::diagnostic_code(&error_code)),
5661            }
5662        } else if let Some(expires_at_ms) = self
5663            .lease_state
5664            .as_ref()
5665            .and_then(|state| state.expires_at_ms)
5666        {
5667            ClientDiagnosticsLease {
5668                state: if expires_at_ms <= captured_at_ms {
5669                    "expired".to_owned()
5670                } else {
5671                    "active".to_owned()
5672                },
5673                expires_at_ms: Some(expires_at_ms),
5674                error_code: None,
5675            }
5676        } else {
5677            ClientDiagnosticsLease {
5678                state: "none".to_owned(),
5679                expires_at_ms: None,
5680                error_code: None,
5681            }
5682        };
5683        let connectivity = match self.last_round.as_ref() {
5684            Some(round) if round.status == "succeeded" => "online",
5685            Some(round)
5686                if round.status == "failed"
5687                    && round
5688                        .error_code
5689                        .as_deref()
5690                        .is_some_and(Self::retryable_transport_code) =>
5691            {
5692                "offline"
5693            }
5694            _ => "unknown",
5695        };
5696        Ok(ClientDiagnosticsSnapshot {
5697            version: CLIENT_DIAGNOSTICS_VERSION,
5698            captured_at_ms,
5699            host: ClientDiagnosticsHost {
5700                kind: "direct".to_owned(),
5701                role: "single".to_owned(),
5702                connectivity: connectivity.to_owned(),
5703                realtime: if self.realtime_connected {
5704                    "connected".to_owned()
5705                } else {
5706                    "disconnected".to_owned()
5707                },
5708            },
5709            security_lifecycle: self.security_lifecycle().to_owned(),
5710            schema: ClientDiagnosticsSchema {
5711                current_version: self.schema.version,
5712                upgrading: self.upgrading,
5713                required_version: self
5714                    .schema_floor
5715                    .as_ref()
5716                    .and_then(|floor| floor.required_schema_version),
5717                latest_version: self
5718                    .schema_floor
5719                    .as_ref()
5720                    .and_then(|floor| floor.latest_schema_version),
5721            },
5722            replica: ClientDiagnosticsReplica {
5723                local_revision: self.local_revision().to_string(),
5724                sync_needed: self.sync_needed,
5725                pending_outbox: self.outbox.len(),
5726            },
5727            lease,
5728            subscriptions: ordered_subscriptions,
5729            subscriptions_truncated,
5730            last_round: self.last_round.clone(),
5731            last_change: self.last_change.clone(),
5732            storage: self.diagnostics_storage(),
5733        })
5734    }
5735
5736    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
5737        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
5738            let page_count: i64 = self
5739                .conn
5740                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
5741            let page_size: i64 = self
5742                .conn
5743                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
5744            // Reuse compiled aggregates; every invocation still reads current storage.
5745            let outbox_bytes: i64 = self
5746                .conn
5747                .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
5748                .query_row([], |row| row.get(0))?;
5749            let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
5750                "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
5751            )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
5752            let blob_bytes = if self.schema_has_blobs() {
5753                self.conn
5754                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
5755                    .query_row([], |row| row.get(0))?
5756            } else {
5757                0
5758            };
5759            let pressure = self
5760                .limits
5761                .blob_cache_max_bytes
5762                .is_some_and(|limit| blob_bytes > limit);
5763            Ok(ClientDiagnosticsStorage {
5764                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
5765                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
5766                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
5767                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
5768                retained_outcome_entries: Some(outcome_entries.max(0)),
5769                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
5770                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
5771            })
5772        };
5773        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
5774            status: "unreadable".to_owned(),
5775            database_bytes_approx: None,
5776            pending_outbox_bytes_approx: None,
5777            retained_outcome_bytes_approx: None,
5778            retained_outcome_entries: None,
5779            blob_cache_bytes_approx: None,
5780            pressure_reason_code: None,
5781        })
5782    }
5783
5784    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
5785        self.change_queue.drain(..).collect()
5786    }
5787
5788    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
5789        self.sync_intent_queue.drain(..).collect()
5790    }
5791
5792    fn schedule_background_retry(&mut self) {
5793        self.sync_intent_queue.push_back(SyncIntent::Background {
5794            delay_ms: self.retry_delay_ms,
5795        });
5796        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
5797    }
5798
5799    fn reset_background_retry(&mut self) {
5800        self.retry_delay_ms = 250;
5801    }
5802
5803    fn retryable_transport_code(code: &str) -> bool {
5804        code == "transport.failed"
5805            || code == "transport.unavailable"
5806            || code == "sync.transport_failed"
5807    }
5808
5809    fn diagnostic_code(code: &str) -> String {
5810        let valid = !code.is_empty()
5811            && code.len() <= 96
5812            && code.contains('.')
5813            && code
5814                .bytes()
5815                .next()
5816                .is_some_and(|byte| byte.is_ascii_lowercase())
5817            && code.bytes().all(|byte| {
5818                byte.is_ascii_lowercase()
5819                    || byte.is_ascii_digit()
5820                    || matches!(byte, b'.' | b'_' | b'-')
5821            });
5822        if valid {
5823            code.to_owned()
5824        } else {
5825            "client.unknown_failure".to_owned()
5826        }
5827    }
5828
5829    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
5830        if self.sync_needed != value {
5831            if self.begin_observation("syncular_status").is_ok() {
5832                self.sync_needed = value;
5833                let batch = ChangeAccumulator {
5834                    status: true,
5835                    ..ChangeAccumulator::default()
5836                };
5837                if self.finish_observation("syncular_status", batch).is_err() {
5838                    self.rollback_observation("syncular_status");
5839                }
5840            } else {
5841                self.sync_needed = value;
5842            }
5843        }
5844        if value && interactive {
5845            self.sync_intent_queue.push_back(SyncIntent::Interactive);
5846        }
5847    }
5848
5849    fn begin_observation(&self, name: &str) -> Result<(), String> {
5850        self.conn
5851            .execute_batch(&format!("SAVEPOINT {name}"))
5852            .map_err(|error| error.to_string())
5853    }
5854
5855    fn rollback_observation(&self, name: &str) {
5856        let _ = self
5857            .conn
5858            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"));
5859    }
5860
5861    fn finish_observation(&mut self, name: &str, batch: ChangeAccumulator) -> Result<(), String> {
5862        #[cfg(feature = "bench-internals")]
5863        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
5864        if !batch.touched() {
5865            self.conn
5866                .execute_batch(&format!("RELEASE {name}"))
5867                .map_err(|error| error.to_string())?;
5868            return Ok(());
5869        }
5870        let revision = self
5871            .local_revision()
5872            .checked_add(1)
5873            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
5874        self.conn
5875            .execute(
5876                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
5877                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
5878            )
5879            .map_err(|error| error.to_string())?;
5880        let status = batch.status.then(|| self.status_snapshot());
5881        let event = ClientChangeBatch {
5882            revision: revision.to_string(),
5883            tables: batch
5884                .tables
5885                .into_iter()
5886                .map(|(table, scope_keys)| TableChange {
5887                    table,
5888                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
5889                })
5890                .collect(),
5891            windows: batch
5892                .windows
5893                .into_iter()
5894                .map(|((base_key, table), units)| WindowChange {
5895                    base_key,
5896                    table,
5897                    units: units.into_iter().collect(),
5898                })
5899                .collect(),
5900            status,
5901            conflicts_changed: batch.conflicts,
5902            rejections_changed: batch.rejections,
5903            outcomes_changed: batch.outcomes,
5904        };
5905        self.conn
5906            .execute_batch(&format!("RELEASE {name}"))
5907            .map_err(|error| error.to_string())?;
5908        let mut diagnostic_tables = event
5909            .tables
5910            .iter()
5911            .map(|entry| entry.table.clone())
5912            .collect::<BTreeSet<_>>()
5913            .into_iter()
5914            .collect::<Vec<_>>();
5915        let mut diagnostic_windows = event
5916            .windows
5917            .iter()
5918            .map(|entry| entry.table.clone())
5919            .collect::<BTreeSet<_>>()
5920            .into_iter()
5921            .collect::<Vec<_>>();
5922        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
5923            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
5924        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
5925        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
5926        self.last_change = Some(DiagnosticLastChange {
5927            revision: event.revision.clone(),
5928            recorded_at_ms: self.clock_now_ms(),
5929            tables: diagnostic_tables,
5930            windows: diagnostic_windows,
5931            domains_truncated,
5932            status_changed: event.status.is_some(),
5933            conflicts_changed: event.conflicts_changed,
5934            rejections_changed: event.rejections_changed,
5935            outcomes_changed: event.outcomes_changed,
5936        });
5937        self.change_queue.push_back(event);
5938        Ok(())
5939    }
5940
5941    fn record_scope_map(
5942        &self,
5943        batch: &mut ChangeAccumulator,
5944        table_name: &str,
5945        scopes: &[(String, Vec<String>)],
5946    ) {
5947        let Some(table) = self.schema.table(table_name) else {
5948            return;
5949        };
5950        for (variable, values) in scopes {
5951            let Some(scope) = table
5952                .scope_variables
5953                .iter()
5954                .find(|scope| &scope.variable == variable)
5955            else {
5956                continue;
5957            };
5958            for value in values {
5959                batch.scope(table_name, format!("{}:{value}", scope.prefix));
5960            }
5961        }
5962    }
5963
5964    /// Record a row's current scope keys from the base or visible table.
5965    fn record_row_scopes(
5966        &self,
5967        batch: &mut ChangeAccumulator,
5968        table_name: &str,
5969        row_id: &str,
5970        base: bool,
5971    ) -> bool {
5972        let Some(table) = self.schema.table(table_name) else {
5973            return false;
5974        };
5975        if table.scope_variables.is_empty() {
5976            return false;
5977        }
5978        let columns = table
5979            .scope_variables
5980            .iter()
5981            .map(|scope| quote_ident(&scope.column))
5982            .collect::<Vec<_>>()
5983            .join(", ");
5984        let full_table = if base {
5985            base_table(table_name)
5986        } else {
5987            visible_table(table_name)
5988        };
5989        let sql = format!(
5990            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
5991            row_id_predicate(table)
5992        );
5993        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
5994            return false;
5995        };
5996        let values = stmt.query_row(rusqlite::params![row_id], |row| {
5997            let mut values = Vec::with_capacity(table.scope_variables.len());
5998            for index in 0..table.scope_variables.len() {
5999                values.push(row.get::<_, Option<String>>(index)?);
6000            }
6001            Ok(values)
6002        });
6003        let Ok(values) = values else {
6004            return false;
6005        };
6006        let mut recorded = false;
6007        for (scope, value) in table.scope_variables.iter().zip(values) {
6008            if let Some(value) = value {
6009                batch.scope(table_name, format!("{}:{value}", scope.prefix));
6010                recorded = true;
6011            }
6012        }
6013        recorded
6014    }
6015
6016    fn record_commit_changes(
6017        &self,
6018        batch: &mut ChangeAccumulator,
6019        tables: &[String],
6020        changes: &[ssp2::model::Change],
6021    ) {
6022        #[cfg(feature = "bench-internals")]
6023        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
6024        for change in changes {
6025            let Some(table_name) = tables.get(change.table_index as usize) else {
6026                continue;
6027            };
6028            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
6029            if let Some(table) = self.schema.table(table_name) {
6030                for (variable, value) in &change.scopes {
6031                    if let Some(scope) = table
6032                        .scope_variables
6033                        .iter()
6034                        .find(|scope| &scope.variable == variable)
6035                    {
6036                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
6037                        precise = true;
6038                    }
6039                }
6040            }
6041            if !precise {
6042                batch.table(table_name);
6043            }
6044        }
6045    }
6046
6047    fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
6048        if effective.is_empty() {
6049            return false;
6050        }
6051        let Some(table) = self.schema.table(table_name) else {
6052            return false;
6053        };
6054        let mut clauses = Vec::new();
6055        let mut params = Vec::new();
6056        for (variable, values) in effective {
6057            let Some(column) = table.scope_column(variable) else {
6058                return false;
6059            };
6060            if values.is_empty() {
6061                return false;
6062            }
6063            let placeholders = values
6064                .iter()
6065                .map(|value| {
6066                    params.push(SqlValue::Text(value.clone()));
6067                    "?"
6068                })
6069                .collect::<Vec<_>>()
6070                .join(", ");
6071            clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
6072        }
6073        let sql = format!(
6074            "SELECT 1 FROM {} WHERE {} LIMIT 1",
6075            base_table(table_name),
6076            clauses.join(" AND ")
6077        );
6078        self.conn
6079            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
6080            .is_ok()
6081    }
6082
6083    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
6084    pub fn upgrading(&self) -> bool {
6085        self.upgrading
6086    }
6087
6088    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
6089    /// keeping this client's local database (identity, outbox, tables). The
6090    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
6091    /// version changed. Mirrors the TS client's boot-time detection —
6092    /// the Rust core has no persistent restart, so recreation IS the boot.
6093    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
6094        let new_schema = parse_schema_json(schema_json)?;
6095        let marker: Option<i32> = self
6096            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6097            .and_then(|v| v.parse().ok());
6098        self.schema = new_schema;
6099        self.create_bookkeeping_tables()?;
6100        if marker != Some(self.schema.version) {
6101            self.run_schema_reset()?;
6102        }
6103        self.prune_unknown_subscriptions()?;
6104        // The conformance recreate is the in-memory equivalent of reopening a
6105        // durable client. Apply the same startup catch-up contract even when
6106        // the schema itself did not change.
6107        self.enqueue_startup_sync_if_needed()?;
6108        Ok(())
6109    }
6110
6111    fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
6112        let startup_work = !self.stopped
6113            && (!self.outbox.is_empty()
6114                || !self.load_pending_evictions()?.is_empty()
6115                || self.subs.iter().any(|sub| sub.state == SubState::Active));
6116        if startup_work {
6117            self.sync_needed = true;
6118            self.sync_intent_queue.push_back(SyncIntent::Interactive);
6119        }
6120        Ok(())
6121    }
6122
6123    /// A native client persists schema-floor stops across process restarts.
6124    /// Once the running app already satisfies that floor, the persisted stop
6125    /// is only stale evidence from an older server round (for example, the
6126    /// server was restarted after catching up to an app that was ahead).
6127    /// Clear it and let the normal startup pull re-negotiate. If the server is
6128    /// still incompatible it will return the floor again in that first round.
6129    fn clear_satisfied_persisted_schema_floor(&mut self) {
6130        let satisfied = self
6131            .schema_floor
6132            .as_ref()
6133            .and_then(|floor| floor.required_schema_version)
6134            .is_some_and(|required| self.schema.version >= required);
6135        if !satisfied {
6136            return;
6137        }
6138        self.schema_floor = None;
6139        self.stopped = false;
6140        self.delete_meta(SCHEMA_FLOOR_KEY);
6141    }
6142
6143    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
6144    /// and leaseState. Drops/recreates every synced table from the new
6145    /// schema, resets subscription sync-state (keeping registrations), clears
6146    /// the schema-floor stop state, rewrites the marker, drops outbox commits
6147    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
6148    fn run_schema_reset(&mut self) -> Result<(), String> {
6149        self.begin_observation("syncular_schema_reset")?;
6150        let mut batch = ChangeAccumulator::default();
6151        let result = self.run_schema_reset_observed(&mut batch, true);
6152        if let Err(error) = result {
6153            self.rollback_observation("syncular_schema_reset");
6154            return Err(error);
6155        }
6156        if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
6157            self.rollback_observation("syncular_schema_reset");
6158            return Err(error);
6159        }
6160        Ok(())
6161    }
6162
6163    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
6164        let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
6165        self.begin_observation("syncular_log_epoch_reset")?;
6166        let mut batch = ChangeAccumulator::default();
6167        let result = self.run_schema_reset_observed(&mut batch, false);
6168        if let Err(error) = result {
6169            self.rollback_observation("syncular_log_epoch_reset");
6170            return Err(error);
6171        }
6172        self.set_meta(LOG_EPOCH_KEY, log_epoch);
6173        self.sync_needed = true;
6174        batch.status = true;
6175        if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
6176            self.rollback_observation("syncular_log_epoch_reset");
6177            return Err(error);
6178        }
6179        self.sync_intent_queue.push_back(SyncIntent::Interactive);
6180        Ok(resets)
6181    }
6182
6183    fn run_schema_reset_observed(
6184        &mut self,
6185        batch: &mut ChangeAccumulator,
6186        drop_incompatible: bool,
6187    ) -> Result<(), String> {
6188        self.upgrading = true;
6189        batch.status = true;
6190        for table in &self.schema.tables {
6191            batch.table(&table.name);
6192        }
6193        for (base_key, unit, table) in self.load_registered_window_units() {
6194            batch.window(&base_key, &table, &unit);
6195        }
6196        // The per-table insert SQL is derived from the OLD column lists.
6197        self.insert_sql.borrow_mut().clear();
6198        self.overlay_dirty.set(true);
6199        // Drop every synced table (base + visible) that currently exists —
6200        // discovered from sqlite_master so a bump that adds/removes tables is
6201        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
6202        // and the blob cache) are preserved; base tables are `_syncular_base_*`
6203        // so they are matched explicitly, not by the bookkeeping filter.
6204        // Drop virtual tables first so SQLite removes every FTS shadow table
6205        // atomically instead of the generic discovery tearing it apart.
6206        let virtual_tables: Vec<String> = {
6207            let mut stmt = self
6208                .conn
6209                .prepare(
6210                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
6211                )
6212                .map_err(|e| e.to_string())?;
6213            let rows = stmt
6214                .query_map([], |row| row.get::<_, String>(0))
6215                .map_err(|e| e.to_string())?;
6216            rows.filter_map(Result::ok)
6217                .filter(|name| is_synced_table_name(name))
6218                .collect()
6219        };
6220        for name in virtual_tables {
6221            self.conn
6222                .execute_batch(&format!(
6223                    "DROP TABLE IF EXISTS {}",
6224                    quote_ident(&format!("_syncular_fts_{name}"))
6225                ))
6226                .map_err(|e| e.to_string())?;
6227            self.conn
6228                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
6229                .map_err(|e| e.to_string())?;
6230        }
6231        let existing: Vec<String> = {
6232            let mut stmt = self
6233                .conn
6234                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
6235                .map_err(|e| e.to_string())?;
6236            let rows = stmt
6237                .query_map([], |row| row.get::<_, String>(0))
6238                .map_err(|e| e.to_string())?;
6239            rows.filter_map(Result::ok)
6240                .filter(|name| is_synced_table_name(name))
6241                .collect()
6242        };
6243        for name in &existing {
6244            if let Some(table) = name.strip_prefix("_syncular_base_") {
6245                batch.table(table);
6246            } else if !name.starts_with("_syncular_") {
6247                batch.table(name);
6248            }
6249        }
6250        for name in existing {
6251            self.conn
6252                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
6253                .map_err(|e| e.to_string())?;
6254        }
6255        // Recreate the synced tables from the NEW schema.
6256        self.create_synced_tables()?;
6257        // Reset every subscription's sync-state, keeping the registration.
6258        for sub in &mut self.subs {
6259            sub.cursor = -1;
6260            sub.bootstrap_state = None;
6261            sub.effective = None;
6262            sub.state = SubState::Active;
6263            sub.reason_code = None;
6264            sub.synced_once = false;
6265        }
6266        let subs = self.subs.clone();
6267        for sub in &subs {
6268            self.persist_sub(sub)?;
6269        }
6270        // The stop state is over: this client now ships a servable schema.
6271        self.stopped = false;
6272        self.schema_floor = None;
6273        self.delete_meta(SCHEMA_FLOOR_KEY);
6274        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
6275        self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
6276        // §7.4.4: drop outbox commits that cannot re-encode under the new
6277        // schema (a referenced column/table the bump removed), surfacing each
6278        // as a `sync.outbox_incompatible` rejection.
6279        if drop_incompatible && self.drop_incompatible_outbox()? {
6280            batch.rejections = true;
6281            batch.status = true;
6282            batch.outcomes = true;
6283        }
6284        // Re-apply the surviving outbox optimistically over the empty tables.
6285        self.rebuild_overlay();
6286        Ok(())
6287    }
6288
6289    /// §7.4.4: a persisted upsert whose values reference a column the current
6290    /// schema lacks (or a removed table) cannot be encoded. Drop the commit
6291    /// and raise a client-local `sync.outbox_incompatible` rejection.
6292    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
6293        let schema = &self.schema;
6294        let incompatible = self
6295            .outbox
6296            .iter()
6297            .filter(|commit| {
6298                commit.ops.iter().any(|op| {
6299                    if !op.upsert {
6300                        return false;
6301                    }
6302                    match schema.table(&op.table) {
6303                        None => true,
6304                        Some(table) => op.values.as_ref().is_some_and(|values| {
6305                            values
6306                                .keys()
6307                                .any(|key| !table.columns.iter().any(|c| &c.name == key))
6308                        }),
6309                    }
6310                })
6311            })
6312            .cloned()
6313            .collect::<Vec<_>>();
6314        if incompatible.is_empty() {
6315            return Ok(false);
6316        }
6317        let mut rejections = Vec::new();
6318        for commit in &incompatible {
6319            let results = commit
6320                .ops
6321                .iter()
6322                .enumerate()
6323                .map(|(op_index, operation)| {
6324                    let rejection = RejectionRecord {
6325                        client_commit_id: commit.client_commit_id.clone(),
6326                        op_index: op_index as i32,
6327                        code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
6328                        message: "the persisted commit cannot encode under the current schema"
6329                            .to_owned(),
6330                        retryable: false,
6331                        details: None,
6332                        operation: Some(CommitOperation::from(operation)),
6333                    };
6334                    rejections.push(rejection.clone());
6335                    CommitOperationOutcome::Error { rejection }
6336                })
6337                .collect::<Vec<_>>();
6338            self.persist_commit_outcome(
6339                &commit.client_commit_id,
6340                CommitOutcomeStatus::Rejected,
6341                &results,
6342                Some(&commit.ops),
6343            )?;
6344            self.delete_outbox_persisted(&commit.client_commit_id)?;
6345        }
6346        self.prune_commit_outcomes()?;
6347        let incompatible_ids = incompatible
6348            .iter()
6349            .map(|commit| commit.client_commit_id.as_str())
6350            .collect::<BTreeSet<_>>();
6351        self.outbox
6352            .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
6353        self.rejections.extend(rejections);
6354        Ok(true)
6355    }
6356
6357    /// §7.4.3: (re)create the base + visible table pair for every synced
6358    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
6359    fn create_synced_tables(&self) -> Result<(), String> {
6360        for table in &self.schema.tables {
6361            // The base half + the visible half form the synced-table pair. An
6362            // index name is global in SQLite, so the base half's indexes are
6363            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
6364            for (full, index_prefix) in [
6365                (base_table(&table.name), "_syncular_base_"),
6366                (visible_table(&table.name), ""),
6367            ] {
6368                let mut cols: Vec<String> =
6369                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
6370                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
6371                let sql = format!(
6372                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
6373                    cols.join(", "),
6374                    quote_ident(&table.primary_key)
6375                );
6376                self.conn.execute(&sql, []).map_err(|e| e.to_string())?;
6377                // Local secondary indexes (CREATE INDEX subset). Created on
6378                // both halves so mirror reads hit an index on either. Runs on
6379                // both the initial create and the §7.4.3 reset recreate path.
6380                for index in &table.indexes {
6381                    let unique = if index.unique { "UNIQUE " } else { "" };
6382                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
6383                    let cols_sql = index
6384                        .columns
6385                        .iter()
6386                        .map(|c| quote_ident(c))
6387                        .collect::<Vec<_>>()
6388                        .join(", ");
6389                    let index_sql = format!(
6390                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
6391                    );
6392                    self.conn
6393                        .execute(&index_sql, [])
6394                        .map_err(|e| e.to_string())?;
6395                }
6396            }
6397        }
6398        // FTS exists only on the visible half. It is a local search
6399        // projection, never a synced/base table or a wire-schema member.
6400        for table in &self.schema.tables {
6401            for index in &table.fts_indexes {
6402                self.create_fts_projection(table, index)?;
6403            }
6404        }
6405        Ok(())
6406    }
6407
6408    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
6409        let count: i64 = self
6410            .conn
6411            .query_row(
6412                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
6413                rusqlite::params![index.name],
6414                |row| row.get(0),
6415            )
6416            .map_err(|e| e.to_string())?;
6417        Ok(count > 0)
6418    }
6419
6420    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
6421        for suffix in ["ai", "ad", "au"] {
6422            self.conn
6423                .execute(
6424                    &format!(
6425                        "DROP TRIGGER IF EXISTS {}",
6426                        quote_ident(&format!("{}_{suffix}", index.name))
6427                    ),
6428                    [],
6429                )
6430                .map_err(|e| e.to_string())?;
6431        }
6432        Ok(())
6433    }
6434
6435    fn create_fts_triggers(
6436        &self,
6437        table: &TableSchema,
6438        index: &FtsIndexSchema,
6439    ) -> Result<(), String> {
6440        let fts = quote_ident(&index.name);
6441        let source = visible_table(&table.name);
6442        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
6443        let pk = quote_ident(&table.primary_key);
6444        let projection_columns = std::iter::once(source_id.clone())
6445            .chain(index.columns.iter().map(|column| quote_ident(column)))
6446            .collect::<Vec<_>>()
6447            .join(", ");
6448        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
6449            .chain(
6450                index
6451                    .columns
6452                    .iter()
6453                    .map(|column| format!("new.{}", quote_ident(column))),
6454            )
6455            .collect::<Vec<_>>()
6456            .join(", ");
6457        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
6458        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)");
6459        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)");
6460        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})");
6461        let bi = quote_ident(&format!("{}_bi", index.name));
6462        let ai = quote_ident(&format!("{}_ai", index.name));
6463        let ad = quote_ident(&format!("{}_ad", index.name));
6464        let au = quote_ident(&format!("{}_au", index.name));
6465        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
6466        let sql = format!(
6467            "DROP TRIGGER IF EXISTS {bi};
6468             DROP TRIGGER IF EXISTS {ai};
6469             DROP TRIGGER IF EXISTS {ad};
6470             DROP TRIGGER IF EXISTS {au};
6471             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
6472             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
6473             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
6474             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
6475        );
6476        self.conn.execute_batch(&sql).map_err(|e| e.to_string())
6477    }
6478
6479    fn rebuild_fts_projection(
6480        &self,
6481        table: &TableSchema,
6482        index: &FtsIndexSchema,
6483    ) -> Result<(), String> {
6484        let fts = quote_ident(&index.name);
6485        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
6486        let pk = quote_ident(&table.primary_key);
6487        let indexed_columns = index
6488            .columns
6489            .iter()
6490            .map(|column| quote_ident(column))
6491            .collect::<Vec<_>>();
6492        let projection_columns = std::iter::once(source_id.clone())
6493            .chain(indexed_columns.iter().cloned())
6494            .collect::<Vec<_>>()
6495            .join(", ");
6496        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
6497        let sql = format!(
6498            "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};",
6499            columns = indexed_columns.join(", "),
6500            source = visible_table(&table.name),
6501        );
6502        self.conn.execute_batch(&sql).map_err(|e| e.to_string())
6503    }
6504
6505    fn create_fts_projection(
6506        &self,
6507        table: &TableSchema,
6508        index: &FtsIndexSchema,
6509    ) -> Result<(), String> {
6510        self.begin_observation("syncular_fts_schema")?;
6511        let result = (|| {
6512            let existed = self.fts_projection_exists(index)?;
6513            let tokenizer = index.tokenize.replace('\'', "''");
6514            let columns = index
6515                .columns
6516                .iter()
6517                .map(|column| quote_ident(column))
6518                .collect::<Vec<_>>()
6519                .join(", ");
6520            let sql = format!(
6521            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
6522            fts = quote_ident(&index.name),
6523            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
6524        );
6525            self.conn.execute(&sql, []).map_err(|error| {
6526                format!(
6527                    "cannot create local FTS5 projection {:?}: {error}",
6528                    index.name
6529                )
6530            })?;
6531            let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
6532            let mapped: bool = self
6533                .conn
6534                .query_row(
6535                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
6536                    [format!("_syncular_fts_{}", index.name)],
6537                    |row| row.get(0),
6538                )
6539                .map_err(|e| e.to_string())?;
6540            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())?;
6541            if !mapped && existed {
6542                self.conn
6543                    .execute_batch(&format!(
6544                        "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
6545                        quote_ident(FTS_SOURCE_ID_COLUMN),
6546                        quote_ident(&index.name)
6547                    ))
6548                    .map_err(|e| e.to_string())?;
6549            }
6550            self.create_fts_triggers(table, index)?;
6551            if !existed {
6552                self.rebuild_fts_projection(table, index)?;
6553            }
6554            Ok(())
6555        })();
6556        if let Err(error) = result {
6557            self.rollback_observation("syncular_fts_schema");
6558            return Err(error);
6559        }
6560        if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
6561            self.rollback_observation("syncular_fts_schema");
6562            return Err(error.to_string());
6563        }
6564        Ok(())
6565    }
6566
6567    // -- persistence write-through --------------------------------------------
6568
6569    fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
6570        let state = serde_json::json!({
6571            "requested": scope_map_to_json(&sub.requested),
6572            "params": sub.params,
6573            "cursor": sub.cursor,
6574            "bootstrapState": sub.bootstrap_state,
6575            "status": sub.state.name(),
6576            "reasonCode": sub.reason_code,
6577            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
6578            "syncedOnce": sub.synced_once,
6579        });
6580        self.conn.execute(
6581            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
6582            rusqlite::params![sub.id, sub.table, state.to_string()],
6583        ).map(|_| ()).map_err(|error| error.to_string())
6584    }
6585
6586    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
6587        let ops: Vec<Value> = commit
6588            .ops
6589            .iter()
6590            .map(|op| {
6591                serde_json::json!({
6592                    "op": if op.upsert { "upsert" } else { "delete" },
6593                    "table": op.table,
6594                    "rowId": op.row_id,
6595                    "baseVersion": op.base_version,
6596                    "values": op.values.clone().map(Value::Object),
6597                })
6598            })
6599            .collect();
6600        self.conn
6601            .execute(
6602                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
6603                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
6604            )
6605            .map_err(|error| error.to_string())?;
6606        for operation in &commit.ops {
6607            let Some(values) = &operation.values else {
6608                continue;
6609            };
6610            let Some(table) = self.schema.table(&operation.table) else {
6611                continue;
6612            };
6613            for column in table
6614                .columns
6615                .iter()
6616                .filter(|column| column.ty == ColumnType::BlobRef)
6617            {
6618                let Some(Value::String(raw)) = values.get(&column.name) else {
6619                    continue;
6620                };
6621                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
6622                    continue;
6623                };
6624                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
6625                    continue;
6626                };
6627                self.conn
6628                    .execute(
6629                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
6630                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
6631                        rusqlite::params![commit.client_commit_id, blob_id],
6632                    )
6633                    .map_err(|error| error.to_string())?;
6634            }
6635        }
6636        Ok(())
6637    }
6638
6639    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
6640        self.conn
6641            .execute(
6642                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
6643                rusqlite::params![client_commit_id],
6644            )
6645            .map_err(|error| error.to_string())?;
6646        self.conn
6647            .execute(
6648                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
6649                rusqlite::params![client_commit_id],
6650            )
6651            .map(|_| ())
6652            .map_err(|error| error.to_string())
6653    }
6654
6655    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
6656        match status {
6657            CommitOutcomeStatus::Applied => "applied",
6658            CommitOutcomeStatus::Cached => "cached",
6659            CommitOutcomeStatus::Conflict => "conflict",
6660            CommitOutcomeStatus::Rejected => "rejected",
6661        }
6662    }
6663
6664    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
6665        match resolution {
6666            CommitOutcomeResolution::Active => "active",
6667            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
6668            CommitOutcomeResolution::Superseded => "superseded",
6669            CommitOutcomeResolution::Dismissed => "dismissed",
6670        }
6671    }
6672
6673    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
6674        match value {
6675            "applied" => Ok(CommitOutcomeStatus::Applied),
6676            "cached" => Ok(CommitOutcomeStatus::Cached),
6677            "conflict" => Ok(CommitOutcomeStatus::Conflict),
6678            "rejected" => Ok(CommitOutcomeStatus::Rejected),
6679            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
6680        }
6681    }
6682
6683    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
6684        match value {
6685            "active" => Ok(CommitOutcomeResolution::Active),
6686            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
6687            "superseded" => Ok(CommitOutcomeResolution::Superseded),
6688            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
6689            _ => Err(format!(
6690                "invalid persisted commit outcome resolution {value:?}"
6691            )),
6692        }
6693    }
6694
6695    fn persist_commit_outcome(
6696        &self,
6697        client_commit_id: &str,
6698        status: CommitOutcomeStatus,
6699        results: &[CommitOperationOutcome],
6700        operations: Option<&[OutboxOp]>,
6701    ) -> Result<(), String> {
6702        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
6703        let operations_json = operations
6704            .map(|items| {
6705                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
6706            })
6707            .transpose()
6708            .map_err(|error| error.to_string())?;
6709        self.conn
6710            .execute(
6711                "INSERT INTO _syncular_commit_outcomes (
6712                   client_commit_id, status, recorded_at_ms, results_json,
6713                   operations_json, resolution
6714                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
6715                rusqlite::params![
6716                    client_commit_id,
6717                    Self::outcome_status_name(status),
6718                    self.clock_now_ms(),
6719                    results_json,
6720                    operations_json
6721                ],
6722            )
6723            .map(|_| ())
6724            .map_err(|error| error.to_string())
6725    }
6726
6727    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
6728        let StoredCommitOutcomeRow {
6729            sequence,
6730            client_commit_id,
6731            status,
6732            recorded_at_ms,
6733            results_json,
6734            operations_json,
6735            resolution,
6736            resolved_at_ms,
6737            replacement_client_commit_id,
6738        } = row;
6739        Ok(CommitOutcome {
6740            sequence,
6741            client_commit_id,
6742            status: Self::parse_outcome_status(&status)?,
6743            recorded_at_ms,
6744            results: serde_json::from_str(&results_json)
6745                .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
6746            operations: operations_json
6747                .map(|value| {
6748                    serde_json::from_str(&value).map_err(|error| {
6749                        format!("invalid persisted commit outcome operations: {error}")
6750                    })
6751                })
6752                .transpose()?,
6753            resolution: Self::parse_outcome_resolution(&resolution)?,
6754            resolved_at_ms,
6755            replacement_client_commit_id,
6756        })
6757    }
6758
6759    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
6760        let row = self
6761            .conn
6762            .query_row(
6763                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
6764                        resolution, resolved_at_ms, replacement_client_commit_id
6765                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
6766                rusqlite::params![client_commit_id],
6767                |row| {
6768                    Ok(StoredCommitOutcomeRow {
6769                        sequence: row.get(0)?,
6770                        client_commit_id: row.get(1)?,
6771                        status: row.get(2)?,
6772                        recorded_at_ms: row.get(3)?,
6773                        results_json: row.get(4)?,
6774                        operations_json: row.get(5)?,
6775                        resolution: row.get(6)?,
6776                        resolved_at_ms: row.get(7)?,
6777                        replacement_client_commit_id: row.get(8)?,
6778                    })
6779                },
6780            )
6781            .optional()
6782            .map_err(|error| error.to_string())?;
6783        row.map(Self::outcome_from_row).transpose()
6784    }
6785
6786    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
6787        if query.limit == Some(0) {
6788            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
6789        }
6790        let mut sql = String::from(
6791            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
6792                    resolution, resolved_at_ms, replacement_client_commit_id
6793               FROM _syncular_commit_outcomes",
6794        );
6795        if query.active_only {
6796            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
6797        }
6798        sql.push_str(" ORDER BY seq DESC");
6799        if let Some(limit) = query.limit {
6800            sql.push_str(&format!(" LIMIT {limit}"));
6801        }
6802        let mut stmt = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
6803        let rows = stmt
6804            .query_map([], |row| {
6805                Ok(StoredCommitOutcomeRow {
6806                    sequence: row.get(0)?,
6807                    client_commit_id: row.get(1)?,
6808                    status: row.get(2)?,
6809                    recorded_at_ms: row.get(3)?,
6810                    results_json: row.get(4)?,
6811                    operations_json: row.get(5)?,
6812                    resolution: row.get(6)?,
6813                    resolved_at_ms: row.get(7)?,
6814                    replacement_client_commit_id: row.get(8)?,
6815                })
6816            })
6817            .map_err(|error| error.to_string())?;
6818        let mut outcomes = Vec::new();
6819        for row in rows {
6820            outcomes.push(Self::outcome_from_row(
6821                row.map_err(|error| error.to_string())?,
6822            )?);
6823        }
6824        Ok(outcomes)
6825    }
6826
6827    fn prune_commit_outcomes(&self) -> Result<(), String> {
6828        #[cfg(test)]
6829        self.outcome_prune_count
6830            .set(self.outcome_prune_count.get() + 1);
6831        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
6832        let count = self
6833            .conn
6834            .query_row(
6835                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
6836                [],
6837                |row| row.get::<_, i64>(0),
6838            )
6839            .map_err(|error| error.to_string())? as usize;
6840        let excess = count.saturating_sub(max_entries);
6841        if excess == 0 {
6842            return Ok(());
6843        }
6844        let mut stmt = self
6845            .conn
6846            .prepare(
6847                "SELECT seq FROM _syncular_commit_outcomes
6848                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
6849                  ORDER BY seq ASC LIMIT ?1",
6850            )
6851            .map_err(|error| error.to_string())?;
6852        let rows = stmt
6853            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
6854            .map_err(|error| error.to_string())?;
6855        let sequences = rows
6856            .collect::<Result<Vec<_>, _>>()
6857            .map_err(|error| error.to_string())?;
6858        drop(stmt);
6859        for sequence in sequences {
6860            self.conn
6861                .execute(
6862                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
6863                    rusqlite::params![sequence],
6864                )
6865                .map_err(|error| error.to_string())?;
6866        }
6867        Ok(())
6868    }
6869
6870    // -- driver surface ---------------------------------------------------------
6871
6872    pub fn subscribe(
6873        &mut self,
6874        id: String,
6875        table: String,
6876        scopes: Vec<(String, Vec<String>)>,
6877        params: Option<String>,
6878    ) -> Result<(), String> {
6879        if self.schema.table(&table).is_none() {
6880            return Err(format!("unknown table {table:?}"));
6881        }
6882        if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
6883            let same_intent = existing.table == table
6884                && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
6885                && existing.params == params;
6886            if same_intent {
6887                return Ok(());
6888            }
6889            return Err(
6890                "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
6891                    .to_owned(),
6892            );
6893        }
6894        let sub = Subscription {
6895            id: id.clone(),
6896            table,
6897            requested: scopes,
6898            params,
6899            cursor: -1,
6900            bootstrap_state: None,
6901            state: SubState::Active,
6902            reason_code: None,
6903            effective: None,
6904            synced_once: false,
6905        };
6906        self.persist_sub(&sub)?;
6907        self.subs.push(sub);
6908        Ok(())
6909    }
6910
6911    pub fn unsubscribe(&mut self, id: &str) {
6912        self.subs.retain(|s| s.id != id);
6913        let _ = self.conn.execute(
6914            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
6915            rusqlite::params![id],
6916        );
6917    }
6918
6919    // -- windowed subscriptions (§4.8) ------------------------------------------
6920
6921    /// §4.8: set the live window units for a base — a value-sharded family
6922    /// of subscriptions, one per unit. Added units get fresh subscriptions
6923    /// (image-lane bootstrap on the next sync); removed units are
6924    /// unsubscribed with their first eviction chunk in one transaction (E1–E4).
6925    /// Idempotent; re-entry cancels any deferred eviction.
6926    pub fn set_window(
6927        &mut self,
6928        base: &WindowBase,
6929        units: &[String],
6930    ) -> Result<CommandEffects, String> {
6931        let table = self
6932            .schema
6933            .table(&base.table)
6934            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
6935        if table.scope_column(&base.variable).is_none() {
6936            return Err(format!(
6937                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
6938                base.table, base.variable
6939            ));
6940        }
6941        let base_key = window_base_key(base);
6942        let wanted: std::collections::HashSet<&String> = units.iter().collect();
6943        let live = self.load_window_units(&base_key);
6944        self.begin_observation("syncular_window")?;
6945        let mut batch = ChangeAccumulator::default();
6946        let mut changed = false;
6947
6948        let prior_subs = self.subs.clone();
6949        let widened = (|| {
6950            // Widen: units wanted but not live → fresh subscription + registry row.
6951            for unit in units {
6952                if live.iter().any(|(u, _)| u == unit) {
6953                    continue;
6954                }
6955                let sub_id = derive_sub_id(base, unit);
6956                self.delete_pending_evict(&sub_id)?;
6957                self.insert_window_unit(&base_key, unit, &sub_id)?;
6958                self.subscribe(
6959                    sub_id,
6960                    base.table.clone(),
6961                    unit_scopes(base, unit),
6962                    base.params.clone(),
6963                )?;
6964                batch.window(&base_key, &base.table, unit);
6965                changed = true;
6966            }
6967
6968            self.finish_observation("syncular_window", batch)
6969        })();
6970        if let Err(error) = widened {
6971            self.rollback_observation("syncular_window");
6972            self.subs = prior_subs;
6973            return Err(error);
6974        }
6975        for (unit, sub_id) in live {
6976            if wanted.contains(&unit) {
6977                continue;
6978            }
6979            self.rebuild_overlay_if_dirty();
6980            let prior_subs = self.subs.clone();
6981            self.begin_observation("syncular_window")?;
6982            let effective = self
6983                .subs
6984                .iter()
6985                .find(|sub| sub.id == sub_id)
6986                .and_then(|sub| sub.effective.clone())
6987                .unwrap_or_else(|| unit_scopes(base, &unit));
6988            let mut batch = ChangeAccumulator::default();
6989            self.record_scope_map(&mut batch, &base.table, &effective);
6990            batch.window(&base_key, &base.table, &unit);
6991            let result = self.evict_unit(&base_key, base, &unit, &sub_id);
6992            let remaining = match result {
6993                Ok(remaining) => remaining,
6994                Err(error) => {
6995                    self.rollback_observation("syncular_window");
6996                    self.subs = prior_subs;
6997                    return Err(error);
6998                }
6999            };
7000            if let Err(error) = self.finish_observation("syncular_window", batch) {
7001                self.rollback_observation("syncular_window");
7002                self.subs = prior_subs;
7003                return Err(error);
7004            }
7005            if remaining {
7006                self.drain_pending_evictions()?;
7007            }
7008            changed = true;
7009        }
7010        Ok(if changed {
7011            CommandEffects::interactive()
7012        } else {
7013            CommandEffects::none()
7014        })
7015    }
7016
7017    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
7018    /// the subset still bootstrap-pending. Registration alone is not
7019    /// completeness — a unit is pending until its subscription completes a
7020    /// bootstrap round (cursor advances past -1 with no resume token held).
7021    pub fn window_state(&self, base: &WindowBase) -> WindowState {
7022        let mut units = Vec::new();
7023        let mut pending = Vec::new();
7024        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
7025            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
7026                Some(sub) => {
7027                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
7028                }
7029                None => true,
7030            };
7031            if is_pending {
7032                pending.push(unit.clone());
7033            }
7034            units.push(unit);
7035        }
7036        WindowState { units, pending }
7037    }
7038
7039    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
7040        let mut stmt = match self
7041            .conn
7042            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
7043        {
7044            Ok(stmt) => stmt,
7045            Err(_) => return Vec::new(),
7046        };
7047        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
7048            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7049        });
7050        match rows {
7051            Ok(rows) => rows.filter_map(Result::ok).collect(),
7052            Err(_) => Vec::new(),
7053        }
7054    }
7055
7056    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
7057        let mut stmt = match self.conn.prepare(
7058            "SELECT windows.base, windows.unit, subscriptions.tbl
7059               FROM _syncular_windows AS windows
7060               JOIN _syncular_subscriptions AS subscriptions
7061                 ON subscriptions.id = windows.sub_id
7062               ORDER BY windows.base, windows.unit",
7063        ) {
7064            Ok(stmt) => stmt,
7065            Err(_) => return Vec::new(),
7066        };
7067        let rows = stmt.query_map([], |row| {
7068            Ok((
7069                row.get::<_, String>(0)?,
7070                row.get::<_, String>(1)?,
7071                row.get::<_, String>(2)?,
7072            ))
7073        });
7074        match rows {
7075            Ok(rows) => rows.filter_map(Result::ok).collect(),
7076            Err(_) => Vec::new(),
7077        }
7078    }
7079
7080    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
7081        self.conn
7082            .query_row(
7083                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
7084                rusqlite::params![sub_id],
7085                |row| Ok((row.get(0)?, row.get(1)?)),
7086            )
7087            .ok()
7088    }
7089
7090    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
7091        self.conn
7092            .execute(
7093                "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
7094                rusqlite::params![base_key, unit, sub_id],
7095            )
7096            .map(|_| ())
7097            .map_err(|error| error.to_string())
7098    }
7099
7100    fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
7101        self.conn
7102            .execute(
7103                "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
7104                rusqlite::params![base_key, unit],
7105            )
7106            .map(|_| ())
7107            .map_err(|error| error.to_string())
7108    }
7109
7110    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
7111    /// Deletes the unit's rows except those pinned by a pending outbox
7112    /// commit (E1); records a deferred eviction if any pin remains; discards
7113    /// the subscription's cursor/resume/effective-echo (E3) and version
7114    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
7115    /// nothing.
7116    fn evict_unit(
7117        &mut self,
7118        base_key: &str,
7119        base: &WindowBase,
7120        unit: &str,
7121        sub_id: &str,
7122    ) -> Result<bool, String> {
7123        let effective = self
7124            .subs
7125            .iter()
7126            .find(|s| s.id == sub_id)
7127            .and_then(|s| s.effective.clone())
7128            .unwrap_or_else(|| unit_scopes(base, unit));
7129        let pinned = self.pinned_row_ids(&base.table);
7130        let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
7131        self.delete_window_unit(base_key, unit)?;
7132        self.conn
7133            .execute(
7134                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7135                [sub_id],
7136            )
7137            .map_err(|error| error.to_string())?;
7138        self.subs.retain(|sub| sub.id != sub_id);
7139        if deferred {
7140            self.save_pending_evict(sub_id, &base.table, &effective)?;
7141        } else {
7142            self.delete_pending_evict(sub_id)?;
7143        }
7144        Ok(remaining)
7145    }
7146
7147    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
7148    /// primary keys. Returns (more unpinned work, any scoped rows remain).
7149    /// A missing scope-column mapping fails without deleting rows.
7150    fn evict_scope_rows(
7151        &self,
7152        table_name: &str,
7153        effective: &[(String, Vec<String>)],
7154        pinned: &std::collections::HashSet<String>,
7155    ) -> Result<(bool, bool), String> {
7156        if effective.is_empty() {
7157            return Ok((false, false));
7158        }
7159        let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
7160        let mut clauses = Vec::new();
7161        let mut params: Vec<SqlValue> = Vec::new();
7162        for (variable, values) in effective {
7163            let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
7164            if values.is_empty() {
7165                return Ok((false, false));
7166            }
7167            let holes = values
7168                .iter()
7169                .map(|v| {
7170                    params.push(SqlValue::Text(v.clone()));
7171                    "?"
7172                })
7173                .collect::<Vec<_>>()
7174                .join(", ");
7175            clauses.push(format!("{} IN ({holes})", quote_ident(column)));
7176        }
7177        let scope = clauses.join(" AND ");
7178        let scope_params = params.len();
7179        let pk = quote_ident(&table.primary_key);
7180        if !pinned.is_empty() {
7181            let holes = pinned
7182                .iter()
7183                .map(|id| {
7184                    params.push(SqlValue::Text(id.clone()));
7185                    "?"
7186                })
7187                .collect::<Vec<_>>()
7188                .join(", ");
7189            clauses.push(format!("{pk} NOT IN ({holes})"));
7190        }
7191        let predicate = clauses.join(" AND ");
7192        let base = base_table(table_name);
7193        let visible = visible_table(table_name);
7194        let mut ids: Vec<SqlValue> = Vec::new();
7195        for source in [&base, &visible] {
7196            let mut stmt = self
7197                .conn
7198                .prepare_cached(&format!(
7199                    "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
7200                ))
7201                .map_err(|e| e.to_string())?;
7202            ids = stmt
7203                .query_map(rusqlite::params_from_iter(&params), |row| row.get(0))
7204                .map_err(|e| e.to_string())?
7205                .collect::<Result<_, _>>()
7206                .map_err(|e| e.to_string())?;
7207            if !ids.is_empty() {
7208                break;
7209            }
7210        }
7211        if !ids.is_empty() {
7212            let holes = vec!["?"; ids.len()].join(", ");
7213            for source in [&base, &visible] {
7214                self.conn
7215                    .execute(
7216                        &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
7217                        rusqlite::params_from_iter(&ids),
7218                    )
7219                    .map_err(|e| e.to_string())?;
7220            }
7221        }
7222        let mut remaining = false;
7223        let mut deferred = false;
7224        for source in [&base, &visible] {
7225            remaining |= self
7226                .conn
7227                .query_row(
7228                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
7229                    rusqlite::params_from_iter(&params),
7230                    |row| row.get::<_, bool>(0),
7231                )
7232                .map_err(|e| e.to_string())?;
7233            deferred |= self
7234                .conn
7235                .query_row(
7236                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
7237                    rusqlite::params_from_iter(&params[..scope_params]),
7238                    |row| row.get::<_, bool>(0),
7239                )
7240                .map_err(|e| e.to_string())?;
7241        }
7242        Ok((remaining, deferred))
7243    }
7244
7245    /// Resume committed eviction chunks, retaining outbox pins and their durable marker.
7246    fn drain_pending_evictions(&mut self) -> Result<(), String> {
7247        let pending = self.load_pending_evictions()?;
7248        if pending.is_empty() {
7249            return Ok(());
7250        }
7251        self.rebuild_overlay_if_dirty();
7252        for (sub_id, table_name, effective) in pending {
7253            if self.schema.table(&table_name).is_none() {
7254                self.delete_pending_evict(&sub_id)?;
7255                continue;
7256            }
7257            loop {
7258                std::thread::yield_now();
7259                self.begin_observation("syncular_evict")?;
7260                let pinned = self.pinned_row_ids(&table_name);
7261                let result = self.evict_scope_rows(&table_name, &effective, &pinned);
7262                let (remaining, deferred) = match result {
7263                    Ok(result) => result,
7264                    Err(error) => {
7265                        self.rollback_observation("syncular_evict");
7266                        return Err(error);
7267                    }
7268                };
7269                if !deferred {
7270                    if let Err(error) = self.delete_pending_evict(&sub_id) {
7271                        self.rollback_observation("syncular_evict");
7272                        return Err(error);
7273                    }
7274                }
7275                let mut batch = ChangeAccumulator::default();
7276                self.record_scope_map(&mut batch, &table_name, &effective);
7277                if let Err(error) = self.finish_observation("syncular_evict", batch) {
7278                    self.rollback_observation("syncular_evict");
7279                    return Err(error);
7280                }
7281                if !remaining {
7282                    break;
7283                }
7284            }
7285        }
7286        Ok(())
7287    }
7288
7289    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
7290    /// commit — rows that MUST NOT be evicted until the commit drains.
7291    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
7292        let mut pinned = std::collections::HashSet::new();
7293        for commit in &self.outbox {
7294            for op in &commit.ops {
7295                if op.table == table {
7296                    pinned.insert(op.row_id.clone());
7297                }
7298            }
7299        }
7300        pinned
7301    }
7302
7303    fn save_pending_evict(
7304        &self,
7305        sub_id: &str,
7306        table: &str,
7307        effective: &[(String, Vec<String>)],
7308    ) -> Result<(), String> {
7309        self.conn.execute(
7310            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
7311            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
7312        ).map(|_| ()).map_err(|error| error.to_string())
7313    }
7314
7315    fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
7316        self.conn
7317            .execute(
7318                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
7319                rusqlite::params![sub_id],
7320            )
7321            .map(|_| ())
7322            .map_err(|error| error.to_string())
7323    }
7324
7325    fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
7326        let mut stmt = self
7327            .conn
7328            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
7329            .map_err(|error| error.to_string())?;
7330        let rows = stmt
7331            .query_map([], |row| {
7332                Ok((
7333                    row.get::<_, String>(0)?,
7334                    row.get::<_, String>(1)?,
7335                    row.get::<_, String>(2)?,
7336                ))
7337            })
7338            .map_err(|error| error.to_string())?;
7339        rows.map(|row| {
7340            let (sub_id, table, json) = row.map_err(|error| error.to_string())?;
7341            let value: Value = serde_json::from_str(&json)
7342                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
7343            let effective = json_to_scope_map(&value)
7344                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
7345            Ok((sub_id, table, effective))
7346        })
7347        .collect()
7348    }
7349
7350    /// Record one atomic local commit (§7.1) and apply it optimistically.
7351    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
7352        if mutations.is_empty() {
7353            return Err("a commit must contain at least one operation (§6.1)".to_owned());
7354        }
7355        let mut ops = Vec::with_capacity(mutations.len());
7356        for mutation in mutations {
7357            match mutation {
7358                Mutation::Upsert {
7359                    table,
7360                    values,
7361                    base_version,
7362                } => {
7363                    let schema_table = self
7364                        .schema
7365                        .table(&table)
7366                        .ok_or_else(|| format!("unknown table {table:?}"))?;
7367                    // §5: value keys are accepted in snake_case AND the
7368                    // generated row types' camelCase; normalize to SQL truth
7369                    // before the pk lookup / codec see them.
7370                    let values = normalize_values_casing(schema_table, values)?;
7371                    let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
7372                    // §6.7 `mutate` marks every column present: fill missing
7373                    // nullable columns with NULL before the payload is built.
7374                    let values = full_row_values(schema_table, values)?;
7375                    // §5.11: validate the payload encodes with the current
7376                    // codec. A key-selection or unknown-key failure is a
7377                    // durable rejection at the push seam (§10.3), never an
7378                    // author-time error. Full-row `mutate` presents every
7379                    // column, so no stored fallback is needed.
7380                    self.validate_author_encode(schema_table, &row_id, &values)?;
7381                    ops.push(OutboxOp {
7382                        upsert: true,
7383                        table,
7384                        row_id,
7385                        base_version,
7386                        values: Some(values),
7387                    });
7388                }
7389                Mutation::Delete {
7390                    table,
7391                    row_id,
7392                    base_version,
7393                } => {
7394                    if self.schema.table(&table).is_none() {
7395                        return Err(format!("unknown table {table:?}"));
7396                    }
7397                    ops.push(OutboxOp {
7398                        upsert: false,
7399                        table,
7400                        row_id,
7401                        base_version,
7402                        values: None,
7403                    });
7404                }
7405            }
7406        }
7407        self.record_outbox_commit(ops)
7408    }
7409
7410    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
7411        let commit = OutboxCommit {
7412            client_commit_id: uuid::Uuid::new_v4().to_string(),
7413            ops,
7414        };
7415        self.begin_observation("syncular_mutation")?;
7416        let mut batch = ChangeAccumulator::default();
7417        for op in &commit.ops {
7418            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
7419            if let Some(values) = &op.values {
7420                if let Some(table) = self.schema.table(&op.table) {
7421                    for scope in &table.scope_variables {
7422                        if let Some(Value::String(value)) = values.get(&scope.column) {
7423                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
7424                            precise = true;
7425                        }
7426                    }
7427                }
7428            }
7429            if !precise {
7430                batch.table(&op.table);
7431            }
7432        }
7433        if let Err(error) = self.persist_outbox_insert(&commit) {
7434            self.rollback_observation("syncular_mutation");
7435            return Err(error);
7436        }
7437        let was_dirty = self.overlay_dirty.get();
7438        // A clean overlay already contains the FIFO fold of every previous
7439        // commit. Appending a commit only needs its own operations applied.
7440        if !was_dirty {
7441            self.apply_outbox_ops(&commit.ops);
7442        }
7443        let id = commit.client_commit_id.clone();
7444        self.outbox.push(commit);
7445        if was_dirty {
7446            self.rebuild_overlay();
7447        }
7448        batch.status = true;
7449        if let Err(error) = self.finish_observation("syncular_mutation", batch) {
7450            self.rollback_observation("syncular_mutation");
7451            self.outbox.pop();
7452            self.overlay_dirty.set(was_dirty);
7453            return Err(error);
7454        }
7455        Ok(id)
7456    }
7457
7458    /// Record one §6.1 sparse upsert: the primary key plus the supplied
7459    /// non-scope columns are present; every other column is absent and stays
7460    /// untouched on the server and in the local overlay. The row need not be
7461    /// locally present — an absent local row leaves the overlay absent (§7.1)
7462    /// and the server answers per §5.2.
7463    pub fn patch(
7464        &mut self,
7465        table: &str,
7466        row_id: &str,
7467        partial: Map<String, Value>,
7468        base_version: Option<i64>,
7469    ) -> Result<String, String> {
7470        let schema_table = self
7471            .schema
7472            .table(table)
7473            .ok_or_else(|| format!("unknown table {table:?}"))?;
7474        let partial = normalize_values_casing(schema_table, partial)?;
7475        if let Some(pk) = partial.get(&schema_table.primary_key) {
7476            if pk.as_str() != Some(row_id) {
7477                return Err(format!(
7478                    "sync.invalid_request: table {table:?}: patch cannot change the primary key"
7479                ));
7480            }
7481        }
7482        // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
7483        // server accepts a present scope column whose value equals the
7484        // stored row and applies it as a no-op, so a decoded envelope
7485        // round-trips. The local row is the only value the client can prove
7486        // equality against; when the row is absent locally the client cannot
7487        // prove equality against the server's stored row and fails closed.
7488        // A primary key that is also a scope column needs no stored-row
7489        // read: the primary key in a sparse payload is by construction the
7490        // row id being patched (§6.1), so its value is proven equal already.
7491        for scope in &schema_table.scope_variables {
7492            if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
7493                continue;
7494            }
7495            let Some(column) = schema_table
7496                .columns
7497                .iter()
7498                .find(|column| column.name == scope.column)
7499            else {
7500                continue;
7501            };
7502            let stored = self
7503                .conn
7504                .query_row(
7505                    &format!(
7506                        "SELECT {} FROM {} WHERE {}",
7507                        quote_ident(&scope.column),
7508                        visible_table(&schema_table.name),
7509                        row_id_predicate(schema_table)
7510                    ),
7511                    rusqlite::params![row_id],
7512                    |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
7513                )
7514                .ok();
7515            // Decode the supplied value through the same column-type path the
7516            // rest of the patch encoding uses, so a value that only equals
7517            // the stored one in another number representation still compares
7518            // equal.
7519            let incoming = json_to_column_value(column, partial.get(&scope.column))
7520                .ok()
7521                .map(|value| column_value_to_json(&value));
7522            // A stored blob never equals a patch value, matching the server's
7523            // scope-column comparison (§3.4): bytes are unstorable as scope
7524            // values.
7525            let equal = stored.as_ref().is_some_and(|stored| {
7526                stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
7527            });
7528            if !equal {
7529                return Err(format!(
7530                    "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
7531                    scope.column
7532                ));
7533            }
7534        }
7535        let mut values = partial;
7536        // Presence-set semantics: a proven-equal scope column is dropped, so
7537        // the patch leaves the stored value untouched (§6.2).
7538        for scope in &schema_table.scope_variables {
7539            values.remove(&scope.column);
7540        }
7541        values.insert(
7542            schema_table.primary_key.clone(),
7543            Value::from(row_id.to_owned()),
7544        );
7545        // §5.11: structural validation only; an absent key-id selector is
7546        // resolved at the push seam from the stored local row, and a
7547        // key-selection or unknown-key failure becomes a durable
7548        // `client.encrypt_failed` rejection there (§10.3), never an
7549        // author-time error.
7550        self.validate_author_encode(schema_table, row_id, &values)?;
7551        self.record_outbox_commit(vec![OutboxOp {
7552            upsert: true,
7553            table: table.to_owned(),
7554            row_id: row_id.to_owned(),
7555            base_version,
7556            values: Some(values),
7557        }])
7558    }
7559
7560    /// §5.11 key-id fallback: the stored local row's selector value as a
7561    /// positional row (only the selector slot filled). Returns None when no
7562    /// selector is configured, the row is locally absent, or the stored
7563    /// value is not a usable key id.
7564    fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
7565        let selector = self.encryption.key_id_columns.get(&table.name)?;
7566        let index = table
7567            .columns
7568            .iter()
7569            .position(|column| &column.name == selector)?;
7570        let sql = format!(
7571            "SELECT {} FROM {} WHERE {}",
7572            quote_ident(selector),
7573            visible_table(&table.name),
7574            row_id_predicate(table)
7575        );
7576        let key: Option<String> = self
7577            .conn
7578            .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
7579            .ok()?;
7580        let key = key.filter(|key| !key.is_empty())?;
7581        let mut fallback: Row = vec![None; table.columns.len()];
7582        fallback[index] = Some(ColumnValue::String(key));
7583        Some(fallback)
7584    }
7585
7586    /// §5.11/§10.3 author-time encode check: a structural failure (a
7587    /// non-nullable NULL, a value the codec rejects) is an author-time
7588    /// error; a key-selection or unknown-key failure is deferred to the push
7589    /// seam, where it becomes a durable `client.encrypt_failed` rejection.
7590    fn validate_author_encode(
7591        &self,
7592        table: &TableSchema,
7593        row_id: &str,
7594        values: &Map<String, Value>,
7595    ) -> Result<(), String> {
7596        match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
7597            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
7598            result => result.map(|_| ()),
7599        }
7600    }
7601
7602    /// §5.11/§10.3: drop every pending commit whose push encode fails with
7603    /// `client.encrypt_failed` (an unresolvable key id or an unknown key) and
7604    /// raise one durable rejection per commit. `sync()` never aborts for an
7605    /// encode failure. Mirrors §7.4.4's `drop_incompatible_outbox`, including
7606    /// undoing the dropped commit's purely-optimistic rows.
7607    fn drop_unencodable_outbox(&mut self) -> Result<bool, String> {
7608        if !self
7609            .schema
7610            .tables
7611            .iter()
7612            .any(|table| table.has_encrypted_columns())
7613        {
7614            return Ok(false);
7615        }
7616        let failures = self
7617            .outbox
7618            .iter()
7619            .filter_map(|commit| {
7620                commit
7621                    .ops
7622                    .iter()
7623                    .find_map(|op| {
7624                        let values = op.values.as_ref().filter(|_| op.upsert)?;
7625                        let table = self.schema.table(&op.table)?;
7626                        let fallback = self.stored_key_fallback(table, &op.row_id);
7627                        match encode_sparse_row_json(
7628                            table,
7629                            &op.row_id,
7630                            values,
7631                            &self.encryption,
7632                            fallback.as_ref(),
7633                        ) {
7634                            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Some(error),
7635                            _ => None,
7636                        }
7637                    })
7638                    .map(|error| (commit.clone(), error))
7639            })
7640            .collect::<Vec<_>>();
7641        if failures.is_empty() {
7642            return Ok(false);
7643        }
7644        for (commit, message) in failures {
7645            let rejection = RejectionRecord {
7646                client_commit_id: commit.client_commit_id.clone(),
7647                op_index: 0,
7648                code: ENCRYPT_FAILED_CODE.to_owned(),
7649                message,
7650                retryable: false,
7651                details: None,
7652                operation: commit.ops.first().map(CommitOperation::from),
7653            };
7654            self.persist_commit_outcome(
7655                &commit.client_commit_id,
7656                CommitOutcomeStatus::Rejected,
7657                &[CommitOperationOutcome::Error {
7658                    rejection: rejection.clone(),
7659                }],
7660                Some(&commit.ops),
7661            )?;
7662            self.delete_outbox_persisted(&commit.client_commit_id)?;
7663            self.outbox
7664                .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
7665            self.rejections.push(rejection);
7666        }
7667        self.prune_commit_outcomes()?;
7668        self.overlay_dirty.set(true);
7669        self.rebuild_overlay();
7670        Ok(true)
7671    }
7672
7673    pub fn pending_commit_ids(&self) -> Vec<String> {
7674        self.outbox
7675            .iter()
7676            .map(|c| c.client_commit_id.clone())
7677            .collect()
7678    }
7679
7680    /// §6.1: the sparse push payload bytes of every pending upsert operation,
7681    /// FIFO — exactly what the next push round would put on the wire. Used by
7682    /// conformance B.21(h) to pin cross-core byte identity.
7683    pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
7684        let mut payloads = Vec::new();
7685        for commit in &self.outbox {
7686            for op in &commit.ops {
7687                let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
7688                    continue;
7689                };
7690                let Some(table) = self.schema.table(&op.table) else {
7691                    continue;
7692                };
7693                let fallback = self.stored_key_fallback(table, &op.row_id);
7694                if let Ok(payload) = encode_sparse_row_json(
7695                    table,
7696                    &op.row_id,
7697                    values,
7698                    &self.encryption,
7699                    fallback.as_ref(),
7700                ) {
7701                    payloads.push(payload);
7702                }
7703            }
7704        }
7705        payloads
7706    }
7707
7708    pub fn conflicts(&self) -> &[ConflictRecord] {
7709        &self.conflicts
7710    }
7711
7712    pub fn rejections(&self) -> &[RejectionRecord] {
7713        &self.rejections
7714    }
7715
7716    pub fn resolve_commit_outcome(
7717        &mut self,
7718        input: ResolveCommitOutcomeInput,
7719    ) -> Result<CommitOutcome, String> {
7720        let current = self
7721            .commit_outcome(&input.client_commit_id)?
7722            .ok_or_else(|| {
7723                format!(
7724                    "sync.outcome_not_found: no durable outcome exists for {:?}",
7725                    input.client_commit_id
7726                )
7727            })?;
7728        if current.resolution != CommitOutcomeResolution::Active {
7729            return Ok(current);
7730        }
7731        if input.resolution == CommitOutcomeResolution::Active {
7732            return Err("sync.invalid_request: resolution must leave active state".to_owned());
7733        }
7734        match input.resolution {
7735            CommitOutcomeResolution::Superseded => {
7736                let replacement = input
7737                    .replacement_client_commit_id
7738                    .as_deref()
7739                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
7740                    .ok_or_else(|| {
7741                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
7742                            .to_owned()
7743                    })?;
7744                let _ = replacement;
7745            }
7746            _ if input.replacement_client_commit_id.is_some() => {
7747                return Err(
7748                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
7749                        .to_owned(),
7750                );
7751            }
7752            _ => {}
7753        }
7754        let allowed = match current.status {
7755            CommitOutcomeStatus::Conflict => matches!(
7756                input.resolution,
7757                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
7758            ),
7759            CommitOutcomeStatus::Rejected => {
7760                input.resolution == CommitOutcomeResolution::Superseded
7761            }
7762            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
7763                input.resolution == CommitOutcomeResolution::Dismissed
7764            }
7765        };
7766        if !allowed {
7767            return Err(format!(
7768                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
7769                input.resolution, current.status
7770            ));
7771        }
7772
7773        self.begin_observation("syncular_outcome_resolution")?;
7774        let result = (|| {
7775            self.conn
7776                .execute(
7777                    "UPDATE _syncular_commit_outcomes
7778                        SET resolution = ?1, resolved_at_ms = ?2,
7779                            replacement_client_commit_id = ?3
7780                      WHERE client_commit_id = ?4 AND resolution = 'active'",
7781                    rusqlite::params![
7782                        Self::outcome_resolution_name(input.resolution),
7783                        self.clock_now_ms(),
7784                        input.replacement_client_commit_id,
7785                        input.client_commit_id
7786                    ],
7787                )
7788                .map_err(|error| error.to_string())?;
7789            let resolved = self
7790                .commit_outcome(&current.client_commit_id)?
7791                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
7792            self.prune_commit_outcomes()?;
7793            Ok(resolved)
7794        })();
7795        let resolved = match result {
7796            Ok(outcome) => outcome,
7797            Err(error) => {
7798                self.rollback_observation("syncular_outcome_resolution");
7799                return Err(error);
7800            }
7801        };
7802        self.conflicts
7803            .retain(|record| record.client_commit_id != current.client_commit_id);
7804        self.rejections
7805            .retain(|record| record.client_commit_id != current.client_commit_id);
7806        let batch = ChangeAccumulator {
7807            conflicts: current.status == CommitOutcomeStatus::Conflict,
7808            rejections: current.status == CommitOutcomeStatus::Rejected,
7809            outcomes: true,
7810            ..ChangeAccumulator::default()
7811        };
7812        if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
7813            self.rollback_observation("syncular_outcome_resolution");
7814            return Err(error);
7815        }
7816        Ok(resolved)
7817    }
7818
7819    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
7820        self.schema_floor.as_ref()
7821    }
7822
7823    /// §7.3.5: the client's opaque auth-lease state, if any.
7824    pub fn lease_state(&self) -> Option<&LeaseState> {
7825        self.lease_state.as_ref()
7826    }
7827
7828    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
7829    /// the two lease codes set it; other errors leave leaseState untouched.
7830    fn record_lease_error(&mut self, code: &str) {
7831        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
7832            return;
7833        }
7834        let mut next = self.lease_state.clone().unwrap_or_default();
7835        next.error_code = Some(code.to_owned());
7836        self.set_lease_state(Some(next));
7837    }
7838
7839    fn set_lease_state(&mut self, next: Option<LeaseState>) {
7840        if self.lease_state == next {
7841            return;
7842        }
7843        if self.begin_observation("syncular_lease").is_err() {
7844            return;
7845        }
7846        self.lease_state = next;
7847        if let Some(lease) = &self.lease_state {
7848            if let Ok(json) = serde_json::to_string(lease) {
7849                self.set_meta(LEASE_STATE_KEY, &json);
7850            }
7851        } else {
7852            self.delete_meta(LEASE_STATE_KEY);
7853        }
7854        let batch = ChangeAccumulator {
7855            status: true,
7856            ..ChangeAccumulator::default()
7857        };
7858        if self.finish_observation("syncular_lease", batch).is_err() {
7859            self.rollback_observation("syncular_lease");
7860        }
7861    }
7862
7863    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
7864        if self.schema_floor == next {
7865            return;
7866        }
7867        if self.begin_observation("syncular_schema_floor").is_err() {
7868            return;
7869        }
7870        self.schema_floor = next;
7871        self.stopped = self.schema_floor.is_some();
7872        if let Some(floor) = &self.schema_floor {
7873            if let Ok(json) = serde_json::to_string(floor) {
7874                self.set_meta(SCHEMA_FLOOR_KEY, &json);
7875            }
7876        } else {
7877            self.delete_meta(SCHEMA_FLOOR_KEY);
7878        }
7879        let batch = ChangeAccumulator {
7880            status: true,
7881            ..ChangeAccumulator::default()
7882        };
7883        if self
7884            .finish_observation("syncular_schema_floor", batch)
7885            .is_err()
7886        {
7887            self.rollback_observation("syncular_schema_floor");
7888        }
7889    }
7890
7891    fn set_upgrading(&mut self, value: bool) {
7892        if self.upgrading == value {
7893            return;
7894        }
7895        if self.begin_observation("syncular_upgrading").is_err() {
7896            return;
7897        }
7898        self.upgrading = value;
7899        let batch = ChangeAccumulator {
7900            status: true,
7901            ..ChangeAccumulator::default()
7902        };
7903        if self
7904            .finish_observation("syncular_upgrading", batch)
7905            .is_err()
7906        {
7907            self.rollback_observation("syncular_upgrading");
7908        }
7909    }
7910
7911    pub fn sync_needed(&self) -> bool {
7912        self.sync_needed
7913    }
7914
7915    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
7916        let sub = self.subs.iter().find(|s| s.id == id)?;
7917        Some(SubscriptionStateView {
7918            id: sub.id.clone(),
7919            table: sub.table.clone(),
7920            status: sub.state.name().to_owned(),
7921            cursor: sub.cursor,
7922            has_resume_token: sub.bootstrap_state.is_some(),
7923            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
7924            reason_code: sub.reason_code.clone(),
7925        })
7926    }
7927
7928    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
7929        let schema_table = self
7930            .schema
7931            .table(table)
7932            .ok_or_else(|| format!("unknown table {table:?}"))?;
7933        let sql = format!(
7934            "SELECT * FROM {} ORDER BY {} ASC",
7935            visible_table(table),
7936            quote_ident(&schema_table.primary_key)
7937        );
7938        let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?;
7939        let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
7940        let mut out = Vec::new();
7941        while let Some(row) = rows.next().map_err(|e| e.to_string())? {
7942            let mut values = Map::new();
7943            for (i, column) in schema_table.columns.iter().enumerate() {
7944                let value = row.get_ref(i).map_err(|e| e.to_string())?;
7945                values.insert(column.name.clone(), sql_ref_to_json(column, value));
7946            }
7947            let version: i64 = row
7948                .get(schema_table.columns.len())
7949                .map_err(|e| e.to_string())?;
7950            let row_id = match values.get(&schema_table.primary_key) {
7951                Some(Value::String(s)) => s.clone(),
7952                Some(Value::Number(n)) => n.to_string(),
7953                Some(Value::Bool(b)) => b.to_string(),
7954                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
7955            };
7956            out.push(RowState {
7957                row_id,
7958                version,
7959                values,
7960            });
7961        }
7962        Ok(out)
7963    }
7964
7965    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
7966    //
7967    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
7968    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
7969    // the op with `yrs`, re-encodes the whole doc state, and records a
7970    // baseVersion-less sparse upsert carrying only the primary key and the
7971    // crdt column (§5.10.3, §6.6). No local merge — merging is server-side;
7972    // the overlay re-materializes the edit immediately (optimistic apply,
7973    // §7.1) and the server-merged bytes arrive on the next pull, idempotently.
7974    // Byte-compatible with `@syncular/crdt-yjs`.
7975
7976    /// The current stored value of a `crdt` column for one row — the visible
7977    /// (optimistic) bytes, or `None` when the row is absent or the column is
7978    /// NULL (the empty document, §5.10.1). Errors if the column is not a
7979    /// `crdt` column (guards the app against a typo'd column name).
7980    #[cfg(feature = "crdt-yjs")]
7981    fn crdt_column_bytes(
7982        &self,
7983        table: &str,
7984        row_id: &str,
7985        column: &str,
7986    ) -> Result<Option<Vec<u8>>, String> {
7987        let schema_table = self
7988            .schema
7989            .table(table)
7990            .ok_or_else(|| format!("unknown table {table:?}"))?;
7991        let col = schema_table
7992            .columns
7993            .iter()
7994            .find(|c| c.name == column)
7995            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
7996        if col.ty != ColumnType::Crdt {
7997            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
7998        }
7999        let sql = format!(
8000            "SELECT {} FROM {} WHERE {}",
8001            quote_ident(column),
8002            visible_table(table),
8003            row_id_predicate(schema_table)
8004        );
8005        let bytes: Option<Vec<u8>> = self
8006            .conn
8007            .prepare_cached(&sql)
8008            .map_err(|error| error.to_string())?
8009            .query_row(rusqlite::params![row_id], |row| {
8010                row.get::<_, Option<Vec<u8>>>(0)
8011            })
8012            .map_err(|e| match e {
8013                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
8014                other => other.to_string(),
8015            })?;
8016        Ok(bytes)
8017    }
8018
8019    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
8020    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
8021    /// An absent row / NULL column is the empty document (empty string).
8022    #[cfg(feature = "crdt-yjs")]
8023    pub fn crdt_text(
8024        &self,
8025        table: &str,
8026        row_id: &str,
8027        column: &str,
8028        name: &str,
8029    ) -> Result<String, String> {
8030        let bytes = self
8031            .crdt_column_bytes(table, row_id, column)?
8032            .unwrap_or_default();
8033        crate::crdt::text(&bytes, name)
8034    }
8035
8036    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and
8037    /// record the resulting crdt-only sparse operation. Returns the enqueued
8038    /// `clientCommitId`.
8039    #[cfg(feature = "crdt-yjs")]
8040    pub fn crdt_insert_text(
8041        &mut self,
8042        table: &str,
8043        row_id: &str,
8044        column: &str,
8045        name: &str,
8046        index: u32,
8047        value: &str,
8048    ) -> Result<String, String> {
8049        let current = self
8050            .crdt_column_bytes(table, row_id, column)?
8051            .unwrap_or_default();
8052        let update = crate::crdt::insert_text(&current, name, index, value)?;
8053        self.crdt_push_update(table, row_id, column, &update)
8054    }
8055
8056    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and
8057    /// record the resulting crdt-only sparse operation. Returns the enqueued
8058    /// `clientCommitId`.
8059    #[cfg(feature = "crdt-yjs")]
8060    pub fn crdt_delete_text(
8061        &mut self,
8062        table: &str,
8063        row_id: &str,
8064        column: &str,
8065        name: &str,
8066        index: u32,
8067        len: u32,
8068    ) -> Result<String, String> {
8069        let current = self
8070            .crdt_column_bytes(table, row_id, column)?
8071            .unwrap_or_default();
8072        let update = crate::crdt::delete_text(&current, name, index, len)?;
8073        self.crdt_push_update(table, row_id, column, &update)
8074    }
8075
8076    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
8077    /// `crdt` column's current state and record the resulting crdt-only sparse
8078    /// operation. The app authored the update with its own `yrs` model.
8079    /// Returns the enqueued `clientCommitId`.
8080    #[cfg(feature = "crdt-yjs")]
8081    pub fn crdt_apply_update(
8082        &mut self,
8083        table: &str,
8084        row_id: &str,
8085        column: &str,
8086        update: &[u8],
8087    ) -> Result<String, String> {
8088        let current = self
8089            .crdt_column_bytes(table, row_id, column)?
8090            .unwrap_or_default();
8091        let next = crate::crdt::apply_update(&current, update)?;
8092        self.crdt_push_update(table, row_id, column, &next)
8093    }
8094
8095    /// Shared tail of the crdt edit methods: record a §6.1 sparse crdt-only
8096    /// upsert — the primary key plus the crdt column, no `baseVersion` — and
8097    /// enqueue it. The server merges the crdt column and leaves every other
8098    /// column untouched (§5.10.3), so a baseVersion-less crdt edit cannot
8099    /// clobber a concurrent edit to another column. A locally absent row
8100    /// records the same partial operation; the server answers per §6.2.
8101    #[cfg(feature = "crdt-yjs")]
8102    fn crdt_push_update(
8103        &mut self,
8104        table: &str,
8105        row_id: &str,
8106        column: &str,
8107        crdt_bytes: &[u8],
8108    ) -> Result<String, String> {
8109        let mut bytes_obj = Map::new();
8110        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
8111        let mut partial = Map::new();
8112        partial.insert(column.to_owned(), Value::Object(bytes_obj));
8113        self.patch(table, row_id, partial, None)
8114    }
8115
8116    /// Run an arbitrary read-only SQL query against the local database and
8117    /// return each row as a `column-name → JSON value` map. This is the seam
8118    /// the React `useSyncQuery` live-query API needs (it takes app-authored
8119    /// SQL over the visible tables/views, not a fixed table read like
8120    /// [`read_rows`]).
8121    ///
8122    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
8123    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
8124    /// same envelope the command surface uses everywhere else. Output BLOB
8125    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
8126    ///
8127    /// The result column typing is dynamic (SQLite's stored affinity), because
8128    /// arbitrary SQL can alias, join, and compute — there is no schema column
8129    /// to consult per output cell, unlike [`read_rows`].
8130    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
8131        query_connection(&self.conn, sql, params)
8132    }
8133
8134    /// Repository benchmark and conformance access, present only with `bench-internals`.
8135    #[cfg(feature = "bench-internals")]
8136    #[doc(hidden)]
8137    pub fn benchmark_connection(&mut self) -> &mut Connection {
8138        &mut self.conn
8139    }
8140
8141    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
8142    #[cfg(feature = "bench-internals")]
8143    #[doc(hidden)]
8144    pub fn benchmark_phases(
8145        &self,
8146        enabled: Option<bool>,
8147        reset: bool,
8148    ) -> Result<Option<Value>, String> {
8149        self.benchmark_phases.configure(enabled, reset)?;
8150        Ok(self.benchmark_phases.snapshot())
8151    }
8152
8153    /// Rows, coverage, and local revision from one SQLite read snapshot.
8154    pub fn query_snapshot(
8155        &mut self,
8156        sql: &str,
8157        params: &[QueryValue],
8158        coverage: &[WindowCoverage],
8159    ) -> Result<QuerySnapshot, String> {
8160        snapshot_connection(&self.conn, sql, params, coverage)
8161    }
8162
8163    // -- request building ---------------------------------------------------------
8164
8165    fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
8166        #[cfg(feature = "bench-internals")]
8167        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
8168        let log_epoch = self.get_meta(LOG_EPOCH_KEY);
8169        let mut frames = vec![Frame::ReqHeader {
8170            client_id: self.client_id.clone(),
8171            schema_version: self.schema.version,
8172            log_epoch: log_epoch.clone(),
8173        }];
8174        let mut pushed_ids = Vec::new();
8175        let mut ops_in_request = 0usize;
8176        let mut deferred_commits = 0usize;
8177        #[cfg(feature = "bench-internals")]
8178        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
8179        for (index, commit) in self
8180            .outbox
8181            .iter()
8182            .take(if log_epoch.is_some() {
8183                self.outbox.len()
8184            } else {
8185                0
8186            })
8187            .enumerate()
8188        {
8189            // §6.1 splitBatch: stop at the operation cap — commits apply in
8190            // order, so everything from the first non-fitting commit on is
8191            // deferred to the next round. A single over-cap commit still goes
8192            // alone (commits are atomic and cannot be split).
8193            if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
8194                deferred_commits = self.outbox.len() - index;
8195                break;
8196            }
8197            ops_in_request += commit.ops.len();
8198            let operations = commit
8199                .ops
8200                .iter()
8201                .map(|op| {
8202                    let payload = op.values.as_ref().and_then(|values| {
8203                        let table = self.schema.table(&op.table)?;
8204                        // §0: outbox entries encode at send time with the
8205                        // current codec (validated at mutate()). §6.1: the
8206                        // values map's keys are the sparse presence set.
8207                        // §5.11: encrypted columns are encrypted here, with
8208                        // an absent key-id selector read from the stored row.
8209                        // `sync_inner` runs `drop_unencodable_outbox` first,
8210                        // so an encrypt failure cannot reach this encode; the
8211                        // author seam validated every other failure.
8212                        let fallback = self.stored_key_fallback(table, &op.row_id);
8213                        encode_sparse_row_json(
8214                            table,
8215                            &op.row_id,
8216                            values,
8217                            &self.encryption,
8218                            fallback.as_ref(),
8219                        )
8220                        .ok()
8221                    });
8222                    ssp2::model::Operation {
8223                        table: op.table.clone(),
8224                        row_id: op.row_id.clone(),
8225                        op: if op.upsert { Op::Upsert } else { Op::Delete },
8226                        base_version: op.base_version,
8227                        payload,
8228                    }
8229                })
8230                .collect();
8231            frames.push(Frame::PushCommit {
8232                client_commit_id: commit.client_commit_id.clone(),
8233                operations,
8234            });
8235            pushed_ids.push(commit.client_commit_id.clone());
8236        }
8237        #[cfg(feature = "bench-internals")]
8238        drop(outbox_phase);
8239        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
8240        // bare URL — capability negotiation, decided per transport.
8241        let accept = self.limits.accept.unwrap_or(if url_capable {
8242            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
8243        } else {
8244            DEFAULT_ACCEPT
8245        });
8246        frames.push(Frame::PullHeader {
8247            limit_commits: self.limits.limit_commits.unwrap_or(0),
8248            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
8249            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
8250            accept,
8251        });
8252        let mut fresh = Vec::new();
8253        for sub in &self.subs {
8254            if sub.state != SubState::Active {
8255                continue;
8256            }
8257            let mut scopes = sub.requested.clone();
8258            sort_scope_map(&mut scopes);
8259            frames.push(Frame::Subscription {
8260                id: sub.id.clone(),
8261                table: sub.table.clone(),
8262                scopes,
8263                params: sub.params.clone().map(RawJson),
8264                cursor: sub.cursor,
8265                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
8266            });
8267            fresh.push((
8268                sub.id.clone(),
8269                sub.cursor < 0 && sub.bootstrap_state.is_none(),
8270            ));
8271        }
8272        let message = Message {
8273            wire_version: WIRE_VERSION,
8274            msg_kind: MsgKind::Request,
8275            frames,
8276        };
8277        (
8278            message,
8279            RequestMeta {
8280                pushed_ids,
8281                fresh,
8282                accept,
8283                deferred_commits,
8284            },
8285        )
8286    }
8287
8288    // -- sync -------------------------------------------------------------------
8289
8290    /// Cloneable observation handle; no client lock is needed to read it.
8291    pub fn progress(&self) -> ProgressObserver {
8292        self.progress.clone()
8293    }
8294
8295    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
8296        self.progress.start();
8297        let started_at_ms = self.clock_now_ms();
8298        let outcome = self.sync_inner(transport);
8299        self.progress.update(|p| match &outcome {
8300            SyncOutcome::Ok(report) if report.failed.is_empty() => {
8301                p.state = ProgressState::Complete
8302            }
8303            SyncOutcome::Ok(_) => {
8304                p.state = ProgressState::Failed;
8305                p.error_code = Some("sync.scope_revoked".into());
8306            }
8307            SyncOutcome::Failed { error_code, .. } => {
8308                p.state = ProgressState::Failed;
8309                p.error_code = Some(Self::diagnostic_code(error_code));
8310            }
8311        });
8312        let completed_at_ms = self.clock_now_ms();
8313        self.last_round = Some(match &outcome {
8314            SyncOutcome::Ok(report) => DiagnosticLastRound {
8315                status: "succeeded".to_owned(),
8316                started_at_ms,
8317                completed_at_ms,
8318                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
8319                counters: Some(DiagnosticRoundCounters {
8320                    pushed: report.pushed,
8321                    applied: report.applied.len(),
8322                    rejected: report.rejected.len(),
8323                    retryable: report.retryable.len(),
8324                    conflicts: report.conflicts,
8325                    commits_applied: report.commits_applied,
8326                    segment_rows_applied: report.segment_rows_applied,
8327                    bootstrapping: report.bootstrapping.len(),
8328                    resets: report.resets.len(),
8329                    revoked: report.revoked.len(),
8330                    failed: report.failed.len(),
8331                    deferred_commits: report.deferred_commits,
8332                }),
8333                error_code: None,
8334            },
8335            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
8336                status: "failed".to_owned(),
8337                started_at_ms,
8338                completed_at_ms,
8339                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
8340                counters: None,
8341                error_code: Some(Self::diagnostic_code(error_code)),
8342            },
8343        });
8344        outcome
8345    }
8346
8347    fn sync_inner(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
8348        if self.stopped {
8349            // §1.6: the client stopped at the schema floor; syncing is inert
8350            // until an upgrade. The outbox is preserved for replay.
8351            return SyncOutcome::Ok(SyncReport {
8352                schema_floor: self.schema_floor.clone(),
8353                ..SyncReport::default()
8354            });
8355        }
8356        // §8.4: the coalesced sync-needed signal clears when a pull round
8357        // BEGINS, so a wake-up landing mid-round survives it.
8358        self.set_sync_needed(false, false);
8359        // §5.9.7 B4: upload pending blobs before pushing the referencing
8360        // rows, so the server-side existence check (§6.6) passes.
8361        if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
8362            if let Err(TransportError { code, message }) = self.flush_blob_uploads(transport) {
8363                if Self::retryable_transport_code(&code) {
8364                    self.schedule_background_retry();
8365                }
8366                return SyncOutcome::Failed {
8367                    error_code: code,
8368                    message,
8369                };
8370            }
8371        }
8372        if let Err(message) = self.drain_pending_evictions() {
8373            return SyncOutcome::Failed {
8374                error_code: "storage.failed".into(),
8375                message,
8376            };
8377        }
8378        // §5.11/§10.3: an unresolvable key id or an unknown key is a durable
8379        // per-commit rejection, never a reason to abort the round. Drop those
8380        // commits before the request is built.
8381        if let Err(message) = self.drop_unencodable_outbox() {
8382            return SyncOutcome::Failed {
8383                error_code: "storage.failed".into(),
8384                message,
8385            };
8386        }
8387        let (message, meta) = self.build_request(transport.supports_url_fetch());
8388        let request_bytes = {
8389            #[cfg(feature = "bench-internals")]
8390            let _phase = self.benchmark_phases.start(Phase::RequestEncode);
8391            encode_message(&message)
8392        };
8393        // §8.7: rounds ride the socket whenever it is connected (one
8394        // loop, no fallback pair); the transport seam stays bytes-in /
8395        // bytes-out either way. Registration-at-round-end is server-side.
8396        let round = if self.realtime_connected {
8397            transport.realtime_sync(&request_bytes)
8398        } else {
8399            transport.sync(&request_bytes)
8400        };
8401        let response_bytes = match round {
8402            Ok(bytes) => bytes,
8403            Err(TransportError { code, message }) => {
8404                // §7.3.5: a request-level lease code stops-and-surfaces —
8405                // record it in leaseState (no local-data purge, §7.3.4).
8406                self.record_lease_error(&code);
8407                if Self::retryable_transport_code(&code) {
8408                    self.schedule_background_retry();
8409                }
8410                return SyncOutcome::Failed {
8411                    error_code: code,
8412                    message,
8413                };
8414            }
8415        };
8416        #[cfg(feature = "bench-internals")]
8417        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
8418        let response = match decode_message(&response_bytes) {
8419            Ok(message) => message,
8420            Err(error) => {
8421                // §1.2 rule 1 / §1.4 rule 5: truncated or malformed
8422                // responses abort without persisting anything.
8423                return SyncOutcome::Failed {
8424                    error_code: error.code.as_str().to_owned(),
8425                    message: error.detail,
8426                };
8427            }
8428        };
8429        #[cfg(feature = "bench-internals")]
8430        drop(decode_phase);
8431        if response.msg_kind != MsgKind::Response {
8432            return SyncOutcome::Failed {
8433                error_code: "sync.invalid_request".to_owned(),
8434                message: "expected a response message".to_owned(),
8435            };
8436        }
8437        let mut outcome = self.process_response(transport, response, &meta);
8438        if let SyncOutcome::Ok(report) = &mut outcome {
8439            report.deferred_commits = meta.deferred_commits;
8440        }
8441        match &outcome {
8442            SyncOutcome::Ok(_) => self.reset_background_retry(),
8443            SyncOutcome::Failed { error_code, .. }
8444                if Self::retryable_transport_code(error_code) =>
8445            {
8446                self.schedule_background_retry();
8447            }
8448            SyncOutcome::Failed { .. } => {}
8449        }
8450        if meta.deferred_commits > 0 {
8451            // §6.1 splitBatch: commits past the operation cap wait for the
8452            // next round — keep the host's sync signal raised until then.
8453            self.set_sync_needed(true, true);
8454        }
8455        outcome
8456    }
8457
8458    pub fn sync_until_idle(
8459        &mut self,
8460        transport: &mut dyn Transport,
8461        max_rounds: Option<u32>,
8462    ) -> SyncOutcome {
8463        let rounds = max_rounds.unwrap_or(20).max(1);
8464        let mut aggregate = SyncReport::default();
8465        let mut spent = 0;
8466        while spent < rounds {
8467            spent += 1;
8468            let before = self
8469                .subs
8470                .iter()
8471                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
8472                .collect::<Vec<_>>();
8473            match self.sync(transport) {
8474                SyncOutcome::Failed {
8475                    error_code,
8476                    message,
8477                } => {
8478                    return SyncOutcome::Failed {
8479                        error_code,
8480                        message,
8481                    };
8482                }
8483                SyncOutcome::Ok(report) => {
8484                    if max_rounds.is_none()
8485                        && report.segment_rows_applied > 0
8486                        && !report.bootstrapping.is_empty()
8487                        && before
8488                            != self
8489                                .subs
8490                                .iter()
8491                                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
8492                                .collect::<Vec<_>>()
8493                    {
8494                        spent = 0;
8495                    }
8496                    aggregate.pushed += report.pushed;
8497                    aggregate.applied.extend(report.applied.iter().cloned());
8498                    aggregate.rejected.extend(report.rejected.iter().cloned());
8499                    aggregate.retryable.extend(report.retryable.iter().cloned());
8500                    aggregate.conflicts += report.conflicts;
8501                    aggregate.commits_applied += report.commits_applied;
8502                    aggregate.segment_rows_applied += report.segment_rows_applied;
8503                    aggregate.bootstrapping = report.bootstrapping.clone();
8504                    aggregate.resets.extend(report.resets.iter().cloned());
8505                    aggregate.revoked.extend(report.revoked.iter().cloned());
8506                    aggregate.failed.extend(report.failed.iter().cloned());
8507                    aggregate.deferred_commits = report.deferred_commits;
8508                    if report.schema_floor.is_some() {
8509                        aggregate.schema_floor = report.schema_floor.clone();
8510                    }
8511                    // §4.5: pull again whenever the response contained
8512                    // commits or segments; resets re-bootstrap; a pending
8513                    // resume token continues paging (§4.7); a raised
8514                    // sync-needed signal covers §6.1 splitBatch remainders
8515                    // (deferred outbox commits push on the next round).
8516                    let more = !report.bootstrapping.is_empty()
8517                        || report.commits_applied > 0
8518                        || report.segment_rows_applied > 0
8519                        || !report.resets.is_empty()
8520                        || self.sync_needed;
8521                    if !more {
8522                        return SyncOutcome::Ok(aggregate);
8523                    }
8524                }
8525            }
8526        }
8527        SyncOutcome::Failed {
8528            error_code: "sync.invalid_request".into(),
8529            message: "sync did not reach idle within the round budget".into(),
8530        }
8531    }
8532
8533    fn process_response(
8534        &mut self,
8535        transport: &mut dyn Transport,
8536        response: Message,
8537        meta: &RequestMeta,
8538    ) -> SyncOutcome {
8539        #[cfg(feature = "bench-internals")]
8540        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
8541        let mut report = SyncReport {
8542            pushed: meta.pushed_ids.len() as u32,
8543            ..SyncReport::default()
8544        };
8545        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
8546            HashMap::new();
8547        let pushed_ids = meta
8548            .pushed_ids
8549            .iter()
8550            .map(String::as_str)
8551            .collect::<HashSet<_>>();
8552        let mut last_final_push_result_id: Option<String> = None;
8553        for frame in &response.frames {
8554            match frame {
8555                Frame::PushResultDetails {
8556                    client_commit_id,
8557                    entries,
8558                } => {
8559                    let details = rejection_details_by_commit
8560                        .entry(client_commit_id.clone())
8561                        .or_default();
8562                    for entry in entries {
8563                        let parsed = match RejectionDetails::parse(&entry.details.0) {
8564                            Ok(value) => value,
8565                            Err(message) => {
8566                                return SyncOutcome::Failed {
8567                                    error_code: "sync.invalid_request".to_owned(),
8568                                    message,
8569                                };
8570                            }
8571                        };
8572                        details.insert(entry.op_index, parsed);
8573                    }
8574                }
8575                Frame::PushResult {
8576                    client_commit_id,
8577                    status,
8578                    results,
8579                    ..
8580                } if pushed_ids.contains(client_commit_id.as_str())
8581                    && Self::push_result_is_final(*status, results) =>
8582                {
8583                    last_final_push_result_id = Some(client_commit_id.clone());
8584                }
8585                _ => {}
8586            }
8587        }
8588        let mut frames = response.frames.into_iter().peekable();
8589        if response.wire_version < 2 {
8590            return SyncOutcome::Failed {
8591                error_code: "client.invalid_host_response".to_owned(),
8592                message: "server response does not carry wire version 2 log-epoch state".to_owned(),
8593            };
8594        }
8595        match frames.next() {
8596            Some(Frame::RespHeader {
8597                required_schema_version,
8598                latest_schema_version,
8599                log_epoch,
8600                reset_required,
8601            }) => {
8602                let Some(log_epoch) = log_epoch else {
8603                    return SyncOutcome::Failed {
8604                        error_code: "client.invalid_host_response".to_owned(),
8605                        message: "response header omits logEpoch".to_owned(),
8606                    };
8607                };
8608                let Some(reset_required) = reset_required else {
8609                    return SyncOutcome::Failed {
8610                        error_code: "client.invalid_host_response".to_owned(),
8611                        message: "response header omits resetRequired".to_owned(),
8612                    };
8613                };
8614                if let Some(required) = required_schema_version {
8615                    // §1.6 schema-floor response: nothing else is processed;
8616                    // stop syncing and surface the upgrade requirement.
8617                    let floor = SchemaFloor {
8618                        required_schema_version: Some(required),
8619                        latest_schema_version,
8620                    };
8621                    self.set_schema_floor(Some(floor.clone()));
8622                    report.schema_floor = Some(floor);
8623                    return SyncOutcome::Ok(report);
8624                }
8625                if reset_required {
8626                    if frames.next().is_some() {
8627                        return SyncOutcome::Failed {
8628                            error_code: "client.invalid_host_response".to_owned(),
8629                            message: "log-epoch reset response contains body frames".to_owned(),
8630                        };
8631                    }
8632                    match self.run_log_epoch_reset(&log_epoch) {
8633                        Ok(resets) => {
8634                            report.resets = resets;
8635                            return SyncOutcome::Ok(report);
8636                        }
8637                        Err(message) => {
8638                            return SyncOutcome::Failed {
8639                                error_code: "sync.local_corrupt".to_owned(),
8640                                message,
8641                            };
8642                        }
8643                    }
8644                }
8645                if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
8646                    return SyncOutcome::Failed {
8647                        error_code: "client.invalid_host_response".to_owned(),
8648                        message: "server changed logEpoch without requiring a reset".to_owned(),
8649                    };
8650                }
8651            }
8652            _ => {
8653                return SyncOutcome::Failed {
8654                    error_code: "sync.invalid_request".to_owned(),
8655                    message: "response does not start with RESP_HEADER".to_owned(),
8656                };
8657            }
8658        }
8659
8660        let mut failure: Option<(String, String)> = None;
8661        while let Some(frame) = frames.next() {
8662            match frame {
8663                frame @ Frame::PushResult { .. } => {
8664                    let successful = matches!(
8665                        &frame,
8666                        Frame::PushResult {
8667                            status: PushStatus::Applied | PushStatus::Cached,
8668                            ..
8669                        }
8670                    );
8671                    let mut results = vec![frame];
8672                    if successful {
8673                        while matches!(
8674                            frames.peek(),
8675                            Some(Frame::PushResult {
8676                                status: PushStatus::Applied | PushStatus::Cached,
8677                                ..
8678                            })
8679                        ) {
8680                            if let Some(next) = frames.next() {
8681                                results.push(next);
8682                            }
8683                        }
8684                    }
8685                    if self
8686                        .handle_push_results(
8687                            &results,
8688                            &pushed_ids,
8689                            &rejection_details_by_commit,
8690                            &mut report,
8691                            last_final_push_result_id.as_deref(),
8692                        )
8693                        .is_err()
8694                    {
8695                        failure = Some((
8696                            "client.outcome_persistence_failed".to_owned(),
8697                            "local commit outcome could not be persisted".to_owned(),
8698                        ));
8699                        break;
8700                    }
8701                }
8702                Frame::PushResultDetails { .. } => {}
8703                Frame::SubStart {
8704                    id,
8705                    status,
8706                    reason_code,
8707                    effective_scopes,
8708                    bootstrap: _,
8709                } => {
8710                    let mut body = Vec::new();
8711                    let mut sub_end: Option<(i64, Option<String>)> = None;
8712                    for inner in frames.by_ref() {
8713                        match inner {
8714                            Frame::SubEnd {
8715                                next_cursor,
8716                                bootstrap_state,
8717                            } => {
8718                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
8719                                break;
8720                            }
8721                            Frame::Unknown { .. } => {}
8722                            other => body.push(other),
8723                        }
8724                    }
8725                    if let Err(SectionError::Abort(code, message)) = self.process_section(
8726                        transport,
8727                        &id,
8728                        status,
8729                        &reason_code,
8730                        effective_scopes,
8731                        body,
8732                        sub_end,
8733                        meta,
8734                        &mut report,
8735                    ) {
8736                        failure = Some((code, message));
8737                        break;
8738                    }
8739                }
8740                Frame::Lease {
8741                    lease_id,
8742                    expires_at_ms,
8743                } => {
8744                    // §7.3.5: persist the opaque lease; a fresh lease clears
8745                    // any prior lease error (the outage/revocation is over).
8746                    self.set_lease_state(Some(LeaseState {
8747                        lease_id: Some(lease_id),
8748                        expires_at_ms: Some(expires_at_ms),
8749                        error_code: None,
8750                    }));
8751                }
8752                Frame::Error { code, message, .. } => {
8753                    // §1.6: the whole request failed; already-completed
8754                    // subscriptions keep their applied data and cursors.
8755                    failure = Some((code, message));
8756                    break;
8757                }
8758                Frame::Unknown { .. } => {}
8759                _ => {
8760                    failure = Some((
8761                        "sync.invalid_request".to_owned(),
8762                        "unexpected frame in response".to_owned(),
8763                    ));
8764                    break;
8765                }
8766            }
8767        }
8768
8769        // §7.1: reconcile the visible overlay once at the response boundary
8770        // after all outbox acknowledgements and server data applied so a
8771        // batch of PUSH_RESULT frames cannot trigger repeated full-table
8772        // rebuilds. This also covers a round that aborted mid-way.
8773        if self.overlay_dirty.get() {
8774            self.rebuild_overlay();
8775        }
8776        if let Some((error_code, message)) = failure {
8777            return SyncOutcome::Failed {
8778                error_code,
8779                message,
8780            };
8781        }
8782        // §4.8 E1: the push half may have drained commits that pinned rows of
8783        // a shrunk window unit — retry any deferred evictions now.
8784        if let Err(message) = self.drain_pending_evictions() {
8785            return SyncOutcome::Failed {
8786                error_code: "storage.failed".into(),
8787                message,
8788            };
8789        }
8790        self.ack_after_pull(transport);
8791        // §7.4.5: the reset is over once the first post-reset pull round
8792        // leaves no subscription mid-bootstrap — the tables are rebuilt.
8793        if self.upgrading && report.bootstrapping.is_empty() {
8794            self.set_upgrading(false);
8795        }
8796        SyncOutcome::Ok(report)
8797    }
8798
8799    // -- push results (§6.3, §7.2) ------------------------------------------------
8800
8801    fn handle_push_results(
8802        &mut self,
8803        frames: &[Frame],
8804        pushed_ids: &HashSet<&str>,
8805        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
8806        report: &mut SyncReport,
8807        last_final_push_result_id: Option<&str>,
8808    ) -> Result<(), String> {
8809        self.begin_observation("syncular_push_results")?;
8810        let conflict_count = self.conflicts.len();
8811        let rejection_count = self.rejections.len();
8812        let applied_count = report.applied.len();
8813        let rejected_count = report.rejected.len();
8814        let retryable_count = report.retryable.len();
8815        let was_dirty = self.overlay_dirty.get();
8816        let mut removed = Vec::new();
8817        let mut batch = ChangeAccumulator::default();
8818        let persisted = (|| {
8819            for frame in frames {
8820                let Frame::PushResult {
8821                    client_commit_id,
8822                    status,
8823                    results,
8824                    ..
8825                } = frame
8826                else {
8827                    return Err("unexpected acknowledgement frame".to_owned());
8828                };
8829                if !pushed_ids.contains(client_commit_id.as_str()) {
8830                    continue;
8831                }
8832                let Some(index) = self
8833                    .outbox
8834                    .iter()
8835                    .position(|commit| commit.client_commit_id == *client_commit_id)
8836                else {
8837                    continue;
8838                };
8839                let status = *status;
8840                let rejection_details = rejection_details_by_commit.get(client_commit_id);
8841                let operations = self.outbox[index].ops.clone();
8842                match status {
8843                    PushStatus::Applied | PushStatus::Cached => {
8844                        // §7.2: a lost ack replays as `cached` — proceed as if the
8845                        // ack had arrived.
8846                        let journal_results = results
8847                            .iter()
8848                            .map(|result| {
8849                                let op_index = match result {
8850                                    OpResult::Applied { op_index }
8851                                    | OpResult::Conflict { op_index, .. }
8852                                    | OpResult::Error { op_index, .. } => *op_index,
8853                                };
8854                                CommitOperationOutcome::Applied { op_index }
8855                            })
8856                            .collect::<Vec<_>>();
8857                        let outcome_status = if status == PushStatus::Applied {
8858                            CommitOutcomeStatus::Applied
8859                        } else {
8860                            CommitOutcomeStatus::Cached
8861                        };
8862                        let persisted = self
8863                            .persist_commit_outcome(
8864                                client_commit_id,
8865                                outcome_status,
8866                                &journal_results,
8867                                None,
8868                            )
8869                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
8870                        persisted?;
8871                        self.overlay_dirty.set(true);
8872                        batch.status = true;
8873                        batch.outcomes = true;
8874                    }
8875                    PushStatus::Rejected => {
8876                        if results.iter().any(|result| {
8877                            matches!(
8878                                result,
8879                                OpResult::Error {
8880                                    code,
8881                                    retryable: true,
8882                                    ..
8883                                } if code == "sync.idempotency_cache_miss"
8884                            )
8885                        }) {
8886                            // §6.3/§7.2: a serving failure, not an outcome — keep the
8887                            // exact commit queued for an identical retry.
8888                            report.retryable.push(client_commit_id.to_owned());
8889                            continue;
8890                        }
8891
8892                        let mut journal_results = Vec::with_capacity(results.len());
8893                        let mut conflicts = Vec::new();
8894                        let mut rejections = Vec::new();
8895                        for result in results {
8896                            match result {
8897                                OpResult::Applied { op_index } => {
8898                                    journal_results.push(CommitOperationOutcome::Applied {
8899                                        op_index: *op_index,
8900                                    });
8901                                }
8902                                OpResult::Conflict {
8903                                    op_index,
8904                                    code,
8905                                    message,
8906                                    server_version,
8907                                    server_row,
8908                                    conflict_columns: conflict_columns_bitmap,
8909                                } => {
8910                                    let operation = operations
8911                                        .get(*op_index as usize)
8912                                        .map(CommitOperation::from);
8913                                    let (table, row_id) = operation
8914                                        .as_ref()
8915                                        .map(|op| (op.table.clone(), op.row_id.clone()))
8916                                        .unwrap_or_default();
8917                                    let server_row_json = self
8918                                        .schema
8919                                        .table(&table)
8920                                        .and_then(|t| {
8921                                            decode_row_bytes(t, server_row, &self.encryption)
8922                                                .ok()
8923                                                .map(|row| (t, row))
8924                                        })
8925                                        .map(|(t, row)| {
8926                                            let mut map = Map::new();
8927                                            for (i, column) in t.columns.iter().enumerate() {
8928                                                map.insert(
8929                                                    column.name.clone(),
8930                                                    column_value_to_json(
8931                                                        row.get(i).unwrap_or(&None),
8932                                                    ),
8933                                                );
8934                                            }
8935                                            map
8936                                        })
8937                                        .unwrap_or_default();
8938                                    // §6.3: decode the presence-layout bitmap
8939                                    // into the marked column names.
8940                                    let conflict_columns = self
8941                                        .schema
8942                                        .table(&table)
8943                                        .map(|t| {
8944                                            t.columns
8945                                                .iter()
8946                                                .enumerate()
8947                                                .filter(|(index, _)| {
8948                                                    conflict_columns_bitmap
8949                                                        .get(index / 8)
8950                                                        .is_some_and(|byte| {
8951                                                            byte & (1 << (index % 8)) != 0
8952                                                        })
8953                                                })
8954                                                .map(|(_, column)| column.name.clone())
8955                                                .collect::<Vec<_>>()
8956                                        })
8957                                        .unwrap_or_default();
8958                                    let conflict = ConflictRecord {
8959                                        client_commit_id: client_commit_id.to_owned(),
8960                                        op_index: *op_index,
8961                                        table,
8962                                        row_id,
8963                                        code: code.clone(),
8964                                        message: message.clone(),
8965                                        server_version: *server_version,
8966                                        server_row: server_row_json,
8967                                        conflict_columns,
8968                                        operation,
8969                                    };
8970                                    journal_results.push(CommitOperationOutcome::Conflict {
8971                                        conflict: conflict.clone(),
8972                                    });
8973                                    conflicts.push(conflict);
8974                                }
8975                                OpResult::Error {
8976                                    op_index,
8977                                    code,
8978                                    message,
8979                                    retryable,
8980                                } => {
8981                                    let rejection = RejectionRecord {
8982                                        client_commit_id: client_commit_id.to_owned(),
8983                                        op_index: *op_index,
8984                                        code: code.clone(),
8985                                        message: message.clone(),
8986                                        retryable: *retryable,
8987                                        details: rejection_details
8988                                            .and_then(|details| details.get(op_index))
8989                                            .cloned(),
8990                                        operation: operations
8991                                            .get(*op_index as usize)
8992                                            .map(CommitOperation::from),
8993                                    };
8994                                    journal_results.push(CommitOperationOutcome::Error {
8995                                        rejection: rejection.clone(),
8996                                    });
8997                                    rejections.push(rejection);
8998                                }
8999                            }
9000                        }
9001                        let outcome_status = if conflicts.is_empty() {
9002                            CommitOutcomeStatus::Rejected
9003                        } else {
9004                            CommitOutcomeStatus::Conflict
9005                        };
9006                        let persisted = self
9007                            .persist_commit_outcome(
9008                                client_commit_id,
9009                                outcome_status,
9010                                &journal_results,
9011                                Some(&operations),
9012                            )
9013                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
9014                        persisted?;
9015                        batch.conflicts = !conflicts.is_empty();
9016                        batch.rejections = !rejections.is_empty();
9017                        batch.status = true;
9018                        batch.outcomes = true;
9019                        self.conflicts.extend(conflicts);
9020                        self.rejections.extend(rejections);
9021                        self.overlay_dirty.set(true);
9022                    }
9023                }
9024                removed.push((index, self.outbox.remove(index)));
9025                if batch.status {
9026                    for operation in &operations {
9027                        if !self.record_row_scopes(
9028                            &mut batch,
9029                            &operation.table,
9030                            &operation.row_id,
9031                            false,
9032                        ) {
9033                            batch.table(&operation.table);
9034                        }
9035                    }
9036                }
9037                match status {
9038                    PushStatus::Applied | PushStatus::Cached => {
9039                        report.applied.push(client_commit_id.to_owned())
9040                    }
9041                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
9042                }
9043            }
9044            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)) {
9045                self.prune_commit_outcomes()?;
9046            }
9047            self.finish_observation("syncular_push_results", batch)
9048        })();
9049        if let Err(error) = persisted {
9050            self.rollback_observation("syncular_push_results");
9051            for (index, commit) in removed.into_iter().rev() {
9052                self.outbox.insert(index, commit);
9053            }
9054            self.conflicts.truncate(conflict_count);
9055            self.rejections.truncate(rejection_count);
9056            self.overlay_dirty.set(was_dirty);
9057            report.applied.truncate(applied_count);
9058            report.rejected.truncate(rejected_count);
9059            report.retryable.truncate(retryable_count);
9060            return Err(error);
9061        }
9062        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
9063        Ok(())
9064    }
9065
9066    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
9067        status != PushStatus::Rejected
9068            || !results.iter().any(|result| {
9069                matches!(
9070                    result,
9071                    OpResult::Error {
9072                        code,
9073                        retryable: true,
9074                        ..
9075                    } if code == "sync.idempotency_cache_miss"
9076                )
9077            })
9078    }
9079
9080    // -- subscription sections ------------------------------------------------------
9081
9082    #[allow(clippy::too_many_arguments)]
9083    fn process_section(
9084        &mut self,
9085        transport: &mut dyn Transport,
9086        id: &str,
9087        status: SubStatus,
9088        reason_code: &str,
9089        effective_scopes: Vec<(String, Vec<String>)>,
9090        body: Vec<Frame>,
9091        sub_end: Option<(i64, Option<String>)>,
9092        meta: &RequestMeta,
9093        report: &mut SyncReport,
9094    ) -> Result<(), SectionError> {
9095        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
9096            return Ok(()); // unknown echo: ignore
9097        };
9098        if status != SubStatus::Active && sub_end.is_none() {
9099            return Err(SectionError::Abort(
9100                "sync.invalid_request".into(),
9101                "subscription section without SUB_END".into(),
9102            ));
9103        }
9104        match status {
9105            SubStatus::Revoked => {
9106                self.begin_observation("syncular_revocation")
9107                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
9108                let previous = self.subs[sub_index].clone();
9109                let was_dirty = self.overlay_dirty.get();
9110                let previous_outbox = self.outbox.clone();
9111                let rejection_count = self.rejections.len();
9112                let revoked_count = report.revoked.len();
9113                let failed_count = report.failed.len();
9114                let result =
9115                    (|| {
9116                        let mut batch = ChangeAccumulator::default();
9117                        let registered = self.window_unit_by_sub_id(id);
9118                        // §3.3: stop pulling, purge exactly the last effective grant.
9119                        let (table, effective) = {
9120                            let sub = &self.subs[sub_index];
9121                            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
9122                        };
9123                        let purged = self.purge_scope_rows(&table, &effective);
9124                        match purged {
9125                            Ok(()) => {
9126                                self.record_scope_map(&mut batch, &table, &effective);
9127                                let sub = &mut self.subs[sub_index];
9128                                sub.state = SubState::Revoked;
9129                                sub.reason_code = Some(if reason_code.is_empty() {
9130                                    "sync.scope_revoked".to_owned()
9131                                } else {
9132                                    reason_code.to_owned()
9133                                });
9134                                report.revoked.push(id.to_owned());
9135                                let doomed_effective = effective;
9136                                let sub_table = table;
9137                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
9138                                    |message| SectionError::Abort("storage.failed".into(), message),
9139                                )?;
9140                                let dropped = self
9141                                    .drop_doomed_outbox(&sub_table, &doomed_effective)
9142                                    .map_err(|message| {
9143                                        SectionError::Abort("storage.failed".to_owned(), message)
9144                                    })?;
9145                                if dropped {
9146                                    batch.status = true;
9147                                    batch.rejections = true;
9148                                    batch.outcomes = true;
9149                                }
9150                                // §5.9.7 B2: revocation deletes now-unauthorized blob
9151                                // bodies (evicted ≠ revoked).
9152                                self.delete_unreferenced_cached_blobs();
9153                            }
9154                            Err(()) => {
9155                                // §3.3 fail closed: no local mapping — never clear by
9156                                // approximation; fatal configuration error.
9157                                let sub = &mut self.subs[sub_index];
9158                                sub.state = SubState::Failed;
9159                                sub.reason_code = Some("sync.scope_revoked".to_owned());
9160                                report.failed.push(id.to_owned());
9161                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
9162                                    |message| SectionError::Abort("storage.failed".into(), message),
9163                                )?;
9164                            }
9165                        }
9166                        if let Some((base_key, unit)) = registered {
9167                            batch.window(&base_key, &self.subs[sub_index].table, &unit);
9168                        }
9169                        self.rebuild_overlay_if_dirty();
9170                        self.finish_observation("syncular_revocation", batch)
9171                            .map_err(|message| {
9172                                SectionError::Abort("storage.failed".to_owned(), message)
9173                            })?;
9174                        Ok(())
9175                    })();
9176                if result.is_err() {
9177                    self.rollback_observation("syncular_revocation");
9178                    self.subs[sub_index] = previous;
9179                    self.overlay_dirty.set(was_dirty);
9180                    self.outbox = previous_outbox;
9181                    self.rejections.truncate(rejection_count);
9182                    report.revoked.truncate(revoked_count);
9183                    report.failed.truncate(failed_count);
9184                }
9185                result
9186            }
9187            SubStatus::Reset => {
9188                self.begin_observation("syncular_reset")
9189                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
9190                let previous = self.subs[sub_index].clone();
9191                let was_dirty = self.overlay_dirty.get();
9192                let reset_count = report.resets.len();
9193                let result = (|| {
9194                    let mut batch = ChangeAccumulator::default();
9195                    let registered = self.window_unit_by_sub_id(id);
9196                    // §4.6: discard cursor + bootstrap state, keep local rows —
9197                    // reset is a staleness signal, not a purge signal.
9198                    let sub = &mut self.subs[sub_index];
9199                    sub.cursor = -1;
9200                    sub.bootstrap_state = None;
9201                    report.resets.push(id.to_owned());
9202                    self.persist_sub(&self.subs[sub_index].clone())
9203                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9204                    if let Some((base_key, unit)) = registered {
9205                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
9206                    }
9207                    self.finish_observation("syncular_reset", batch)
9208                        .map_err(|message| {
9209                            SectionError::Abort("storage.failed".to_owned(), message)
9210                        })?;
9211                    Ok(())
9212                })();
9213                if result.is_err() {
9214                    self.rollback_observation("syncular_reset");
9215                    self.subs[sub_index] = previous;
9216                    self.overlay_dirty.set(was_dirty);
9217                    report.resets.truncate(reset_count);
9218                }
9219                result
9220            }
9221            SubStatus::Active => {
9222                let fresh = meta
9223                    .fresh
9224                    .iter()
9225                    .find(|(fid, _)| fid == id)
9226                    .map(|(_, fresh)| *fresh)
9227                    .unwrap_or(false);
9228                let previous = self.subs[sub_index].clone();
9229                self.subs[sub_index].effective = Some(effective_scopes);
9230                let outcome = self
9231                    .apply_section_body(transport, sub_index, body, fresh, meta, report)
9232                    .and_then(|()| {
9233                        let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
9234                            SectionError::Abort(
9235                                "sync.invalid_request".into(),
9236                                "subscription section without SUB_END".into(),
9237                            )
9238                        })?;
9239                        self.apply_sub_end(sub_index, cursor, bootstrap_state)
9240                            .map_err(|message| {
9241                                SectionError::Abort("storage.failed".into(), message)
9242                            })
9243                    });
9244                match outcome {
9245                    Ok(()) => {
9246                        if self.subs[sub_index].bootstrap_state.is_some() {
9247                            report.bootstrapping.push(id.to_owned());
9248                        }
9249                        Ok(())
9250                    }
9251                    Err(SectionError::FailClosed) => {
9252                        self.subs[sub_index] = previous.clone();
9253                        self.begin_observation("syncular_section_failure")
9254                            .map_err(|message| {
9255                                SectionError::Abort("storage.failed".into(), message)
9256                            })?;
9257                        let persisted = (|| {
9258                            let mut batch = ChangeAccumulator::default();
9259                            if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
9260                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
9261                            }
9262                            self.subs[sub_index].state = SubState::Failed;
9263                            self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
9264                            self.persist_sub(&self.subs[sub_index])?;
9265                            self.finish_observation("syncular_section_failure", batch)
9266                        })();
9267                        if let Err(message) = persisted {
9268                            self.rollback_observation("syncular_section_failure");
9269                            self.subs[sub_index] = previous;
9270                            return Err(SectionError::Abort("storage.failed".into(), message));
9271                        }
9272                        report.failed.push(id.to_owned());
9273                        Ok(())
9274                    }
9275                    Err(error) => {
9276                        // Frame/block transactions already completed. Preserve their
9277                        // rows and revisions, but leave this subscription cursor unchanged.
9278                        self.subs[sub_index] = previous;
9279                        Err(error)
9280                    }
9281                }
9282            }
9283        }
9284    }
9285
9286    fn apply_sub_end(
9287        &mut self,
9288        sub_index: usize,
9289        cursor: i64,
9290        bootstrap_state: Option<String>,
9291    ) -> Result<(), String> {
9292        #[cfg(feature = "bench-internals")]
9293        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
9294        let previous = self.subs[sub_index].clone();
9295        self.begin_observation("syncular_sub_end")?;
9296        let persisted = (|| {
9297            let mut batch = ChangeAccumulator::default();
9298            let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
9299                && cursor >= 0
9300                && bootstrap_state.is_none();
9301            if completed {
9302                if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
9303                    batch.window(&base_key, &previous.table, &unit);
9304                }
9305            }
9306            let sub = &mut self.subs[sub_index];
9307            sub.cursor = cursor;
9308            sub.bootstrap_state = bootstrap_state;
9309            sub.synced_once = true;
9310            self.persist_sub(&self.subs[sub_index])?;
9311            self.finish_observation("syncular_sub_end", batch)
9312        })();
9313        if persisted.is_err() {
9314            self.rollback_observation("syncular_sub_end");
9315            self.subs[sub_index] = previous;
9316        }
9317        persisted
9318    }
9319
9320    fn apply_section_body(
9321        &mut self,
9322        transport: &mut dyn Transport,
9323        sub_index: usize,
9324        body: Vec<Frame>,
9325        fresh: bool,
9326        meta: &RequestMeta,
9327        report: &mut SyncReport,
9328    ) -> Result<(), SectionError> {
9329        let mut saw_segment = false;
9330        for frame in body {
9331            match frame {
9332                Frame::Commit {
9333                    tables, changes, ..
9334                } => {
9335                    self.apply_commit_frame(&tables, &changes)
9336                        .map_err(|(c, m)| SectionError::Abort(c, m))?;
9337                    report.commits_applied += 1;
9338                }
9339                Frame::SegmentInline { payload } => {
9340                    let segment = decode_rows_segment(&payload)
9341                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
9342                    self.progress.update(|p| {
9343                        p.phase = ProgressPhase::Import;
9344                        p.subscription_id = Some(self.subs[sub_index].id.clone());
9345                        p.table = Some(self.subs[sub_index].table.clone());
9346                        p.segment_id = None;
9347                        p.bytes_received = payload.len() as u64;
9348                        p.bytes_total = Some(payload.len() as u64);
9349                        p.rows_processed = 0;
9350                        p.rows_total =
9351                            Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
9352                    });
9353                    let first = !saw_segment;
9354                    saw_segment = true;
9355                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
9356                    report.segment_rows_applied += applied;
9357                }
9358                Frame::SegmentRef {
9359                    segment_id,
9360                    byte_length,
9361                    media_type,
9362                    table,
9363                    row_count,
9364                    as_of_commit_seq,
9365                    scope_digest,
9366                    row_cursor,
9367                    next_row_cursor,
9368                    url,
9369                    url_expires_at_ms,
9370                    ..
9371                } => {
9372                    // §4.2: reject a descriptor whose mediaType was not
9373                    // advertised — never skip or guess.
9374                    let advertised = match media_type {
9375                        MediaType::Rows => {
9376                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
9377                                || meta.accept & ACCEPT_INLINE_ROWS != 0
9378                        }
9379                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
9380                    };
9381                    if !advertised {
9382                        return Err(SectionError::Abort(
9383                            "sync.invalid_request".to_owned(),
9384                            format!(
9385                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
9386                                media_type.name()
9387                            ),
9388                        ));
9389                    }
9390                    self.progress.update(|p| {
9391                        p.phase = ProgressPhase::Download;
9392                        p.subscription_id = Some(self.subs[sub_index].id.clone());
9393                        p.table = Some(table.clone());
9394                        p.segment_id = Some(segment_id.clone());
9395                        p.bytes_received = 0;
9396                        p.bytes_total = u64::try_from(byte_length).ok();
9397                        p.rows_processed = 0;
9398                        p.rows_total = u64::try_from(row_count).ok();
9399                    });
9400                    let observer = self.progress.clone();
9401                    let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
9402                    let bytes = if let Some(url) = url {
9403                        // §5.4: a url-carrying descriptor MUST be fetched
9404                        // from that URL; failure invalidates the whole
9405                        // descriptor (no fall-through to §5.5 — re-pull
9406                        // recovers, §1.4 rule 5).
9407                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
9408                            return Err(SectionError::Abort(
9409                                "sync.invalid_request".to_owned(),
9410                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
9411                                    .to_owned(),
9412                            ));
9413                        }
9414                        // §5.4: MUST NOT start a fetch at/past expiry.
9415                        if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
9416                            return Err(SectionError::Abort(
9417                                "sync.segment_expired".to_owned(),
9418                                format!(
9419                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
9420                                ),
9421                            ));
9422                        }
9423                        transport
9424                            .fetch_url(&url, &mut on_progress)
9425                            .map_err(|e| SectionError::Abort(e.code, e.message))?
9426                    } else {
9427                        let requested_scopes_json =
9428                            canonical_scope_json(&self.subs[sub_index].requested);
9429                        transport
9430                            .download_segment(
9431                                &SegmentRequest {
9432                                    segment_id: segment_id.clone(),
9433                                    table,
9434                                    requested_scopes_json,
9435                                },
9436                                &mut on_progress,
9437                            )
9438                            .map_err(|e| SectionError::Abort(e.code, e.message))?
9439                    };
9440                    // §5.1: verify the content address before applying.
9441                    on_progress(bytes.len() as u64);
9442                    let digest = Sha256::digest(&bytes);
9443                    let expected = segment_id
9444                        .strip_prefix("sha256:")
9445                        .unwrap_or(segment_id.as_str());
9446                    if bytes_to_hex(&digest) != expected {
9447                        return Err(SectionError::Abort(
9448                            "sync.invalid_request".to_owned(),
9449                            "segment bytes do not match the content address (§5.1)".to_owned(),
9450                        ));
9451                    }
9452                    self.progress.update(|p| p.phase = ProgressPhase::Import);
9453                    if media_type == MediaType::Sqlite {
9454                        // §5.3: images are whole-table — a paged sqlite
9455                        // descriptor is invalid.
9456                        if row_cursor.is_some() || next_row_cursor.is_some() {
9457                            return Err(SectionError::Abort(
9458                                "sync.invalid_request".to_owned(),
9459                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
9460                                    .to_owned(),
9461                            ));
9462                        }
9463                        let first = !saw_segment;
9464                        saw_segment = true;
9465                        let applied = self.apply_sqlite_segment(
9466                            sub_index,
9467                            &bytes,
9468                            fresh && first,
9469                            row_count,
9470                            as_of_commit_seq,
9471                            &scope_digest,
9472                        )?;
9473                        report.segment_rows_applied += applied;
9474                    } else {
9475                        let segment = decode_rows_segment(&bytes).map_err(|e| {
9476                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
9477                        })?;
9478                        let first = row_cursor.is_none();
9479                        saw_segment = true;
9480                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
9481                        report.segment_rows_applied += applied;
9482                    }
9483                }
9484                Frame::Unknown { .. } => {}
9485                Frame::Error { code, message, .. } => {
9486                    return Err(SectionError::Abort(code, message))
9487                }
9488                _ => {
9489                    return Err(SectionError::Abort(
9490                        "sync.invalid_request".to_owned(),
9491                        "unexpected frame inside a subscription section".to_owned(),
9492                    ));
9493                }
9494            }
9495        }
9496        Ok(())
9497    }
9498
9499    // Both pull and realtime frames use the same durable observation boundary.
9500    fn apply_commit_frame(
9501        &mut self,
9502        tables: &[String],
9503        changes: &[ssp2::model::Change],
9504    ) -> Result<(), (String, String)> {
9505        #[cfg(feature = "bench-internals")]
9506        let _phase = self.benchmark_phases.start(Phase::CommitApply);
9507        let was_dirty = self.overlay_dirty.get();
9508        self.begin_observation("syncular_commit")
9509            .map_err(|message| ("storage.failed".into(), message))?;
9510        let applied = (|| {
9511            let mut batch = ChangeAccumulator::default();
9512            self.record_commit_changes(&mut batch, tables, changes);
9513            self.apply_commit_changes(tables, changes)?;
9514            self.rebuild_overlay_if_dirty();
9515            self.finish_observation("syncular_commit", batch)
9516                .map_err(|message| ("storage.failed".into(), message))
9517        })();
9518        if applied.is_err() {
9519            self.rollback_observation("syncular_commit");
9520            self.overlay_dirty.set(was_dirty);
9521        }
9522        applied
9523    }
9524
9525    fn apply_commit_changes(
9526        &mut self,
9527        tables: &[String],
9528        changes: &[ssp2::model::Change],
9529    ) -> Result<(), (String, String)> {
9530        // A clean overlay can reconcile changed primary keys independently
9531        // when their tables have no secondary unique constraints. Such a
9532        // constraint can change whether another row's pending write succeeds,
9533        // so those frames still require the complete FIFO replay below.
9534        let mirror_visible = !self.overlay_dirty.get()
9535            && (self.outbox.is_empty()
9536                || changes.iter().all(|change| {
9537                    tables
9538                        .get(change.table_index as usize)
9539                        .and_then(|name| self.schema.table(name))
9540                        .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
9541                }));
9542        for change in changes {
9543            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
9544                (
9545                    "sync.invalid_request".to_owned(),
9546                    "change tableIndex out of range".to_owned(),
9547                )
9548            })?;
9549            let table = self.schema.table(table_name).ok_or_else(|| {
9550                (
9551                    "sync.schema_mismatch".to_owned(),
9552                    format!("change targets unknown table {table_name:?}"),
9553                )
9554            })?;
9555            match change.op {
9556                Op::Upsert => {
9557                    let payload = change.row.as_ref().ok_or_else(|| {
9558                        (
9559                            "sync.invalid_request".to_owned(),
9560                            "upsert change without row payload".to_owned(),
9561                        )
9562                    })?;
9563                    // §5.11: decrypt encrypted columns on apply.
9564                    let row = {
9565                        #[cfg(feature = "bench-internals")]
9566                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
9567                        decode_row_bytes(table, payload, &self.encryption)
9568                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?
9569                    };
9570                    let version = change.row_version.unwrap_or(0);
9571                    let table_name = table.name.clone();
9572                    self.write_base_row(&table_name, &row, version)
9573                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
9574                    if mirror_visible {
9575                        self.write_row(&visible_table(&table_name), &table_name, &row, version)
9576                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
9577                    }
9578                }
9579                Op::Delete => {
9580                    self.delete_base_row(table_name, &change.row_id)
9581                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
9582                    if mirror_visible {
9583                        let sql = format!(
9584                            "DELETE FROM {} WHERE {}",
9585                            visible_table(table_name),
9586                            row_id_predicate(table)
9587                        );
9588                        self.conn
9589                            .prepare_cached(&sql)
9590                            .and_then(|mut statement| {
9591                                statement.execute(rusqlite::params![change.row_id])
9592                            })
9593                            .map_err(|error| {
9594                                ("sync.invalid_request".to_owned(), error.to_string())
9595                            })?;
9596                    }
9597                }
9598            }
9599        }
9600        if mirror_visible {
9601            if !self.outbox.is_empty() {
9602                let changed_rows: HashSet<(&str, &str)> = changes
9603                    .iter()
9604                    .map(|change| {
9605                        (
9606                            tables[change.table_index as usize].as_str(),
9607                            change.row_id.as_str(),
9608                        )
9609                    })
9610                    .collect();
9611                self.apply_outbox_ops(
9612                    self.outbox
9613                        .iter()
9614                        .flat_map(|commit| &commit.ops)
9615                        .filter(|op| {
9616                            changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
9617                        }),
9618                );
9619            }
9620            self.overlay_dirty.set(false);
9621        }
9622        Ok(())
9623    }
9624
9625    /// §5.6 segment application: validate against the generated schema,
9626    /// clear the grant on a fresh bootstrap's first page (fail closed
9627    /// without a mapping), then replace-or-upsert each row with its
9628    /// segment-carried server version (§5.2).
9629    fn apply_segment(
9630        &mut self,
9631        sub_index: usize,
9632        segment: &RowsSegment,
9633        first_fresh_page: bool,
9634    ) -> Result<u32, SectionError> {
9635        let (sub_table, effective) = {
9636            let sub = &self.subs[sub_index];
9637            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
9638        };
9639        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
9640            SectionError::Abort(
9641                "sync.schema_mismatch".to_owned(),
9642                format!("subscription table {sub_table:?} is not in the client schema"),
9643            )
9644        })?;
9645        // §5.2: the column table validates against the generated schema —
9646        // order, names, types, nullability; mismatch is fatal. §5.11: the
9647        // server sends the WIRE types (bytes for an encrypted column), so
9648        // validate against wire_columns.
9649        let matches = segment.table == table.name
9650            && segment.schema_version == self.schema.version
9651            && segment.columns.len() == table.wire_columns.len()
9652            && segment
9653                .columns
9654                .iter()
9655                .zip(table.wire_columns.iter())
9656                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
9657        if !matches {
9658            return Err(SectionError::Abort(
9659                "sync.schema_mismatch".to_owned(),
9660                "segment column table does not match the generated schema (§5.2)".to_owned(),
9661            ));
9662        }
9663        let mut applied = 0u32;
9664        let empty = Vec::new();
9665        let blocks = if segment.blocks.is_empty() {
9666            std::slice::from_ref(&empty)
9667        } else {
9668            &segment.blocks
9669        };
9670        for (index, block) in blocks.iter().enumerate() {
9671            let clear = first_fresh_page && index == 0;
9672            if !clear && block.is_empty() {
9673                continue;
9674            }
9675            let was_dirty = self.overlay_dirty.get();
9676            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
9677                && self
9678                    .outbox
9679                    .iter()
9680                    .flat_map(|commit| &commit.ops)
9681                    .any(|op| op.table == table.name);
9682            let mirror_visible = !reconcile_pending;
9683            self.begin_observation("syncular_segment_block")
9684                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9685            let outcome = (|| {
9686                self.rebuild_overlay_if_dirty();
9687                let mut changed_keys = Vec::new();
9688                let mut batch = ChangeAccumulator::default();
9689                if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
9690                    batch.table(&table.name);
9691                }
9692                if clear {
9693                    self.purge_scope_rows(&table.name, &effective)
9694                        .map_err(|()| SectionError::FailClosed)?;
9695                }
9696                for (row_index, row) in block.iter().enumerate() {
9697                    let decrypted;
9698                    let values = if table.has_encrypted_columns() {
9699                        let mut values = row.values.clone();
9700                        crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
9701                            .map_err(|message| {
9702                            SectionError::Abort("client.decrypt_failed".into(), message)
9703                        })?;
9704                        decrypted = values;
9705                        &decrypted
9706                    } else {
9707                        &row.values
9708                    };
9709                    self.write_base_row(&table.name, values, row.server_version)
9710                        .map_err(|message| {
9711                            SectionError::Abort("sync.invalid_request".into(), message)
9712                        })?;
9713                    if reconcile_pending {
9714                        changed_keys.push(
9715                            owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
9716                                |message| SectionError::Abort("storage.failed".into(), message),
9717                            )?,
9718                        );
9719                    }
9720                    if mirror_visible {
9721                        self.write_row(
9722                            &visible_table(&table.name),
9723                            &table.name,
9724                            values,
9725                            row.server_version,
9726                        )
9727                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9728                    }
9729                    let processed = u64::from(applied) + row_index as u64 + 1;
9730                    if processed.is_multiple_of(1024) {
9731                        self.progress.update(|p| p.rows_processed = processed);
9732                    }
9733                }
9734                if reconcile_pending {
9735                    self.reconcile_imported_rows(&table, &mut changed_keys)
9736                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9737                }
9738                self.apply_outbox_ops(
9739                    self.outbox
9740                        .iter()
9741                        .flat_map(|commit| &commit.ops)
9742                        .filter(|op| op.table == table.name),
9743                );
9744                self.overlay_dirty.set(false);
9745                self.finish_observation("syncular_segment_block", batch)
9746                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
9747            })();
9748            if let Err(error) = outcome {
9749                self.rollback_observation("syncular_segment_block");
9750                self.overlay_dirty.set(was_dirty);
9751                return Err(error);
9752            }
9753            applied += block.len() as u32;
9754            self.progress
9755                .update(|p| p.rows_processed = u64::from(applied));
9756            std::thread::yield_now();
9757        }
9758        self.progress
9759            .update(|p| p.rows_processed = u64::from(applied));
9760        Ok(applied)
9761    }
9762
9763    /// §5.3 sqlite-image application: validate the in-file metadata
9764    /// against the descriptor, validate column names/order against the
9765    /// generated schema, run the §5.6 first-page clear when fresh, then
9766    /// replace-or-upsert every image row with its `_syncular_version`.
9767    /// Mechanics: the image lands in a temp file read through a second
9768    /// rusqlite connection, equivalent to ATTACH + INSERT…SELECT.
9769    /// Each image chunk commits its writes and revision before readers continue.
9770    fn apply_sqlite_segment(
9771        &mut self,
9772        sub_index: usize,
9773        bytes: &[u8],
9774        first_fresh_page: bool,
9775        row_count: i64,
9776        as_of_commit_seq: i64,
9777        scope_digest: &str,
9778    ) -> Result<u32, SectionError> {
9779        let invalid = |detail: &str| {
9780            SectionError::Abort(
9781                "sync.invalid_request".to_owned(),
9782                format!("sqlite segment rejected: {detail} (§5.3)"),
9783            )
9784        };
9785        let (sub_table, effective) = {
9786            let sub = &self.subs[sub_index];
9787            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
9788        };
9789        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
9790            SectionError::Abort(
9791                "sync.schema_mismatch".to_owned(),
9792                format!("subscription table {sub_table:?} is not in the client schema"),
9793            )
9794        })?;
9795
9796        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
9797        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
9798        let img = match rusqlite::Connection::open_with_flags(
9799            &path,
9800            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
9801        ) {
9802            Ok(conn) => conn,
9803            Err(_) => {
9804                let _ = std::fs::remove_file(&path);
9805                return Err(invalid("bytes do not open as a SQLite database"));
9806            }
9807        };
9808        let outcome = self.apply_sqlite_image(
9809            &img,
9810            &table,
9811            first_fresh_page,
9812            &effective,
9813            row_count,
9814            as_of_commit_seq,
9815            scope_digest,
9816        );
9817        drop(img);
9818        let _ = std::fs::remove_file(&path);
9819        outcome
9820    }
9821
9822    #[allow(clippy::too_many_arguments)]
9823    fn apply_sqlite_image(
9824        &mut self,
9825        img: &rusqlite::Connection,
9826        table: &crate::schema::TableSchema,
9827        first_fresh_page: bool,
9828        effective: &[(String, Vec<String>)],
9829        row_count: i64,
9830        as_of_commit_seq: i64,
9831        scope_digest: &str,
9832    ) -> Result<u32, SectionError> {
9833        let invalid = |detail: String| {
9834            SectionError::Abort(
9835                "sync.invalid_request".to_owned(),
9836                format!("sqlite segment rejected: {detail} (§5.3)"),
9837            )
9838        };
9839
9840        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
9841        //    one row).
9842        type MetaRow = (i64, String, i64, i64, String, i64, i64);
9843        let meta: MetaRow = img
9844            .query_row(
9845                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
9846                        \"scopeDigest\", \"rowCount\",
9847                        (SELECT count(*) FROM _syncular_segment)
9848                 FROM _syncular_segment",
9849                [],
9850                |row| {
9851                    Ok((
9852                        row.get(0)?,
9853                        row.get(1)?,
9854                        row.get(2)?,
9855                        row.get(3)?,
9856                        row.get(4)?,
9857                        row.get(5)?,
9858                        row.get(6)?,
9859                    ))
9860                },
9861            )
9862            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
9863        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
9864        if meta_count != 1 {
9865            return Err(invalid(format!(
9866                "_syncular_segment must contain exactly one row, found {meta_count}"
9867            )));
9868        }
9869        if format != 1 {
9870            return Err(invalid(format!("format {format}")));
9871        }
9872        if meta_table != table.name {
9873            return Err(invalid(format!("image table {meta_table:?}")));
9874        }
9875        if schema_version != i64::from(self.schema.version) {
9876            return Err(invalid(format!("schemaVersion {schema_version}")));
9877        }
9878        if pin != as_of_commit_seq {
9879            return Err(invalid(format!("asOfCommitSeq {pin}")));
9880        }
9881        if digest != scope_digest {
9882            return Err(invalid("scopeDigest mismatch".to_owned()));
9883        }
9884        if meta_rows != row_count {
9885            return Err(invalid(format!("rowCount {meta_rows}")));
9886        }
9887
9888        // 2. Column names and order vs the generated schema (§5.3 rule 3).
9889        let mut names: Vec<String> = Vec::new();
9890        let mut primary_keys: Vec<String> = Vec::new();
9891        {
9892            let mut stmt = img
9893                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
9894                .map_err(|_| invalid("image data table missing".to_owned()))?;
9895            let mut rows = stmt
9896                .query([])
9897                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
9898            while let Some(row) = rows
9899                .next()
9900                .map_err(|_| invalid("image data table unreadable".to_owned()))?
9901            {
9902                if row.get::<_, i64>(5).map_err(|e| invalid(e.to_string()))? > 0 {
9903                    primary_keys.push(row.get(1).map_err(|e| invalid(e.to_string()))?);
9904                }
9905                names.push(
9906                    row.get::<_, String>(1)
9907                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
9908                );
9909            }
9910        }
9911        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
9912        expected.push("_syncular_version");
9913        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
9914            return Err(SectionError::Abort(
9915                "sync.schema_mismatch".to_owned(),
9916                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
9917            ));
9918        }
9919
9920        if primary_keys != vec![table.primary_key.clone()] {
9921            return Err(invalid(
9922                "image primary key does not match generated schema".into(),
9923            ));
9924        }
9925        let actual_count: i64 = img
9926            .query_row(
9927                &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
9928                [],
9929                |row| row.get(0),
9930            )
9931            .map_err(|e| invalid(e.to_string()))?;
9932        if actual_count != row_count {
9933            return Err(invalid("image row count does not match descriptor".into()));
9934        }
9935        let insert = self.insert_row_sql(&base_table(&table.name), table);
9936        let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
9937        let column_list = names
9938            .iter()
9939            .map(|n| quote_ident(n))
9940            .collect::<Vec<_>>()
9941            .join(", ");
9942        let mut stmt = img
9943            .prepare(&format!(
9944                "SELECT {column_list} FROM {} ORDER BY {}",
9945                quote_ident(&table.name),
9946                quote_ident(&table.primary_key)
9947            ))
9948            .map_err(|e| invalid(e.to_string()))?;
9949        let mut rows = stmt.query([]).map_err(|e| invalid(e.to_string()))?;
9950        let mut applied = 0u32;
9951        loop {
9952            let clear = applied == 0 && first_fresh_page;
9953            let was_dirty = self.overlay_dirty.get();
9954            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
9955                && self
9956                    .outbox
9957                    .iter()
9958                    .flat_map(|commit| &commit.ops)
9959                    .any(|op| op.table == table.name);
9960            let mirror_visible = !reconcile_pending;
9961            self.begin_observation("syncular_image_chunk")
9962                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9963            let result = (|| {
9964                self.rebuild_overlay_if_dirty();
9965                let mut changed_keys = Vec::new();
9966                let mut batch = ChangeAccumulator::default();
9967                if clear {
9968                    if self.scoped_rows_exist(&table.name, effective) {
9969                        batch.table(&table.name);
9970                    }
9971                    self.purge_scope_rows(&table.name, effective)
9972                        .map_err(|()| SectionError::FailClosed)?;
9973                }
9974                let mut processed = 0u32;
9975                {
9976                    let mut ins = self
9977                        .conn
9978                        .prepare_cached(&insert)
9979                        .map_err(|e| invalid(e.to_string()))?;
9980                    let mut mirror = if mirror_visible {
9981                        Some(
9982                            self.conn
9983                                .prepare_cached(&visible_insert)
9984                                .map_err(|e| invalid(e.to_string()))?,
9985                        )
9986                    } else {
9987                        None
9988                    };
9989                    for _ in 0..1024 {
9990                        let Some(row) = rows.next().map_err(|e| invalid(e.to_string()))? else {
9991                            break;
9992                        };
9993                        for (i, column) in table.columns.iter().enumerate() {
9994                            let cell = row.get_ref(i).map_err(|e| invalid(e.to_string()))?;
9995                            let param = image_cell_param(column, cell).map_err(&invalid)?;
9996                            ins.raw_bind_parameter(i + 1, &param)
9997                                .map_err(|e| invalid(e.to_string()))?;
9998                            if let Some(mirror) = &mut mirror {
9999                                mirror
10000                                    .raw_bind_parameter(i + 1, &param)
10001                                    .map_err(|e| invalid(e.to_string()))?;
10002                            }
10003                        }
10004                        let version_index = table.columns.len();
10005                        let version: i64 =
10006                            row.get(version_index).map_err(|e| invalid(e.to_string()))?;
10007                        if version < 1 {
10008                            return Err(invalid("image row version must be positive".into()));
10009                        }
10010                        ins.raw_bind_parameter(version_index + 1, version)
10011                            .map_err(|e| invalid(e.to_string()))?;
10012                        ins.raw_execute().map_err(|e| invalid(e.to_string()))?;
10013                        if let Some(mirror) = &mut mirror {
10014                            mirror
10015                                .raw_bind_parameter(version_index + 1, version)
10016                                .map_err(|e| invalid(e.to_string()))?;
10017                            mirror.raw_execute().map_err(|e| invalid(e.to_string()))?;
10018                        }
10019                        if reconcile_pending {
10020                            changed_keys.push(
10021                                row.get::<_, SqlValue>(table.pk_index)
10022                                    .map_err(|error| invalid(error.to_string()))?,
10023                            );
10024                        }
10025                        processed += 1;
10026                    }
10027                }
10028                if processed > 0 {
10029                    batch.table(&table.name);
10030                    self.overlay_dirty.set(true);
10031                }
10032                if reconcile_pending {
10033                    self.reconcile_imported_rows(table, &mut changed_keys)
10034                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10035                }
10036                self.apply_outbox_ops(
10037                    self.outbox
10038                        .iter()
10039                        .flat_map(|commit| &commit.ops)
10040                        .filter(|op| op.table == table.name),
10041                );
10042                self.overlay_dirty.set(false);
10043                self.finish_observation("syncular_image_chunk", batch)
10044                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10045                Ok(processed)
10046            })();
10047            let processed = match result {
10048                Ok(processed) => processed,
10049                Err(error) => {
10050                    self.rollback_observation("syncular_image_chunk");
10051                    self.overlay_dirty.set(was_dirty);
10052                    return Err(error);
10053                }
10054            };
10055            applied += processed;
10056            self.progress
10057                .update(|p| p.rows_processed = u64::from(applied));
10058            std::thread::yield_now();
10059            if i64::from(applied) == actual_count {
10060                break;
10061            }
10062        }
10063        Ok(applied)
10064    }
10065
10066    /// Restore only imported and pending row identities before FIFO replay. All pending
10067    /// identities participate: a server row can free a unique value for a different edit.
10068    /// The caller owns the chunk transaction and publishes its revision after replay.
10069    fn reconcile_imported_rows(
10070        &self,
10071        table: &TableSchema,
10072        keys: &mut Vec<SqlValue>,
10073    ) -> Result<(), String> {
10074        let visible = visible_table(&table.name);
10075        let base = base_table(&table.name);
10076        let pk = quote_ident(&table.primary_key);
10077        let predicate = row_id_predicate(table);
10078        let mut lookup = self.conn.prepare_cached(&format!(
10079            "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
10080        )).map_err(|error| error.to_string())?;
10081        let mut deleted = HashSet::new();
10082        for op in self
10083            .outbox
10084            .iter()
10085            .flat_map(|commit| &commit.ops)
10086            .filter(|op| op.table == table.name)
10087        {
10088            if op.upsert {
10089                if let Some(values) = &op.values {
10090                    let key = json_to_column_value(
10091                        &table.columns[table.pk_index],
10092                        values.get(&table.primary_key),
10093                    )?;
10094                    keys.push(owned_sql_value(RowParam::Cell(&key))?);
10095                }
10096            } else if deleted.insert(&op.row_id) {
10097                keys.extend(
10098                    lookup
10099                        .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
10100                        .map_err(|error| error.to_string())?
10101                        .collect::<Result<Vec<_>, _>>()
10102                        .map_err(|error| error.to_string())?,
10103                );
10104            }
10105        }
10106        // Delete the complete affected set before restoring any base value, so
10107        // old optimistic unique values cannot collide with the new server rows.
10108        for chunk in keys.chunks(400) {
10109            let holes = vec!["?"; chunk.len()].join(",");
10110            self.conn
10111                .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
10112                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10113                .map_err(|error| error.to_string())?;
10114        }
10115        let updates = table
10116            .columns
10117            .iter()
10118            .map(|column| column.name.as_str())
10119            .chain(std::iter::once("_syncular_version"))
10120            .filter(|name| *name != table.primary_key)
10121            .map(|name| {
10122                let name = quote_ident(name);
10123                format!("{name}=excluded.{name}")
10124            })
10125            .collect::<Vec<_>>()
10126            .join(",");
10127        for chunk in keys.chunks(400) {
10128            let holes = vec!["?"; chunk.len()].join(",");
10129            self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
10130                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10131                .map_err(|error| error.to_string())?;
10132        }
10133        Ok(())
10134    }
10135
10136    // -- application-authorized local purge ---------------------------------------
10137
10138    fn compile_local_data_purge(
10139        &self,
10140        input: &LocalDataPurgeInput,
10141    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
10142        let invalid = |message: String| format!("sync.invalid_request: {message}");
10143        if input.purge_id.is_empty()
10144            || input.purge_id.len() > 128
10145            || !is_local_operation_code_like(&input.purge_id)
10146        {
10147            return Err(invalid(
10148                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
10149            ));
10150        }
10151        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
10152            return Err(invalid(format!(
10153                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
10154            )));
10155        }
10156
10157        let mut deduplicated: BTreeMap<
10158            String,
10159            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
10160        > = BTreeMap::new();
10161        for target in &input.targets {
10162            let table = self.schema.table(&target.table).ok_or_else(|| {
10163                invalid(format!(
10164                    "local purge names unknown table {:?}",
10165                    target.table
10166                ))
10167            })?;
10168            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
10169                return Err(invalid(format!(
10170                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
10171                    target.table
10172                )));
10173            }
10174            let mut selectors = Vec::with_capacity(target.selectors.len());
10175            let mut canonical_selectors = BTreeMap::new();
10176            for (column_name, raw_values) in &target.selectors {
10177                let Some((column_index, column)) = table
10178                    .columns
10179                    .iter()
10180                    .enumerate()
10181                    .find(|(_, column)| column.name == *column_name)
10182                else {
10183                    return Err(invalid(format!(
10184                        "local purge target {:?} names unknown column {:?}",
10185                        target.table, column_name
10186                    )));
10187                };
10188                let encrypted = table
10189                    .encrypted_columns
10190                    .iter()
10191                    .any(|candidate| candidate.index == column_index);
10192                if column.ty != ColumnType::String || encrypted {
10193                    return Err(invalid(format!(
10194                        "local purge selector {:?}.{:?} must be a plaintext string column",
10195                        target.table, column_name
10196                    )));
10197                }
10198                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
10199                    return Err(invalid(format!(
10200                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
10201                        target.table, column_name
10202                    )));
10203                }
10204                let mut values = raw_values.clone();
10205                values.sort();
10206                values.dedup();
10207                if values.iter().any(|value| {
10208                    value.is_empty()
10209                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
10210                        || !is_local_operation_code_like(value)
10211                }) {
10212                    return Err(invalid(format!(
10213                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
10214                    )));
10215                }
10216                selectors.push((column_name.clone(), values.clone()));
10217                canonical_selectors.insert(column_name.clone(), values);
10218            }
10219            let canonical = LocalDataPurgeTarget {
10220                table: target.table.clone(),
10221                selectors: canonical_selectors,
10222            };
10223            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
10224            deduplicated.insert(
10225                key,
10226                (
10227                    CompiledLocalDataPurgeTarget {
10228                        table: target.table.clone(),
10229                        selectors,
10230                    },
10231                    canonical,
10232                ),
10233            );
10234        }
10235        let targets = deduplicated
10236            .values()
10237            .map(|(compiled, _)| compiled.clone())
10238            .collect::<Vec<_>>();
10239        let canonical = deduplicated
10240            .values()
10241            .map(|(_, target)| target.clone())
10242            .collect::<Vec<_>>();
10243        let canonical_plan =
10244            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
10245        Ok((targets, canonical_plan))
10246    }
10247
10248    fn local_purge_base_row_ids(
10249        &self,
10250        targets: &[CompiledLocalDataPurgeTarget],
10251    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
10252        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10253        for target in targets {
10254            let table = self
10255                .schema
10256                .table(&target.table)
10257                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
10258            let mut clauses = Vec::with_capacity(target.selectors.len());
10259            let mut params = Vec::new();
10260            for (column, values) in &target.selectors {
10261                clauses.push(format!(
10262                    "{} IN ({})",
10263                    quote_ident(column),
10264                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
10265                ));
10266                params.extend(values.iter().cloned().map(SqlValue::Text));
10267            }
10268            let sql = format!(
10269                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
10270                quote_ident(&table.primary_key),
10271                base_table(&target.table),
10272                clauses.join(" AND ")
10273            );
10274            let mut statement = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
10275            let rows = statement
10276                .query_map(rusqlite::params_from_iter(params), |row| {
10277                    row.get::<_, String>(0)
10278                })
10279                .map_err(|error| error.to_string())?;
10280            let ids = by_table.entry(target.table.clone()).or_default();
10281            for row in rows {
10282                ids.insert(row.map_err(|error| error.to_string())?);
10283            }
10284        }
10285        Ok(by_table)
10286    }
10287
10288    fn local_purge_values_match(
10289        target: &CompiledLocalDataPurgeTarget,
10290        values: &Map<String, Value>,
10291    ) -> bool {
10292        target.selectors.iter().all(|(column, allowed)| {
10293            values
10294                .get(column)
10295                .and_then(Value::as_str)
10296                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
10297        })
10298    }
10299
10300    /// Apply one host-authorized local security purge. The host MUST gate the
10301    /// corresponding subscriptions first; this primitive owns local SQLite
10302    /// cleanup and never grants/revokes server authority by itself.
10303    pub fn purge_local_data(
10304        &mut self,
10305        input: &LocalDataPurgeInput,
10306    ) -> Result<LocalDataPurgeResult, String> {
10307        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
10308        let meta_key = format!("localPurge:{}", input.purge_id);
10309        let applied_plan = self
10310            .conn
10311            .query_row(
10312                "SELECT value FROM _syncular_meta WHERE key = ?1",
10313                rusqlite::params![meta_key],
10314                |row| row.get::<_, String>(0),
10315            )
10316            .optional()
10317            .map_err(|error| error.to_string())?;
10318        if let Some(applied_plan) = applied_plan {
10319            if applied_plan != canonical_plan {
10320                return Err(format!(
10321                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
10322                    input.purge_id
10323                ));
10324            }
10325            return Ok(LocalDataPurgeResult {
10326                already_applied: true,
10327                purged_rows: 0,
10328                dropped_commits: 0,
10329            });
10330        }
10331
10332        let prior_outbox = self.outbox.clone();
10333        let prior_rejection_count = self.rejections.len();
10334        let prior_overlay_dirty = self.overlay_dirty.get();
10335        self.begin_observation("syncular_local_purge")?;
10336        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
10337            let row_ids = self.local_purge_base_row_ids(&targets)?;
10338            let doomed = self
10339                .outbox
10340                .iter()
10341                .filter(|commit| {
10342                    commit.ops.iter().any(|operation| {
10343                        let matching_targets = targets
10344                            .iter()
10345                            .filter(|target| target.table == operation.table)
10346                            .collect::<Vec<_>>();
10347                        if matching_targets.is_empty() {
10348                            return false;
10349                        }
10350                        if row_ids
10351                            .get(&operation.table)
10352                            .is_some_and(|ids| ids.contains(&operation.row_id))
10353                        {
10354                            return true;
10355                        }
10356                        operation.values.as_ref().is_some_and(|values| {
10357                            matching_targets
10358                                .iter()
10359                                .any(|target| Self::local_purge_values_match(target, values))
10360                        })
10361                    })
10362                })
10363                .cloned()
10364                .collect::<Vec<_>>();
10365            let doomed_ids = doomed
10366                .iter()
10367                .map(|commit| commit.client_commit_id.clone())
10368                .collect::<BTreeSet<_>>();
10369            let mut batch = ChangeAccumulator::default();
10370            let mut rejections = Vec::new();
10371            for commit in &doomed {
10372                for operation in &commit.ops {
10373                    batch.table(&operation.table);
10374                }
10375                let results = commit
10376                    .ops
10377                    .iter()
10378                    .enumerate()
10379                    .map(|(op_index, operation)| {
10380                        let rejection = RejectionRecord {
10381                            client_commit_id: commit.client_commit_id.clone(),
10382                            op_index: op_index as i32,
10383                            code: "client.local_data_purged".to_owned(),
10384                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
10385                            retryable: false,
10386                            details: None,
10387                            operation: Some(CommitOperation::from(operation)),
10388                        };
10389                        rejections.push(rejection.clone());
10390                        CommitOperationOutcome::Error { rejection }
10391                    })
10392                    .collect::<Vec<_>>();
10393                self.persist_commit_outcome(
10394                    &commit.client_commit_id,
10395                    CommitOutcomeStatus::Rejected,
10396                    &results,
10397                    Some(&commit.ops),
10398                )?;
10399                self.delete_outbox_persisted(&commit.client_commit_id)?;
10400            }
10401            if !doomed.is_empty() {
10402                self.outbox
10403                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
10404                self.rejections.extend(rejections);
10405                self.prune_commit_outcomes()?;
10406                self.overlay_dirty.set(true);
10407                batch.status = true;
10408                batch.rejections = true;
10409                batch.outcomes = true;
10410            }
10411
10412            let mut purged_rows = 0usize;
10413            for (table, ids) in &row_ids {
10414                if ids.is_empty() {
10415                    continue;
10416                }
10417                batch.table(table);
10418                for row_id in ids {
10419                    self.delete_base_row(table, row_id)?;
10420                    purged_rows += 1;
10421                }
10422            }
10423            self.rebuild_overlay_if_dirty();
10424            self.delete_unreferenced_cached_blobs();
10425            self.conn
10426                .execute(
10427                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
10428                    rusqlite::params![meta_key, canonical_plan],
10429                )
10430                .map_err(|error| error.to_string())?;
10431            Ok((
10432                batch,
10433                LocalDataPurgeResult {
10434                    already_applied: false,
10435                    purged_rows,
10436                    dropped_commits: doomed.len(),
10437                },
10438            ))
10439        })();
10440        let (batch, result) = match applied {
10441            Ok(value) => value,
10442            Err(error) => {
10443                self.rollback_observation("syncular_local_purge");
10444                self.outbox = prior_outbox;
10445                self.rejections.truncate(prior_rejection_count);
10446                self.overlay_dirty.set(prior_overlay_dirty);
10447                return Err(error);
10448            }
10449        };
10450        if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
10451            self.rollback_observation("syncular_local_purge");
10452            self.outbox = prior_outbox;
10453            self.rejections.truncate(prior_rejection_count);
10454            self.overlay_dirty.set(prior_overlay_dirty);
10455            return Err(error);
10456        }
10457        Ok(result)
10458    }
10459
10460    /// Application-authorized recovery of the replicated projection. Unlike a
10461    /// local security purge, this retains the entire outbox, device identity,
10462    /// lease, outcomes, subscription registrations, and protected bookkeeping.
10463    /// The projection reset, subscription rewind, optimistic replay, and
10464    /// idempotency marker share one savepoint for interruption safety.
10465    pub fn rebootstrap_local_data(
10466        &mut self,
10467        input: &LocalDataRebootstrapInput,
10468    ) -> Result<LocalDataRebootstrapResult, String> {
10469        if input.rebootstrap_id.is_empty()
10470            || input.rebootstrap_id.len() > 128
10471            || !is_local_operation_code_like(&input.rebootstrap_id)
10472        {
10473            return Err(
10474                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
10475                    .to_owned(),
10476            );
10477        }
10478        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
10479        if let Some(persisted) = self.get_meta_strict(&meta_key)? {
10480            let (retained_commits, reset_subscriptions) =
10481                decode_local_rebootstrap_receipt(&persisted)?;
10482            return Ok(LocalDataRebootstrapResult {
10483                already_applied: true,
10484                retained_commits,
10485                reset_subscriptions,
10486            });
10487        }
10488        if self.stopped || self.schema_floor.is_some() {
10489            return Err(
10490                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
10491                    .to_owned(),
10492            );
10493        }
10494
10495        let retained_commits = self.outbox.len();
10496        let reset_subscriptions = self.subs.len();
10497        let prior_subs = self.subs.clone();
10498        let prior_upgrading = self.upgrading;
10499        let prior_stopped = self.stopped;
10500        let prior_schema_floor = self.schema_floor.clone();
10501        let prior_overlay_dirty = self.overlay_dirty.get();
10502        let prior_sync_needed = self.sync_needed;
10503        let prior_sync_intents = self.sync_intent_queue.clone();
10504        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
10505
10506        self.begin_observation("syncular_local_rebootstrap")?;
10507        let mut batch = ChangeAccumulator::default();
10508        let applied = (|| -> Result<(), String> {
10509            self.run_schema_reset_observed(&mut batch, false)?;
10510            self.conn
10511                .execute(
10512                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
10513                    rusqlite::params![meta_key, receipt],
10514                )
10515                .map_err(|error| error.to_string())?;
10516            self.sync_needed = true;
10517            self.sync_intent_queue.push_back(SyncIntent::Interactive);
10518            batch.status = true;
10519            Ok(())
10520        })();
10521        if let Err(error) = applied {
10522            self.rollback_observation("syncular_local_rebootstrap");
10523            self.subs = prior_subs;
10524            self.upgrading = prior_upgrading;
10525            self.stopped = prior_stopped;
10526            self.schema_floor = prior_schema_floor;
10527            self.overlay_dirty.set(prior_overlay_dirty);
10528            self.sync_needed = prior_sync_needed;
10529            self.sync_intent_queue = prior_sync_intents;
10530            return Err(error);
10531        }
10532        if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
10533            self.rollback_observation("syncular_local_rebootstrap");
10534            self.subs = prior_subs;
10535            self.upgrading = prior_upgrading;
10536            self.stopped = prior_stopped;
10537            self.schema_floor = prior_schema_floor;
10538            self.overlay_dirty.set(prior_overlay_dirty);
10539            self.sync_needed = prior_sync_needed;
10540            self.sync_intent_queue = prior_sync_intents;
10541            return Err(error);
10542        }
10543
10544        Ok(LocalDataRebootstrapResult {
10545            already_applied: false,
10546            retained_commits,
10547            reset_subscriptions,
10548        })
10549    }
10550
10551    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
10552
10553    /// Delete base rows matching the effective scopes; `Err(())` = no local
10554    /// scope-column mapping for a key (the fail-closed case).
10555    fn purge_scope_rows(
10556        &mut self,
10557        table_name: &str,
10558        effective: &[(String, Vec<String>)],
10559    ) -> Result<(), ()> {
10560        if effective.is_empty() {
10561            return Ok(());
10562        }
10563        let table = self.schema.table(table_name).ok_or(())?.clone();
10564        let mut clauses = Vec::new();
10565        let mut params: Vec<SqlValue> = Vec::new();
10566        for (variable, values) in effective {
10567            let column = table.scope_column(variable).ok_or(())?;
10568            let placeholders: Vec<String> = values
10569                .iter()
10570                .map(|v| {
10571                    params.push(SqlValue::Text(v.clone()));
10572                    "?".to_owned()
10573                })
10574                .collect();
10575            clauses.push(format!(
10576                "{} IN ({})",
10577                quote_ident(column),
10578                placeholders.join(", ")
10579            ));
10580        }
10581        let sql = format!(
10582            "DELETE FROM {} WHERE {}",
10583            base_table(table_name),
10584            clauses.join(" AND ")
10585        );
10586        self.overlay_dirty.set(true);
10587        self.conn
10588            .execute(&sql, rusqlite::params_from_iter(&params))
10589            .map_err(|_| ())?;
10590        self.conn
10591            .execute(
10592                &format!(
10593                    "DELETE FROM {} WHERE {}",
10594                    visible_table(table_name),
10595                    clauses.join(" AND ")
10596                ),
10597                rusqlite::params_from_iter(&params),
10598            )
10599            .map_err(|_| ())?;
10600        Ok(())
10601    }
10602
10603    /// §3.3: drop pending commits whose upserts provably land in the
10604    /// revoked effective scopes — whole-commit, never per-operation.
10605    fn drop_doomed_outbox(
10606        &mut self,
10607        table_name: &str,
10608        effective: &[(String, Vec<String>)],
10609    ) -> Result<bool, String> {
10610        if effective.is_empty() {
10611            return Ok(false);
10612        }
10613        let Some(table) = self.schema.table(table_name).cloned() else {
10614            return Ok(false);
10615        };
10616        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
10617        for (variable, values) in effective {
10618            match table.scope_column(variable) {
10619                Some(column) => mappings.push((column, values)),
10620                None => return Ok(false), // not provable without a mapping
10621            }
10622        }
10623        let doomed: Vec<OutboxCommit> = self
10624            .outbox
10625            .iter()
10626            .filter(|commit| {
10627                commit.ops.iter().any(|op| {
10628                    op.upsert
10629                        && op.table == table_name
10630                        && op.values.as_ref().is_some_and(|values| {
10631                            mappings
10632                                .iter()
10633                                .all(|(column, allowed)| match values.get(*column) {
10634                                    Some(Value::String(s)) => allowed.contains(s),
10635                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
10636                                    _ => false,
10637                                })
10638                        })
10639                })
10640            })
10641            .cloned()
10642            .collect();
10643        if doomed.is_empty() {
10644            return Ok(false);
10645        }
10646        let mut rejections = Vec::new();
10647        for commit in &doomed {
10648            let results = commit
10649                .ops
10650                .iter()
10651                .enumerate()
10652                .map(|(op_index, operation)| {
10653                    let rejection = RejectionRecord {
10654                        client_commit_id: commit.client_commit_id.clone(),
10655                        op_index: op_index as i32,
10656                        code: "sync.scope_revoked".to_owned(),
10657                        message: "the commit was dropped because its effective scope was revoked"
10658                            .to_owned(),
10659                        retryable: false,
10660                        details: None,
10661                        operation: Some(CommitOperation::from(operation)),
10662                    };
10663                    rejections.push(rejection.clone());
10664                    CommitOperationOutcome::Error { rejection }
10665                })
10666                .collect::<Vec<_>>();
10667            self.persist_commit_outcome(
10668                &commit.client_commit_id,
10669                CommitOutcomeStatus::Rejected,
10670                &results,
10671                Some(&commit.ops),
10672            )?;
10673            self.delete_outbox_persisted(&commit.client_commit_id)?;
10674        }
10675        self.prune_commit_outcomes()?;
10676        let doomed_ids = doomed
10677            .iter()
10678            .map(|commit| commit.client_commit_id.as_str())
10679            .collect::<BTreeSet<_>>();
10680        self.outbox
10681            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
10682        self.rejections.extend(rejections);
10683        self.overlay_dirty.set(true);
10684        Ok(true)
10685    }
10686
10687    // -- blobs (§5.9) ----------------------------------------------------------------
10688
10689    /// §5.9.7: hash bytes into the content address, cache them, queue the
10690    /// upload (flushed before the next push, B4). Returns the canonical
10691    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
10692    /// column value.
10693    pub fn upload_blob(
10694        &mut self,
10695        bytes: &[u8],
10696        media_type: Option<String>,
10697        name: Option<String>,
10698    ) -> Result<Value, String> {
10699        let blob_id = blob_id_for(bytes);
10700        let now = self.clock_now_ms();
10701        let tx = self.conn.transaction().map_err(|e| e.to_string())?;
10702        tx.execute(
10703            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
10704             ON CONFLICT(blob_id) DO NOTHING",
10705            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
10706        )
10707        .map_err(|e| e.to_string())?;
10708        tx.execute(
10709            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
10710            rusqlite::params![blob_id, media_type, now],
10711        )
10712        .map_err(|e| e.to_string())?;
10713        tx.commit().map_err(|e| e.to_string())?;
10714        self.enforce_blob_cache_cap();
10715        let mut obj = Map::new();
10716        obj.insert("blobId".to_owned(), Value::from(blob_id));
10717        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
10718        if let Some(mt) = media_type {
10719            obj.insert("mediaType".to_owned(), Value::from(mt));
10720        }
10721        if let Some(n) = name {
10722            obj.insert("name".to_owned(), Value::from(n));
10723        }
10724        Ok(Value::Object(obj))
10725    }
10726
10727    /// §5.9.7: resolve an owned blob body without driver encoding. A
10728    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
10729    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
10730    pub fn fetch_blob_bytes(
10731        &mut self,
10732        transport: &mut dyn Transport,
10733        blob_id_or_ref: &str,
10734    ) -> Result<FetchedBlob, (String, String)> {
10735        let simple = |m: String| ("client.failed".to_owned(), m);
10736        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
10737            blob_id_or_ref.to_owned()
10738        } else {
10739            let value: Value = serde_json::from_str(blob_id_or_ref)
10740                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
10741            value
10742                .get("blobId")
10743                .and_then(Value::as_str)
10744                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
10745                .to_owned()
10746        };
10747        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
10748            return Ok(cached);
10749        }
10750        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
10751        // blob.not_found) verbatim so the harness can assert on it. The
10752        // authorized endpoint serves bytes inline OR (always-issue, presign
10753        // configured) a signed url the client fetches directly — no host auth,
10754        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
10755        #[cfg(feature = "bench-internals")]
10756        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
10757        let bytes = match transport
10758            .blob_download(&blob_id)
10759            .map_err(|e| (e.code, e.message))?
10760        {
10761            BlobDownload::Bytes(bytes) => bytes,
10762            BlobDownload::Url {
10763                url,
10764                url_expires_at_ms,
10765            } => {
10766                // §5.9.5: MUST NOT start a fetch at/past expiry.
10767                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
10768                    return Err((
10769                        "sync.segment_expired".to_owned(),
10770                        format!(
10771                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
10772                        ),
10773                    ));
10774                }
10775                transport
10776                    .fetch_blob_url(&url)
10777                    .map_err(|e| (e.code, e.message))?
10778            }
10779        };
10780        #[cfg(feature = "bench-internals")]
10781        drop(download_phase);
10782        #[cfg(feature = "bench-internals")]
10783        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
10784        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
10785        if blob_id_for(&bytes) != blob_id {
10786            return Err(simple(format!(
10787                "blob content address mismatch for {blob_id}"
10788            )));
10789        }
10790        #[cfg(feature = "bench-internals")]
10791        drop(validate_phase);
10792        #[cfg(feature = "bench-internals")]
10793        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
10794        let now = self.clock_now_ms();
10795        self.conn
10796            .execute(
10797                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
10798                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
10799            )
10800            .map_err(|e| simple(e.to_string()))?;
10801        self.conn
10802            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
10803            .map_err(|e| simple(e.to_string()))?;
10804        self.enforce_blob_cache_cap();
10805        #[cfg(feature = "bench-internals")]
10806        drop(insert_phase);
10807        self.get_cached_blob_bytes(&blob_id)
10808            .map_err(simple)?
10809            .ok_or_else(|| simple("blob cache write failed".to_owned()))
10810    }
10811
10812    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
10813        #[cfg(feature = "bench-internals")]
10814        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
10815        let mut stmt = self
10816            .conn
10817            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
10818            .map_err(|e| e.to_string())?;
10819        let mut rows = stmt
10820            .query(rusqlite::params![blob_id])
10821            .map_err(|e| e.to_string())?;
10822        if let Some(row) = rows.next().map_err(|e| e.to_string())? {
10823            let bytes: Vec<u8> = row.get(0).map_err(|e| e.to_string())?;
10824            let byte_length: i64 = row.get(1).map_err(|e| e.to_string())?;
10825            let media_type: Option<String> = row.get(2).map_err(|e| e.to_string())?;
10826            return Ok(Some(FetchedBlob {
10827                blob_id: blob_id.to_owned(),
10828                byte_length,
10829                bytes,
10830                media_type,
10831            }));
10832        }
10833        Ok(None)
10834    }
10835
10836    /// §5.9.7 B4: upload every queued blob before push.
10837    fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
10838        let pending: Vec<(String, Option<String>)> = {
10839            let mut stmt = self
10840                .conn
10841                .prepare(
10842                    "SELECT blob_id, media_type FROM (
10843                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
10844                      UNION ALL
10845                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
10846                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
10847                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
10848                    ) ORDER BY created_at_ms, blob_id",
10849                )
10850                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
10851            let rows = stmt
10852                .query_map([], |row| {
10853                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
10854                })
10855                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
10856            rows.collect::<Result<_, _>>()
10857                .map_err(|error| match error {
10858                    rusqlite::Error::InvalidColumnType(..)
10859                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
10860                        "sync.local_corrupt",
10861                        "Pending blob upload metadata is invalid",
10862                    ),
10863                    _ => TransportError::new("client.failed", error.to_string()),
10864                })?
10865        };
10866        for (blob_id, media_type) in pending {
10867            let stored: Option<(SqlValue, SqlValue)> = self
10868                .conn
10869                .query_row(
10870                    "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
10871                    rusqlite::params![blob_id],
10872                    |row| Ok((row.get(0)?, row.get(1)?)),
10873                )
10874                .optional()
10875                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
10876            let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
10877                return Err(TransportError::new(
10878                    "sync.local_corrupt",
10879                    "Pending blob upload body is missing or corrupt",
10880                ));
10881            };
10882            if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
10883                return Err(TransportError::new(
10884                    "sync.local_corrupt",
10885                    "Pending blob upload body is missing or corrupt",
10886                ));
10887            }
10888            self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
10889            self.conn
10890                .execute(
10891                    "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
10892                    rusqlite::params![blob_id],
10893                )
10894                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
10895        }
10896        Ok(())
10897    }
10898
10899    /// §5.9.3: upload one blob, preferring the presigned direct-to-storage
10900    /// grant when the transport supports it, else streaming through the direct
10901    /// host-authenticated endpoint (capability, not fallback). A `Url` grant
10902    /// PUTs direct with no host auth; on a grant PUT failure the client streams
10903    /// through the direct endpoint — a *different, host-authenticated
10904    /// capability*, not a fall-through of the grant's authority.
10905    fn upload_one(
10906        &self,
10907        transport: &mut dyn Transport,
10908        blob_id: &str,
10909        bytes: &[u8],
10910        media_type: Option<&str>,
10911    ) -> Result<(), TransportError> {
10912        match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
10913            BlobUploadGrant::Present => return Ok(()), // idempotent §5.9.3
10914            BlobUploadGrant::Url {
10915                url,
10916                url_expires_at_ms,
10917            } => {
10918                let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
10919                if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
10920                    return Ok(());
10921                }
10922                // Failed/expired grant PUT — stream through the direct endpoint.
10923            }
10924            BlobUploadGrant::None => {
10925                // No presign store — stream through the direct endpoint.
10926            }
10927        }
10928        transport.blob_upload(blob_id, bytes, media_type)
10929    }
10930
10931    fn visible_blob_ids_sql(&self) -> String {
10932        let mut selects = Vec::new();
10933        for table in &self.schema.tables {
10934            for column in table
10935                .columns
10936                .iter()
10937                .filter(|column| column.ty == ColumnType::BlobRef)
10938            {
10939                let identifier = quote_ident(&column.name);
10940                selects.push(format!(
10941                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
10942                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
10943                     AND json_type({identifier}, '$.blobId') = 'text'",
10944                    quote_ident(&table.name),
10945                ));
10946            }
10947        }
10948        if selects.is_empty() {
10949            "SELECT NULL AS blob_id WHERE 0".to_owned()
10950        } else {
10951            selects.join(" UNION ")
10952        }
10953    }
10954
10955    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
10956    /// staged uploads pin bodies directly; no derived refcount is stored.
10957    fn enforce_blob_cache_cap(&self) {
10958        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
10959            return;
10960        };
10961        let mut total: i64 = self
10962            .conn
10963            .query_row(
10964                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
10965                [],
10966                |row| row.get(0),
10967            )
10968            .unwrap_or(0);
10969        if total <= max_bytes {
10970            return;
10971        }
10972        let query = format!(
10973            "SELECT blob_id, byte_length FROM _syncular_blobs
10974             WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
10975               AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
10976               AND blob_id NOT IN ({})
10977             ORDER BY created_at_ms ASC, blob_id ASC",
10978            self.visible_blob_ids_sql(),
10979        );
10980        let candidates: Vec<(String, i64)> = {
10981            let Ok(mut stmt) = self.conn.prepare(&query) else {
10982                return;
10983            };
10984            let Ok(rows) = stmt.query_map([], |row| {
10985                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
10986            }) else {
10987                return;
10988            };
10989            rows.filter_map(Result::ok).collect()
10990        };
10991        for (blob_id, byte_length) in candidates {
10992            if total <= max_bytes {
10993                break;
10994            }
10995            let _ = self.conn.execute(
10996                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
10997                rusqlite::params![blob_id],
10998            );
10999            total -= byte_length;
11000        }
11001    }
11002
11003    fn delete_unreferenced_cached_blobs(&mut self) {
11004        #[cfg(feature = "bench-internals")]
11005        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
11006        if !self.schema_has_blobs() {
11007            return;
11008        }
11009        self.rebuild_overlay_if_dirty();
11010        let _ = self.conn.execute(
11011            &format!(
11012                "DELETE FROM _syncular_blobs
11013                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
11014                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
11015                   AND blob_id NOT IN ({})",
11016                self.visible_blob_ids_sql(),
11017            ),
11018            [],
11019        );
11020    }
11021
11022    // -- local row storage ----------------------------------------------------------
11023
11024    /// The cached per-table primary-key upsert SQL for `full_table` (see
11025    /// the `insert_sql` field: built once, reused per row).
11026    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
11027        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
11028            return sql.clone();
11029        }
11030        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
11031        columns.push(quote_ident("_syncular_version"));
11032        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
11033        let primary_key = quote_ident(&table.primary_key);
11034        let updates = columns
11035            .iter()
11036            .filter(|column| **column != primary_key)
11037            .map(|column| format!("{column}=excluded.{column}"))
11038            .collect::<Vec<_>>()
11039            .join(", ");
11040        let sql = format!(
11041            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
11042            columns.join(", "),
11043            placeholders.join(", ")
11044        );
11045        self.insert_sql
11046            .borrow_mut()
11047            .insert(full_table.to_owned(), sql.clone());
11048        sql
11049    }
11050
11051    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
11052        self.overlay_dirty.set(true);
11053        self.write_row(&base_table(table_name), table_name, row, version)
11054    }
11055
11056    fn write_row(
11057        &self,
11058        full_table: &str,
11059        table_name: &str,
11060        row: &Row,
11061        version: i64,
11062    ) -> Result<(), String> {
11063        #[cfg(feature = "bench-internals")]
11064        let _phase = self.benchmark_phases.start(Phase::RowWrite);
11065        let table = self
11066            .schema
11067            .table(table_name)
11068            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
11069        let sql = self.insert_row_sql(full_table, table);
11070        let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
11071        let params = row
11072            .iter()
11073            .map(RowParam::Cell)
11074            .chain(std::iter::once(RowParam::Version(version)));
11075        stmt.execute(rusqlite::params_from_iter(params))
11076            .map_err(|e| e.to_string())?;
11077        Ok(())
11078    }
11079
11080    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
11081        let table = self
11082            .schema
11083            .table(table_name)
11084            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
11085        self.overlay_dirty.set(true);
11086        let sql = format!(
11087            "DELETE FROM {} WHERE {}",
11088            base_table(table_name),
11089            row_id_predicate(table)
11090        );
11091        let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
11092        stmt.execute(rusqlite::params![row_id])
11093            .map_err(|e| e.to_string())?;
11094        Ok(())
11095    }
11096
11097    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
11098    /// the outbox changed since the last rebuild (the no-op sync round).
11099    fn rebuild_overlay_if_dirty(&mut self) {
11100        if self.overlay_dirty.get() {
11101            self.rebuild_overlay();
11102        }
11103    }
11104
11105    /// §7.1: local reads see outbox state applied optimistically — rebuild
11106    /// every visible table as (base server state) + (pending outbox replay
11107    /// on top). Optimistic rows carry version `-1`.
11108    fn rebuild_overlay(&mut self) {
11109        #[cfg(feature = "bench-internals")]
11110        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
11111        #[cfg(test)]
11112        self.overlay_rebuild_count
11113            .set(self.overlay_rebuild_count.get() + 1);
11114        self.exec("SAVEPOINT syncular_overlay");
11115        for table in self.schema.tables.clone() {
11116            for index in &table.fts_indexes {
11117                let _ = self.drop_fts_triggers(index);
11118            }
11119            let visible = visible_table(&table.name);
11120            let base = base_table(&table.name);
11121            self.exec(&format!("DELETE FROM {visible}"));
11122            self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
11123        }
11124        for commit in &self.outbox {
11125            self.apply_outbox_ops(&commit.ops);
11126        }
11127        for table in self.schema.tables.clone() {
11128            for index in &table.fts_indexes {
11129                let _ = self.rebuild_fts_projection(&table, index);
11130                let _ = self.create_fts_triggers(&table, index);
11131            }
11132        }
11133        self.exec("RELEASE syncular_overlay");
11134        self.overlay_dirty.set(false);
11135    }
11136
11137    fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
11138        #[cfg(feature = "bench-internals")]
11139        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
11140        for op in ops {
11141            #[cfg(feature = "bench-internals")]
11142            if let Some(phase) = &mut phase {
11143                phase.unit();
11144            }
11145            let Some(table) = self.schema.table(&op.table) else {
11146                continue;
11147            };
11148            if op.upsert {
11149                let Some(values) = op.values.as_ref() else {
11150                    continue;
11151                };
11152                // §7.1: the overlay applies the operation's PRESENT columns
11153                // over the current local row. A partial operation over an
11154                // absent local row leaves it absent — the server answers it
11155                // with sync.row_deleted or sync.row_missing (§6.2).
11156                let full = table
11157                    .columns
11158                    .iter()
11159                    .all(|column| values.contains_key(&column.name));
11160                let local = self.visible_row(table, &op.row_id);
11161                if local.is_none() && !full {
11162                    continue;
11163                }
11164                let mut row: Row = Vec::with_capacity(table.columns.len());
11165                let mut ok = true;
11166                for (index, column) in table.columns.iter().enumerate() {
11167                    let incoming = match values.get(&column.name) {
11168                        Some(raw) => json_to_column_value(column, Some(raw)),
11169                        None => Ok(local
11170                            .as_ref()
11171                            .and_then(|(values, _)| values.get(index).cloned())
11172                            .flatten()),
11173                    };
11174                    match incoming {
11175                        Ok(value) => row.push(value),
11176                        Err(_) => {
11177                            ok = false;
11178                            break;
11179                        }
11180                    }
11181                }
11182                if ok {
11183                    let version = local.as_ref().map_or(-1, |(_, version)| *version);
11184                    let _ = self.write_row(&visible_table(&table.name), &table.name, &row, version);
11185                }
11186            } else {
11187                let sql = format!(
11188                    "DELETE FROM {} WHERE {}",
11189                    visible_table(&table.name),
11190                    row_id_predicate(table)
11191                );
11192                let _ = self
11193                    .conn
11194                    .prepare_cached(&sql)
11195                    .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]));
11196            }
11197        }
11198    }
11199
11200    /// The visible (optimistic) row's values plus version, or `None` when the
11201    /// row is absent from the overlay.
11202    fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
11203        let sql = format!(
11204            "SELECT * FROM {} WHERE {}",
11205            visible_table(&table.name),
11206            row_id_predicate(table)
11207        );
11208        let mut stmt = self.conn.prepare_cached(&sql).ok()?;
11209        let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
11210        let row = rows.next().ok()??;
11211        let mut values = Vec::with_capacity(table.columns.len());
11212        for (index, column) in table.columns.iter().enumerate() {
11213            let raw = row.get_ref(index).ok()?;
11214            let json = sql_ref_to_json(column, raw);
11215            values.push(json_to_column_value(column, Some(&json)).ok()?);
11216        }
11217        let version: i64 = row.get(table.columns.len()).ok()?;
11218        Some((values, version))
11219    }
11220
11221    fn exec(&self, sql: &str) {
11222        let _ = self.conn.execute_batch(sql);
11223    }
11224
11225    // -- realtime (§8) ---------------------------------------------------------------
11226
11227    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
11228        if self.realtime_connected {
11229            return Ok(());
11230        }
11231        transport
11232            .realtime_connect_for_client(&self.client_id)
11233            .map_err(|e| format!("{}: {}", e.code, e.message))?;
11234        self.realtime_connected = true;
11235        Ok(())
11236    }
11237
11238    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
11239        if !self.realtime_connected {
11240            return;
11241        }
11242        let _ = transport.realtime_close();
11243        self.realtime_connected = false;
11244        self.presence.clear(); // §8.6.1: presence is per-connection
11245    }
11246
11247    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
11248    /// document for `scope_key`. Requires a live socket; the document is
11249    /// ephemeral (lost on disconnect). Authorization is the connection's
11250    /// registration (§8.6.3) — an unheld key is rejected loudly by the
11251    /// server with `presence.forbidden`.
11252    pub fn set_presence(
11253        &mut self,
11254        transport: &mut dyn Transport,
11255        scope_key: &str,
11256        doc: Option<&Value>,
11257    ) -> Result<(), String> {
11258        if !self.realtime_connected {
11259            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
11260        }
11261        let text = encode_presence_publish(scope_key, doc);
11262        transport
11263            .realtime_send(&text)
11264            .map_err(|e| format!("{}: {}", e.code, e.message))
11265    }
11266
11267    /// §8.6: the peers currently present on a scope key (ephemeral).
11268    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
11269        self.presence
11270            .get(scope_key)
11271            .map(|peers| peers.values().cloned().collect())
11272            .unwrap_or_default()
11273    }
11274
11275    /// §8.6 apply an inbound presence fanout to the local map.
11276    fn apply_presence(
11277        &mut self,
11278        scope_key: String,
11279        kind: Option<PresenceKind>,
11280        actor_id: Option<String>,
11281        client_id: Option<String>,
11282        doc: Option<Value>,
11283        error: Option<String>,
11284    ) {
11285        // The publisher-directed error variant is out-of-band; nothing to
11286        // record in the peer map.
11287        if error.is_some() {
11288            return;
11289        }
11290        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
11291            return;
11292        };
11293        let peer_key = format!("{actor_id} {client_id}");
11294        match kind {
11295            PresenceKind::Leave => {
11296                if let Some(peers) = self.presence.get_mut(&scope_key) {
11297                    peers.remove(&peer_key);
11298                    if peers.is_empty() {
11299                        self.presence.remove(&scope_key);
11300                    }
11301                }
11302            }
11303            _ => {
11304                let doc = match doc {
11305                    Some(Value::Object(_)) => doc.unwrap(),
11306                    _ => return,
11307                };
11308                self.presence.entry(scope_key).or_default().insert(
11309                    peer_key,
11310                    PresencePeer {
11311                        actor_id,
11312                        client_id,
11313                        doc,
11314                    },
11315                );
11316            }
11317        }
11318    }
11319
11320    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
11321    pub fn on_realtime_text(&mut self, text: &str) {
11322        match parse_control(text) {
11323            Ok(ControlMessage::Hello { requires_sync, .. }) => {
11324                if requires_sync {
11325                    // §8.1: pull before trusting the socket for continuity.
11326                    self.set_sync_needed(true, true);
11327                }
11328            }
11329            Ok(ControlMessage::Presence {
11330                scope_key,
11331                kind,
11332                actor_id,
11333                client_id,
11334                doc,
11335                error,
11336                ..
11337            }) => {
11338                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
11339            }
11340            Ok(ControlMessage::Wake { .. }) => {
11341                // §8.3: any wake-up means "run a pull soon", never data.
11342                self.set_sync_needed(true, true);
11343            }
11344            _ => {}
11345        }
11346    }
11347
11348    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
11349    /// a pull response per section; an unapplied delta is a wake-up.
11350    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
11351        if self.stopped {
11352            return;
11353        }
11354        #[cfg(feature = "bench-internals")]
11355        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
11356        let message = match decode_message(bytes) {
11357            Ok(m) if m.msg_kind == MsgKind::Response => m,
11358            _ => {
11359                self.set_sync_needed(true, true);
11360                return;
11361            }
11362        };
11363        #[cfg(feature = "bench-internals")]
11364        drop(decode_phase);
11365        let mut frames = message.frames.into_iter();
11366        let mut applied_cursor: Option<i64> = None;
11367        let mut any_covered = false;
11368        let mut dropped = false;
11369        while let Some(frame) = frames.next() {
11370            let Frame::SubStart {
11371                id,
11372                status,
11373                effective_scopes,
11374                ..
11375            } = frame
11376            else {
11377                continue;
11378            };
11379            let mut body = Vec::new();
11380            let mut next_cursor: Option<i64> = None;
11381            for inner in frames.by_ref() {
11382                match inner {
11383                    Frame::SubEnd {
11384                        next_cursor: nc, ..
11385                    } => {
11386                        next_cursor = Some(nc);
11387                        break;
11388                    }
11389                    Frame::Unknown { .. } => {}
11390                    other => body.push(other),
11391                }
11392            }
11393            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
11394                dropped = true;
11395                continue;
11396            };
11397            let sub = &self.subs[sub_index];
11398            // §8.2: only locally active, not mid-bootstrap subscriptions
11399            // apply; skipped sections are repaired by the next pull.
11400            if status != SubStatus::Active
11401                || sub.state != SubState::Active
11402                || sub.bootstrap_state.is_some()
11403                || !sub.synced_once
11404            {
11405                dropped = true;
11406                continue;
11407            }
11408            if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
11409                // Idempotent redelivery of an already-covered window.
11410                any_covered = true;
11411                continue;
11412            }
11413            let previous_effective = self.subs[sub_index].effective.clone();
11414            let previous_cursor = self.subs[sub_index].cursor;
11415            self.subs[sub_index].effective = Some(effective_scopes);
11416            let mut failed = false;
11417            for inner in body {
11418                match inner {
11419                    Frame::Commit {
11420                        tables, changes, ..
11421                    } => {
11422                        if self.apply_commit_frame(&tables, &changes).is_err() {
11423                            failed = true;
11424                            break;
11425                        }
11426                    }
11427                    _ => {
11428                        failed = true;
11429                        break;
11430                    }
11431                }
11432            }
11433            let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
11434                self.apply_sub_end(sub_index, cursor, None)
11435                    .ok()
11436                    .map(|()| cursor)
11437            });
11438            let Some(next_cursor) = committed else {
11439                self.subs[sub_index].effective = previous_effective;
11440                self.subs[sub_index].cursor = previous_cursor;
11441                dropped = true;
11442                continue;
11443            };
11444            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
11445        }
11446        if let Some(cursor) = applied_cursor {
11447            // §8.2 ack point: the highest applied SUB_END.nextCursor.
11448            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
11449            let _ = transport.realtime_send(&ack);
11450        } else if !any_covered || dropped {
11451            // §8.2: a delta not applied at all is treated as a wake-up.
11452            self.set_sync_needed(true, true);
11453        }
11454    }
11455
11456    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
11457    /// cursor across active, non-bootstrapping subscriptions that have
11458    /// synced at least once. No such subscription, no ack.
11459    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
11460        if !self.realtime_connected {
11461            return;
11462        }
11463        let floor = self
11464            .subs
11465            .iter()
11466            .filter(|s| {
11467                s.state == SubState::Active
11468                    && s.bootstrap_state.is_none()
11469                    && s.synced_once
11470                    && s.cursor >= 0
11471            })
11472            .map(|s| s.cursor)
11473            .min();
11474        if let Some(cursor) = floor {
11475            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
11476            let _ = transport.realtime_send(&ack);
11477        }
11478    }
11479}