Skip to main content

syncular_client/
client.rs

1//! The Syncular v2 Rust client core (SPEC.md client-behavior contract):
2//! rusqlite local storage, §3.2/§3.3 effective-scope persistence + purge,
3//! §4 pull/cursor/bootstrap (§4.7 resume, §5.6 segment application), §6
4//! push with outbox order, §7 optimistic apply / rollback / replay-on-top,
5//! §2.3 clientCommitId idempotency, §8 realtime client rules, §10 errors.
6//!
7//! Built from `SPEC.md` and the committed `ssp2` codec alone — no
8//! reference to the v1 Rust tree or the v2 TypeScript client.
9
10use crate::{ProgressObserver, ProgressPhase, ProgressState};
11use std::cell::{Cell, RefCell};
12use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
13
14use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
15use rusqlite::{Connection, OpenFlags, OptionalExtension};
16use serde::{Deserialize, Serialize};
17use serde_json::{Map, Value};
18use sha2::{Digest, Sha256};
19use ssp2::decode::WIRE_VERSION;
20use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
21use ssp2::primitives::RawJson;
22use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
23use ssp2::{
24    decode_message, encode_message, encode_presence_publish, parse_control, ControlMessage,
25    PresenceKind,
26};
27
28use crate::api::{
29    ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
30    ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
31    ClientDiagnosticsStorage, ClientLimits, CommandEffects, CommitOperation,
32    CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery, CommitOutcomeResolution,
33    CommitOutcomeStatus, ConflictRecord, CoverageSnapshot, DiagnosticLastChange,
34    DiagnosticLastRound, DiagnosticQueryFailure, DiagnosticRoundCounters, DiagnosticSubscription,
35    FetchedBlob, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, LocalDataPurgeTarget,
36    LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation, PresencePeer,
37    PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot, QueryValue,
38    RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RetainedCommitRow, RowState,
39    SchemaFloor, SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport, SyncStatusSnapshot,
40    TableChange, WindowBase, WindowChange, WindowCoverage, WindowState, WindowUnitRef,
41    CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
42    MAX_DIAGNOSTIC_QUERY_FAILURES,
43};
44use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
45#[cfg(feature = "bench-internals")]
46use crate::bench::{Phase, Recorder};
47use crate::previous_version::{
48    capture_previous_version_from_replica, first_incompatibility,
49    reconcile_previous_version_at_boot, set_local_schema_descriptor,
50    sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
51    PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
52};
53use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
54use crate::transport::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
55use crate::values::{
56    bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
57    encode_sparse_row_json, full_row_values, json_to_column_value, json_to_scope_map,
58    normalize_values_casing, render_row_id_json, scope_map_to_json, sort_scope_map,
59};
60
61/// §4.2 default: rows and SQLite images, both applied in committed chunks.
62/// Bit 3 (signed URLs, §5.4) is
63/// added per transport capability at request-build time.
64const DEFAULT_ACCEPT: u8 = 0b0111;
65const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
66const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
67const ACCEPT_SQLITE: u8 = 1 << 2;
68const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
69const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
70
71/// §7.4.1 persisted local schema-version marker (`_syncular_meta` key).
72pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
73const LOCAL_REVISION_KEY: &str = "localRevision";
74const CLIENT_ID_KEY: &str = "clientId";
75const LEASE_STATE_KEY: &str = "leaseState";
76const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
77const LOG_EPOCH_KEY: &str = "logEpoch";
78/// Persisted fail-closed quarantine marker: present while a security preflight
79/// is pending and `activateSecurity` has yet to run. Storing it in the replica
80/// keeps the gate with the data it protects, so a rebuilt host handle reopening
81/// the same file returns in preflight even though its in-memory state is fresh.
82const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
83const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
84const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
85/// §7.4.4 client-local code: a pending outbox commit cannot re-encode under
86/// the new schema after a bump. Never a wire code (§10.3).
87const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
88/// §5.11 client-local code: an encode at the push seam resolved no usable key
89/// id or named an unknown key. Never a wire code (§10.3).
90const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
91
92#[derive(Debug, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase", deny_unknown_fields)]
94struct PersistedLocalDataRebootstrapReceipt {
95    version: u8,
96    retained_commits: u64,
97    reset_subscriptions: u64,
98}
99
100fn invalid_local_rebootstrap_receipt() -> String {
101    "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
102}
103
104fn encode_local_rebootstrap_receipt(
105    retained_commits: usize,
106    reset_subscriptions: usize,
107) -> Result<String, String> {
108    let retained_commits =
109        u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
110    let reset_subscriptions =
111        u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
112    if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
113        return Err(invalid_local_rebootstrap_receipt());
114    }
115    serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
116        version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
117        retained_commits,
118        reset_subscriptions,
119    })
120    .map_err(|_| invalid_local_rebootstrap_receipt())
121}
122
123fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
124    // Pre-0.15.36 markers proved application but did not retain the receipt.
125    if value == "v1" {
126        return Ok((0, 0));
127    }
128    let receipt: PersistedLocalDataRebootstrapReceipt =
129        serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
130    if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
131        || receipt.retained_commits > MAX_JS_SAFE_INTEGER
132        || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
133    {
134        return Err(invalid_local_rebootstrap_receipt());
135    }
136    Ok((
137        usize::try_from(receipt.retained_commits)
138            .map_err(|_| invalid_local_rebootstrap_receipt())?,
139        usize::try_from(receipt.reset_subscriptions)
140            .map_err(|_| invalid_local_rebootstrap_receipt())?,
141    ))
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145enum SubState {
146    Active,
147    Revoked,
148    Failed,
149}
150
151#[cfg(test)]
152mod observation_tests {
153    use super::*;
154    use crate::native_transport::HostTransport;
155    use crate::values::encode_row_json;
156    use serde_json::json;
157
158    fn client() -> SyncClient {
159        SyncClient::new(
160            "retry-test".to_owned(),
161            &json!({
162                "version": 1,
163                "tables": [{
164                    "name": "tasks",
165                    "primaryKey": "id",
166                    "columns": [
167                        { "name": "id", "type": "string", "nullable": false },
168                        { "name": "project_id", "type": "string", "nullable": false }
169                    ],
170                    "scopes": [{ "pattern": "project:{project_id}" }]
171                }]
172            }),
173            ClientLimits::default(),
174        )
175        .expect("test client")
176    }
177
178    #[cfg(feature = "e2ee")]
179    #[test]
180    fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
181        let schema = json!({
182            "version": 1,
183            "tables": [{
184                "name": "secrets",
185                "primaryKey": "id",
186                "columns": [
187                    { "name": "id", "type": "string", "nullable": false },
188                    { "name": "encryption_key_id", "type": "string", "nullable": true },
189                    { "name": "note", "type": "bytes", "nullable": true,
190                      "encrypted": true, "declaredType": "string" }
191                ],
192                "scopes": []
193            }]
194        });
195        let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
196            .expect("test client");
197        let mut config = crate::values::EncryptionConfig::default();
198        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
199        config
200            .key_id_columns
201            .insert("secrets".to_owned(), "encryption_key_id".to_owned());
202        client.set_encryption(config);
203        // The row exists, but its key selector is absent. The encrypted edit
204        // authors locally and fails at the push seam.
205        client
206            .write_base_row(
207                "secrets",
208                &vec![Some(ColumnValue::String("ghost".into())), None, None],
209                1,
210            )
211            .unwrap();
212        client.rebuild_overlay();
213        let commit_id = client
214            .patch(
215                "secrets",
216                "ghost",
217                Map::from_iter([("note".to_owned(), json!("x"))]),
218                None,
219            )
220            .expect("patch records without an author-time encrypt failure");
221        assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
222        // ...and the push seam raises a durable local rejection, not a throw.
223        assert!(client.drop_unencodable_outbox().unwrap());
224        assert!(client.pending_commit_ids().is_empty());
225        let rejections = client.rejections();
226        assert_eq!(rejections.len(), 1);
227        assert_eq!(rejections[0].code, "client.encrypt_failed");
228        assert_eq!(rejections[0].client_commit_id, commit_id);
229        assert_eq!(
230            client
231                .commit_outcome(&commit_id)
232                .unwrap()
233                .map(|outcome| outcome.status),
234            Some(CommitOutcomeStatus::Rejected)
235        );
236    }
237
238    #[test]
239    fn patch_with_a_scope_column_matches_the_stored_local_row() {
240        let mut client = client();
241        client.create_synced_tables().unwrap();
242        client
243            .conn
244            .execute(
245                "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
246                [],
247            )
248            .unwrap();
249
250        // §3.4 rule 5: a value equal to the stored local row is a no-op, so
251        // the scope column leaves the presence set.
252        client
253            .patch(
254                "tasks",
255                "t1",
256                Map::from_iter([
257                    ("project_id".to_owned(), json!("p1")),
258                    ("id".to_owned(), json!("t1")),
259                ]),
260                None,
261            )
262            .expect("an equal scope column is a no-op");
263        let values = client.outbox[0].ops[0]
264            .values
265            .clone()
266            .expect("the patch records values");
267        assert_eq!(values.get("id"), Some(&json!("t1")));
268        assert!(!values.contains_key("project_id"));
269
270        // A differing value stays rejected.
271        let differing = client.patch(
272            "tasks",
273            "t1",
274            Map::from_iter([("project_id".to_owned(), json!("p2"))]),
275            None,
276        );
277        assert!(differing
278            .expect_err("a differing scope column is rejected")
279            .contains("patch cannot write scope column"));
280
281        // An absent local row leaves nothing to prove equality against, so
282        // the patch fails closed.
283        let absent = client.patch(
284            "tasks",
285            "ghost",
286            Map::from_iter([("project_id".to_owned(), json!("p1"))]),
287            None,
288        );
289        assert!(absent
290            .expect_err("an absent local row is rejected")
291            .starts_with("sync.row_missing:"));
292        assert_eq!(client.pending_commit_ids().len(), 1);
293    }
294
295    #[test]
296    fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
297        let schema = json!({
298            "version": 1,
299            "tables": [
300                {
301                    "name": "tenants",
302                    "primaryKey": "tenant_id",
303                    "columns": [
304                        { "name": "tenant_id", "type": "string", "nullable": false },
305                        { "name": "body", "type": "string", "nullable": false }
306                    ],
307                    "scopes": [{ "pattern": "tenant:{tenant_id}" }]
308                },
309                {
310                    "name": "buckets",
311                    "primaryKey": "id",
312                    "columns": [
313                        { "name": "id", "type": "string", "nullable": false },
314                        { "name": "bucket", "type": "float", "nullable": false },
315                        { "name": "body", "type": "string", "nullable": false }
316                    ],
317                    "scopes": [{ "pattern": "bucket:{bucket}" }]
318                }
319            ]
320        });
321        let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
322            .expect("test client");
323        client.create_synced_tables().unwrap();
324
325        // A primary key that is also a scope column still needs a local base.
326        client
327            .conn
328            .execute(
329                "INSERT INTO tenants (tenant_id, body, _syncular_version) VALUES ('t1', 'seed', 1)",
330                [],
331            )
332            .unwrap();
333        client
334            .patch(
335                "tenants",
336                "t1",
337                Map::from_iter([
338                    ("tenant_id".to_owned(), json!("t1")),
339                    ("body".to_owned(), json!("created")),
340                ]),
341                None,
342            )
343            .expect("the primary-key scope column is proven equal by construction");
344
345        // A stored REAL scope value coerces from a supplied integer.
346        client
347            .conn
348            .execute(
349                "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
350                [],
351            )
352            .unwrap();
353        client
354            .patch(
355                "buckets",
356                "b1",
357                Map::from_iter([
358                    ("bucket".to_owned(), json!(2)),
359                    ("body".to_owned(), json!("coerced")),
360                ]),
361                None,
362            )
363            .expect("the stored REAL value coerces from a supplied integer");
364        let values = client.outbox[1].ops[0]
365            .values
366            .clone()
367            .expect("the patch records values");
368        assert!(!values.contains_key("bucket"));
369
370        // A differing value stays rejected.
371        let differing = client.patch(
372            "buckets",
373            "b1",
374            Map::from_iter([("bucket".to_owned(), json!(3))]),
375            None,
376        );
377        assert!(differing
378            .expect_err("a differing scope value is rejected")
379            .contains("patch cannot write scope column"));
380    }
381
382    #[cfg(feature = "e2ee")]
383    #[test]
384    fn stored_key_fallback_resolves_an_integer_primary_key() {
385        let schema = json!({
386            "version": 1,
387            "tables": [{
388                "name": "patients",
389                "primaryKey": "id",
390                "columns": [
391                    { "name": "id", "type": "integer", "nullable": false },
392                    { "name": "encryption_key_id", "type": "string", "nullable": true },
393                    { "name": "note", "type": "bytes", "nullable": true,
394                      "encrypted": true, "declaredType": "string" }
395                ],
396                "scopes": []
397            }]
398        });
399        let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
400            .expect("test client");
401        client.create_synced_tables().unwrap();
402        client
403            .conn
404            .execute(
405                "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
406                 VALUES (5, 'k1', NULL, 1)",
407                [],
408            )
409            .unwrap();
410        let mut config = crate::values::EncryptionConfig::default();
411        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
412        config
413            .key_id_columns
414            .insert("patients".to_owned(), "encryption_key_id".to_owned());
415        client.set_encryption(config);
416        let table = client.schema.table("patients").unwrap();
417        let fallback = client
418            .stored_key_fallback(table, "5")
419            .expect("the stored integer key resolves the fallback");
420        assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
421    }
422
423    #[test]
424    fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
425        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
426            {"name":"id","type":"string","nullable":false},
427            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
428        for state in ["absent", "cached", "pinned"] {
429            let path = std::env::temp_dir().join(format!(
430                "syncular-blob-stage-{}.sqlite",
431                uuid::Uuid::new_v4()
432            ));
433            let mut client = SyncClient::open_path(
434                "stage-test".into(),
435                &schema,
436                Default::default(),
437                path.to_str().unwrap(),
438            )
439            .unwrap();
440            let bytes = b"atomic stage";
441            if state != "absent" {
442                client
443                    .upload_blob(bytes, Some("text/plain".into()), None)
444                    .unwrap();
445                if state == "cached" {
446                    client
447                        .conn
448                        .execute("DELETE FROM _syncular_blob_uploads", [])
449                        .unwrap();
450                }
451            }
452            let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
453            client
454                .conn
455                .execute_batch(
456                    "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
457                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
458                 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
459                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
460                )
461                .unwrap();
462            let error = client
463                .upload_blob(bytes, Some("application/octet-stream".into()), None)
464                .unwrap_err();
465            assert!(error.contains("injected stage failure"), "{state}: {error}");
466            assert!(client.conn.is_autocommit());
467            let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
468            drop(client);
469            let mut reopened = SyncClient::open_path(
470                "stage-test".into(),
471                &schema,
472                Default::default(),
473                path.to_str().unwrap(),
474            )
475            .unwrap();
476            let durable = reopened
477                .query("SELECT * FROM _syncular_blobs", &[])
478                .unwrap();
479            reopened
480                .conn
481                .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
482                .unwrap();
483            let retried = reopened.upload_blob(bytes, None, None).unwrap();
484            assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
485            assert_eq!(
486                reopened
487                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
488                    .unwrap(),
489                vec![Map::from_iter([(
490                    "blob_id".into(),
491                    Value::from(blob_id_for(bytes)),
492                )])]
493            );
494            drop(reopened);
495            std::fs::remove_file(&path).unwrap();
496            assert_eq!(durable, bodies, "{state}: reopened body state");
497            assert_eq!(immediate, bodies, "{state}: immediate body state");
498        }
499    }
500
501    #[test]
502    fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
503        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
504            {"name":"id","type":"string","nullable":false},
505            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
506        for fault in [
507            "missing",
508            "body-type",
509            "length",
510            "hash",
511            "pin-metadata",
512            "read",
513            "pin-delete",
514        ] {
515            let path = std::env::temp_dir().join(format!(
516                "syncular-blob-upload-{}.sqlite",
517                uuid::Uuid::new_v4()
518            ));
519            let mut client = SyncClient::open_path(
520                "upload-test".into(),
521                &schema,
522                Default::default(),
523                path.to_str().unwrap(),
524            )
525            .unwrap();
526            let bytes = b"durable upload";
527            let ref_value = client
528                .upload_blob(bytes, Some("text/plain".into()), None)
529                .unwrap();
530            let commit = client
531                .mutate(vec![Mutation::Upsert {
532                    table: "attachments".into(),
533                    values: Map::from_iter([
534                        ("id".into(), Value::from("pending")),
535                        (
536                            "file".into(),
537                            Value::from(format!(
538                                "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
539                                blob_id_for(bytes),
540                                bytes.len()
541                            )),
542                        ),
543                    ]),
544                    base_version: None,
545                }])
546                .unwrap();
547            client
548                .conn
549                .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
550                .unwrap();
551            client.conn.execute_batch(match fault {
552                "missing" => "DELETE FROM _syncular_blobs",
553                "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
554                "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
555                "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
556                "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
557                "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
558                _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
559            }).unwrap();
560            let state = if fault == "read" {
561                Vec::new()
562            } else {
563                client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
564            };
565            let mut transport = CountingRealtimeTransport::default();
566            let error = client.flush_blob_uploads(&mut transport).unwrap_err();
567            assert_eq!(
568                error.code,
569                if matches!(fault, "read" | "pin-delete") {
570                    "client.failed"
571                } else {
572                    "sync.local_corrupt"
573                },
574                "{fault}"
575            );
576            assert_eq!(
577                transport.blob_uploads.len(),
578                usize::from(fault == "pin-delete"),
579                "{fault}: corrupt bytes must never be uploaded"
580            );
581            assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
582            if fault != "read" {
583                assert_eq!(
584                    client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
585                    state
586                );
587            } else {
588                client
589                    .conn
590                    .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
591                    .unwrap();
592            }
593            drop(client);
594            let mut reopened = SyncClient::open_path(
595                "upload-test".into(),
596                &schema,
597                Default::default(),
598                path.to_str().unwrap(),
599            )
600            .unwrap();
601            assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
602            if fault != "read" {
603                assert_eq!(
604                    reopened
605                        .query("SELECT * FROM _syncular_blobs", &[])
606                        .unwrap(),
607                    state
608                );
609            }
610            if fault == "pin-delete" {
611                reopened
612                    .conn
613                    .execute_batch("DROP TRIGGER fail_pin_delete")
614                    .unwrap();
615            }
616            if fault == "pin-metadata" {
617                reopened
618                    .conn
619                    .execute(
620                        "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
621                        [],
622                    )
623                    .unwrap();
624            }
625            reopened
626                .conn
627                .execute_batch(
628                    "DELETE FROM _syncular_blobs;
629                     INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
630                )
631                .unwrap();
632            transport.blob_uploads.clear();
633            reopened.flush_blob_uploads(&mut transport).unwrap();
634            assert_eq!(
635                transport.blob_uploads,
636                [(blob_id_for(bytes), bytes.to_vec())]
637            );
638            assert_eq!(
639                reopened
640                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
641                    .unwrap(),
642                Vec::<Map<String, Value>>::new()
643            );
644            assert_eq!(reopened.pending_commit_ids(), [commit]);
645            assert_eq!(
646                reopened
647                    .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
648                    .unwrap()
649                    .unwrap()
650                    .bytes,
651                bytes
652            );
653            drop(reopened);
654            std::fs::remove_file(path).unwrap();
655        }
656    }
657
658    #[test]
659    fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
660        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
661            {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
662            "scopes":[{"pattern":"project:{project_id}"}]}]});
663        for realtime in [false, true] {
664            for fault in [
665                "none",
666                "row",
667                "revision",
668                "commit",
669                "cursor",
670                "error",
671                "missing-end",
672            ] {
673                if realtime && fault == "missing-end" {
674                    continue;
675                }
676                let path = std::env::temp_dir()
677                    .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
678                let mut client = SyncClient::open_path(
679                    "frame-test".into(),
680                    &schema,
681                    Default::default(),
682                    path.to_str().unwrap(),
683                )
684                .unwrap();
685                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
686                client
687                    .subscribe(
688                        "tasks".into(),
689                        "tasks".into(),
690                        vec![("project_id".into(), vec!["p1".into()])],
691                        None,
692                    )
693                    .unwrap();
694                client.subs[0].cursor = 0;
695                client.subs[0].synced_once = true;
696                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
697                client.persist_sub(&client.subs[0]).unwrap();
698                client.realtime_state = RealtimeState::Connected;
699                client.realtime_reason_code = None;
700                client.drain_change_batches();
701                let mut transport = CountingRealtimeTransport::default();
702                let (_, meta) = client.build_request(false);
703                let mut response = Message {
704                    wire_version: WIRE_VERSION,
705                    msg_kind: MsgKind::Response,
706                    frames: vec![
707                        Frame::RespHeader {
708                            required_schema_version: None,
709                            latest_schema_version: None,
710                            log_epoch: Some("epoch-1".into()),
711                            reset_required: Some(false),
712                        },
713                        Frame::SubStart {
714                            id: "tasks".into(),
715                            status: SubStatus::Active,
716                            reason_code: "".into(),
717                            effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
718                            bootstrap: false,
719                        },
720                    ],
721                };
722                for (index, id) in ["first", "second"].iter().enumerate() {
723                    let values = Map::from_iter([
724                        ("id".into(), Value::from(*id)),
725                        ("project_id".into(), Value::from("p1")),
726                    ]);
727                    response.frames.push(Frame::Commit {
728                        commit_seq: index as i64 + 1,
729                        actor_id: "writer".into(),
730                        created_at_ms: 1,
731                        tables: vec!["tasks".into()],
732                        changes: vec![ssp2::model::Change {
733                            table_index: 0,
734                            row_id: (*id).into(),
735                            op: Op::Upsert,
736                            row_version: Some(index as i64 + 1),
737                            scopes: vec![("project_id".into(), "p1".into())],
738                            row: Some(
739                                encode_row_json(
740                                    client.schema.table("tasks").unwrap(),
741                                    id,
742                                    &values,
743                                    &client.encryption,
744                                )
745                                .unwrap(),
746                            ),
747                        }],
748                    });
749                }
750                response.frames.push(Frame::SubEnd {
751                    next_cursor: 2,
752                    bootstrap_state: None,
753                });
754                let valid = response.clone();
755                match fault {
756                    "row" => {
757                        let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
758                        let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
759                    },
760                    "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(),
761                    "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(),
762                    "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
763                    "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});},
764                    "missing-end" => {response.frames.truncate(3);},
765                    _ => {}
766                }
767                if realtime {
768                    client.on_realtime_binary(&mut transport, &encode_message(&response));
769                } else {
770                    assert_eq!(
771                        matches!(
772                            client.process_response(&mut transport, response, &meta),
773                            SyncOutcome::Ok(_)
774                        ),
775                        fault == "none",
776                        "{fault}"
777                    );
778                }
779                assert!(
780                    client.conn.is_autocommit(),
781                    "no open transaction after {realtime}/{fault}"
782                );
783                let count = if matches!(fault, "none" | "cursor") {
784                    2
785                } else {
786                    1
787                };
788                let expected_revision = count + u64::from(realtime && fault != "none");
789                assert_eq!(
790                    client.local_revision(),
791                    expected_revision,
792                    "{realtime}/{fault}"
793                );
794                let batches = client.drain_change_batches();
795                assert_eq!(
796                    batches.len(),
797                    expected_revision as usize,
798                    "{realtime}/{fault}"
799                );
800                let row_batches: Vec<_> = batches
801                    .iter()
802                    .filter(|batch| !batch.tables.is_empty())
803                    .collect();
804                assert_eq!(row_batches.len(), count as usize);
805                assert_eq!(
806                    row_batches
807                        .iter()
808                        .map(|b| b.revision.clone())
809                        .collect::<Vec<_>>(),
810                    (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
811                );
812                for table in ["tasks", "_syncular_base_tasks"] {
813                    assert_eq!(
814                        client
815                            .conn
816                            .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
817                                .get::<_, u64>(0))
818                            .unwrap(),
819                        count,
820                        "{realtime}/{fault}/{table}"
821                    );
822                }
823                assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
824                assert_eq!(
825                    transport
826                        .messages
827                        .iter()
828                        .filter(|m| m.contains("\"ack\""))
829                        .count(),
830                    usize::from(fault == "none")
831                );
832                if matches!(fault, "revision" | "commit" | "cursor") {
833                    client
834                        .conn
835                        .execute_batch("DROP TRIGGER fail_frame")
836                        .unwrap();
837                }
838                drop(client);
839                let mut reopened = SyncClient::open_path(
840                    "frame-test".into(),
841                    &schema,
842                    Default::default(),
843                    path.to_str().unwrap(),
844                )
845                .unwrap();
846                assert_eq!(reopened.local_revision(), expected_revision);
847                assert_eq!(
848                    reopened
849                        .query("SELECT id FROM tasks ORDER BY id", &[])
850                        .unwrap()
851                        .len(),
852                    count as usize
853                );
854                assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
855                let (_, meta) = reopened.build_request(false);
856                assert!(matches!(
857                    reopened.process_response(&mut transport, valid, &meta),
858                    SyncOutcome::Ok(_)
859                ));
860                assert_eq!(
861                    reopened
862                        .query("SELECT id FROM tasks ORDER BY id", &[])
863                        .unwrap()
864                        .len(),
865                    2
866                );
867                assert_eq!(reopened.subs[0].cursor, 2);
868                drop(reopened);
869                std::fs::remove_file(path).unwrap();
870            }
871        }
872    }
873
874    #[test]
875    fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
876        for status in [SubStatus::Reset, SubStatus::Revoked] {
877            for fault in ["subscription", "revision"] {
878                let mut client = client();
879                client
880                    .subscribe(
881                        "tasks".into(),
882                        "tasks".into(),
883                        vec![("project_id".into(), vec!["p1".into()])],
884                        None,
885                    )
886                    .unwrap();
887                client.subs[0].cursor = 41;
888                client.subs[0].synced_once = true;
889                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
890                client.persist_sub(&client.subs[0]).unwrap();
891                client
892                    .mutate(vec![Mutation::Upsert {
893                        table: "tasks".into(),
894                        values: Map::from_iter([
895                            ("id".into(), Value::from("pending")),
896                            ("project_id".into(), Value::from("p1")),
897                        ]),
898                        base_version: None,
899                    }])
900                    .unwrap();
901                let ids = client.pending_commit_ids();
902                let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
903                let revision = client.local_revision();
904                client.drain_change_batches();
905                let (_, meta) = client.build_request(false);
906                client.conn.execute_batch(if fault == "subscription" {
907                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
908                } else {
909                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
910                }).unwrap();
911                let mut transport = CountingRealtimeTransport::default();
912                let mut report = SyncReport::default();
913                // Reset changes window completeness and therefore needs a revision.
914                if status == SubStatus::Reset {
915                    client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
916                }
917                let result = client.process_section(
918                    &mut transport,
919                    "tasks",
920                    status,
921                    "sync.scope_revoked",
922                    vec![],
923                    vec![],
924                    Some((42, None)),
925                    &meta,
926                    &mut report,
927                );
928                assert!(result.is_err(), "{status:?}/{fault}");
929                assert!(client.conn.is_autocommit());
930                assert_eq!(client.subs[0].cursor, 41);
931                assert_eq!(client.subs[0].state, SubState::Active);
932                assert_eq!(client.pending_commit_ids(), ids);
933                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
934                assert!(client.rejections.is_empty());
935                assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
936                assert_eq!(client.local_revision(), revision);
937                assert!(client.drain_change_batches().is_empty());
938                assert!(report.revoked.is_empty() && report.resets.is_empty());
939            }
940        }
941    }
942
943    #[test]
944    fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
945        let mut client = client();
946        client
947            .subscribe(
948                "tasks".into(),
949                "tasks".into(),
950                vec![("project_id".into(), vec!["p1".into()])],
951                None,
952            )
953            .unwrap();
954        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
955        client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
956            CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
957        let row = |id: &str| ssp2::segment::SegmentRow {
958            server_version: 1,
959            values: vec![
960                Some(ColumnValue::String(id.into())),
961                Some(ColumnValue::String("p1".into())),
962            ],
963        };
964        let segment = RowsSegment {
965            table: "tasks".into(),
966            schema_version: 1,
967            columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
968            blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
969        };
970        assert!(client.apply_segment(0, &segment, true).is_err());
971        assert!(client.conn.is_autocommit());
972        assert_eq!(
973            client
974                .query("SELECT id FROM tasks ORDER BY id", &[])
975                .unwrap(),
976            vec![Map::from_iter([("id".into(), Value::from("first"))])]
977        );
978        assert_eq!(client.local_revision(), 1);
979        assert_eq!(client.drain_change_batches().len(), 1);
980        assert_eq!(client.subs[0].cursor, -1);
981        client
982            .conn
983            .execute_batch("DROP TRIGGER fail_block")
984            .unwrap();
985        assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
986        assert_eq!(client.local_revision(), 3);
987        assert_eq!(client.drain_change_batches().len(), 2);
988    }
989
990    #[test]
991    fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
992        assert_eq!(
993            client().diagnostics_storage().blob_cache_bytes_approx,
994            Some(0)
995        );
996        let client = SyncClient::new(
997            "diagnostic-storage".to_owned(),
998            &json!({
999                "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
1000                    "columns": [{"name": "id", "type": "string", "nullable": false},
1001                        {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
1002            }),
1003            ClientLimits {
1004                blob_cache_max_bytes: Some(5),
1005                ..Default::default()
1006            },
1007        )
1008        .unwrap();
1009        let empty = client.diagnostics_storage();
1010        assert_eq!(empty.status, "healthy");
1011        assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
1012        assert_eq!(empty.retained_outcome_entries, Some(0));
1013        assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1014        assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1015        client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1016            INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1017                VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1018            INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1019                VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1020        let written = client.diagnostics_storage();
1021        assert_eq!(written.status, "pressure");
1022        assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1023        assert_eq!(written.retained_outcome_entries, Some(2));
1024        assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1025        assert_eq!(written.blob_cache_bytes_approx, Some(8));
1026        assert_eq!(
1027            written.pressure_reason_code.as_deref(),
1028            Some("client.blob_cache_over_limit")
1029        );
1030        client
1031            .conn
1032            .execute_batch(
1033                "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1034            DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1035            )
1036            .unwrap();
1037        let staged = client.diagnostics_storage();
1038        assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1039        assert_eq!(staged.retained_outcome_entries, Some(0));
1040        assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1041        assert_eq!(staged.status, "healthy");
1042        client.conn.execute_batch("ROLLBACK").unwrap();
1043        assert_eq!(client.diagnostics_storage(), written);
1044        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1045        let unreadable = client.diagnostics_storage();
1046        assert_eq!(unreadable.status, "unreadable");
1047        assert!(unreadable.database_bytes_approx.is_none());
1048        assert!(unreadable.pending_outbox_bytes_approx.is_none());
1049        assert!(unreadable.retained_outcome_entries.is_none());
1050        assert!(unreadable.retained_outcome_bytes_approx.is_none());
1051        assert!(unreadable.blob_cache_bytes_approx.is_none());
1052        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1053        let restored = client.diagnostics_storage();
1054        assert_eq!(restored.status, written.status);
1055        assert_eq!(
1056            restored.retained_outcome_bytes_approx,
1057            written.retained_outcome_bytes_approx
1058        );
1059        client.conn.set_prepared_statement_cache_capacity(0);
1060        assert_eq!(client.diagnostics_storage(), restored);
1061    }
1062
1063    /// Answers every round with one server `ERROR` frame (§1.6).
1064    struct ServerErrorTransport {
1065        code: &'static str,
1066        retryable: bool,
1067    }
1068
1069    impl Transport for ServerErrorTransport {
1070        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1071            Ok(encode_message(&Message {
1072                wire_version: WIRE_VERSION,
1073                msg_kind: MsgKind::Response,
1074                frames: vec![
1075                    Frame::RespHeader {
1076                        required_schema_version: None,
1077                        latest_schema_version: None,
1078                        log_epoch: Some("epoch-1".to_owned()),
1079                        reset_required: Some(false),
1080                    },
1081                    Frame::Error {
1082                        code: self.code.to_owned(),
1083                        message: "server error".to_owned(),
1084                        category: "internal".to_owned(),
1085                        retryable: self.retryable,
1086                        recommended_action: "retryLater".to_owned(),
1087                        details: None,
1088                    },
1089                ],
1090            }))
1091        }
1092
1093        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1094            self.sync(request)
1095        }
1096
1097        fn download_segment(
1098            &mut self,
1099            _request: &SegmentRequest,
1100            _on_progress: &mut dyn FnMut(u64),
1101        ) -> Result<Vec<u8>, TransportError> {
1102            Err(TransportError::new("sync.transport_failed", "offline"))
1103        }
1104
1105        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1106            Ok(())
1107        }
1108
1109        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1110            Ok(())
1111        }
1112
1113        fn realtime_close(&mut self) -> Result<(), TransportError> {
1114            Ok(())
1115        }
1116    }
1117
1118    #[test]
1119    fn retryable_server_error_schedules_a_background_retry() {
1120        let mut client = client();
1121        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1122        let mut transport = ServerErrorTransport {
1123            code: "sync.internal_error",
1124            retryable: true,
1125        };
1126        assert!(matches!(
1127            client.sync(&mut transport),
1128            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1129        ));
1130        assert!(matches!(
1131            client.drain_sync_intents().as_slice(),
1132            [SyncIntent::Background { delay_ms: 250 }]
1133        ));
1134        assert_eq!(
1135            client.progress().snapshot().unwrap().retry_delay_ms,
1136            Some(250)
1137        );
1138        let mut transport = ServerErrorTransport {
1139            code: "sync.invalid_request",
1140            retryable: false,
1141        };
1142        assert!(matches!(
1143            client.sync(&mut transport),
1144            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1145        ));
1146        assert!(client.drain_sync_intents().is_empty());
1147        let progress = client.progress().snapshot().unwrap();
1148        assert_eq!(progress.state, ProgressState::Failed);
1149        assert_eq!(progress.retry_delay_ms, None);
1150    }
1151
1152    #[test]
1153    fn failed_progress_retry_delay_doubles_to_the_cap() {
1154        let mut client = client();
1155        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1156        let mut transport = ServerErrorTransport {
1157            code: "sync.internal_error",
1158            retryable: true,
1159        };
1160        let delays: Vec<Option<u64>> = (0..9)
1161            .map(|_| {
1162                client.sync(&mut transport);
1163                client.progress().snapshot().unwrap().retry_delay_ms
1164            })
1165            .collect();
1166        assert_eq!(
1167            delays,
1168            [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
1169        );
1170    }
1171
1172    #[test]
1173    fn background_retry_deadlines_back_off_and_reset() {
1174        let mut client = client();
1175        client.schedule_background_retry();
1176        assert!(matches!(
1177            client.drain_sync_intents().as_slice(),
1178            [SyncIntent::Background { delay_ms: 250 }]
1179        ));
1180        client.schedule_background_retry();
1181        assert!(matches!(
1182            client.drain_sync_intents().as_slice(),
1183            [SyncIntent::Background { delay_ms: 500 }]
1184        ));
1185        client.reset_background_retry();
1186        client.schedule_background_retry();
1187        assert!(matches!(
1188            client.drain_sync_intents().as_slice(),
1189            [SyncIntent::Background { delay_ms: 250 }]
1190        ));
1191    }
1192
1193    #[test]
1194    fn log_epoch_reset_requests_an_immediate_follow_up_round() {
1195        let mut client = client();
1196        client
1197            .subscribe(
1198                "epoch-tasks".to_owned(),
1199                "tasks".to_owned(),
1200                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1201                None,
1202            )
1203            .expect("subscribe");
1204        client.drain_sync_intents();
1205
1206        client
1207            .run_log_epoch_reset("epoch-2")
1208            .expect("reset partition log epoch");
1209
1210        assert!(matches!(
1211            client.drain_sync_intents().as_slice(),
1212            [SyncIntent::Interactive]
1213        ));
1214    }
1215
1216    #[derive(Default)]
1217    struct CountingRealtimeTransport {
1218        connects: usize,
1219        closes: usize,
1220        messages: Vec<String>,
1221        blob_uploads: Vec<(String, Vec<u8>)>,
1222        blob_download: Option<Vec<u8>>,
1223        sync_calls: usize,
1224        realtime_sync_calls: usize,
1225        refuse_connect: bool,
1226    }
1227
1228    impl Transport for CountingRealtimeTransport {
1229        fn blob_upload(
1230            &mut self,
1231            blob_id: &str,
1232            bytes: &[u8],
1233            _media_type: Option<&str>,
1234        ) -> Result<(), TransportError> {
1235            self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1236            Ok(())
1237        }
1238
1239        fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1240            self.blob_download
1241                .clone()
1242                .map(BlobDownload::Bytes)
1243                .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1244        }
1245
1246        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1247            self.sync_calls += 1;
1248            Err(TransportError::new("sync.transport_failed", "offline"))
1249        }
1250
1251        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1252            self.realtime_sync_calls += 1;
1253            Err(TransportError::new("sync.transport_failed", "offline"))
1254        }
1255
1256        fn download_segment(
1257            &mut self,
1258            _request: &SegmentRequest,
1259            _on_progress: &mut dyn FnMut(u64),
1260        ) -> Result<Vec<u8>, TransportError> {
1261            Err(TransportError::new("sync.transport_failed", "offline"))
1262        }
1263
1264        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1265            self.connects += 1;
1266            if self.refuse_connect {
1267                return Err(TransportError::new("transport.failed", "refused"));
1268            }
1269            Ok(())
1270        }
1271
1272        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1273            self.messages.push(text.to_owned());
1274            Ok(())
1275        }
1276
1277        fn realtime_close(&mut self) -> Result<(), TransportError> {
1278            self.closes += 1;
1279            Ok(())
1280        }
1281    }
1282
1283    #[test]
1284    fn blob_results_own_downloaded_bytes() {
1285        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1286            {"name":"id","type":"string","nullable":false},
1287            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1288        let bytes = b"owned typed result".to_vec();
1289        let blob_id = blob_id_for(&bytes);
1290        let mut transport = CountingRealtimeTransport {
1291            blob_download: Some(bytes.clone()),
1292            ..Default::default()
1293        };
1294        let result = {
1295            let mut client =
1296                SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1297                    .unwrap();
1298            let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1299            assert_eq!(result.blob_id, blob_id);
1300            assert_eq!(result.byte_length, bytes.len() as i64);
1301            assert_eq!(result.bytes, bytes);
1302            assert_eq!(result.media_type, None);
1303            assert_eq!(
1304                client
1305                    .conn
1306                    .query_row(
1307                        "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1308                        rusqlite::params![blob_id],
1309                        |row| row.get::<_, i64>(0),
1310                    )
1311                    .unwrap(),
1312                0
1313            );
1314            result
1315        };
1316        assert_eq!(result.bytes, bytes);
1317
1318        let mut client =
1319            SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1320        assert_eq!(
1321            client
1322                .fetch_blob_bytes(&mut transport, "not json")
1323                .unwrap_err(),
1324            (
1325                "client.failed".to_owned(),
1326                "blob ref is not JSON".to_owned()
1327            )
1328        );
1329    }
1330
1331    #[test]
1332    fn visible_blob_references_survive_cache_trimming() {
1333        let schema = json!({"version":1,"tables":[
1334            {"name":"attachments","primaryKey":"id","columns":[
1335                {"name":"id","type":"string","nullable":false},
1336                {"name":"body","type":"blob_ref","nullable":true},
1337                {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
1338            {"name":"avatars","primaryKey":"id","columns":[
1339                {"name":"id","type":"string","nullable":false},
1340                {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
1341        ]});
1342        let bytes = b"referenced above cap".to_vec();
1343        let blob_id = blob_id_for(&bytes);
1344        let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
1345        let mut client = SyncClient::new(
1346            "visible-blob-refs".to_owned(),
1347            &schema,
1348            ClientLimits {
1349                blob_cache_max_bytes: Some(1),
1350                ..Default::default()
1351            },
1352        )
1353        .unwrap();
1354        client.conn.execute(
1355            "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
1356            rusqlite::params![reference, reference, reference, reference],
1357        ).unwrap();
1358        client
1359            .conn
1360            .execute(
1361                "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
1362                rusqlite::params![reference],
1363            )
1364            .unwrap();
1365        client.conn.execute(
1366            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
1367            [],
1368        ).unwrap();
1369        let mut transport = CountingRealtimeTransport {
1370            blob_download: Some(bytes.clone()),
1371            ..Default::default()
1372        };
1373
1374        assert_eq!(
1375            client
1376                .fetch_blob_bytes(&mut transport, &blob_id)
1377                .unwrap()
1378                .bytes,
1379            bytes
1380        );
1381        let rows = client
1382            .conn
1383            .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1384            .unwrap()
1385            .query_map([], |row| row.get::<_, String>(0))
1386            .unwrap()
1387            .collect::<Result<Vec<_>, _>>()
1388            .unwrap();
1389        assert_eq!(rows, vec![blob_id]);
1390    }
1391
1392    #[test]
1393    fn realtime_connection_ownership_is_idempotent() {
1394        let mut client = client();
1395        let mut transport = CountingRealtimeTransport {
1396            connects: 0,
1397            closes: 0,
1398            ..Default::default()
1399        };
1400        client
1401            .connect_realtime(&mut transport)
1402            .expect("first connect");
1403        client
1404            .connect_realtime(&mut transport)
1405            .expect("idempotent connect");
1406        assert_eq!(transport.connects, 1);
1407        client.disconnect_realtime(&mut transport);
1408        client.disconnect_realtime(&mut transport);
1409        assert_eq!(transport.closes, 1);
1410        client
1411            .connect_realtime(&mut transport)
1412            .expect("deliberate reconnect");
1413        assert_eq!(transport.connects, 2);
1414    }
1415
1416    #[test]
1417    fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
1418        let mut client = client();
1419        client
1420            .set_realtime_policy(RealtimePolicy::Required)
1421            .expect("set policy");
1422        let mut transport = CountingRealtimeTransport::default();
1423        match client.sync(&mut transport) {
1424            SyncOutcome::RealtimeUnavailable {
1425                state,
1426                reason_code,
1427                retry_delay_ms,
1428            } => {
1429                assert_eq!(state, RealtimeState::Disconnected);
1430                assert_eq!(reason_code, None);
1431                assert_eq!(retry_delay_ms, 250);
1432            }
1433            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1434        }
1435        assert_eq!(transport.sync_calls, 0);
1436        assert!(matches!(
1437            client.drain_sync_intents().as_slice(),
1438            [SyncIntent::Background { delay_ms: 250 }]
1439        ));
1440        let snapshot = client
1441            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1442            .expect("diagnostics");
1443        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1444        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
1445        assert_eq!(snapshot.host.realtime_reason_code, None);
1446        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1447        assert_eq!(
1448            snapshot
1449                .last_round
1450                .as_ref()
1451                .and_then(|round| round.error_code.clone()),
1452            Some(REALTIME_UNAVAILABLE_CODE.to_owned())
1453        );
1454    }
1455
1456    #[test]
1457    fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
1458        let mut client = client();
1459        client
1460            .set_realtime_policy(RealtimePolicy::Required)
1461            .expect("set policy");
1462        let mut transport = CountingRealtimeTransport {
1463            refuse_connect: true,
1464            ..Default::default()
1465        };
1466        assert!(client.connect_realtime(&mut transport).is_err());
1467        assert_eq!(client.realtime_state(), RealtimeState::Refused);
1468        let snapshot = client
1469            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1470            .expect("diagnostics");
1471        assert_eq!(
1472            snapshot.host.realtime_reason_code.as_deref(),
1473            Some("transport.failed")
1474        );
1475        match client.sync(&mut transport) {
1476            SyncOutcome::RealtimeUnavailable {
1477                state, reason_code, ..
1478            } => {
1479                assert_eq!(state, RealtimeState::Refused);
1480                assert_eq!(reason_code.as_deref(), Some("transport.failed"));
1481            }
1482            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1483        }
1484        assert_eq!(transport.sync_calls, 0);
1485        assert_eq!(transport.realtime_sync_calls, 0);
1486    }
1487
1488    #[test]
1489    fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
1490        let mut client = client();
1491        client
1492            .set_realtime_policy(RealtimePolicy::Required)
1493            .expect("set policy");
1494        let mut transport = CountingRealtimeTransport::default();
1495        client.connect_realtime(&mut transport).expect("connect");
1496        assert_eq!(client.realtime_state(), RealtimeState::Connected);
1497        assert!(
1498            client.set_realtime_policy(RealtimePolicy::Off).is_err(),
1499            "a policy change cannot silently re-route a connected socket"
1500        );
1501        assert!(matches!(
1502            client.sync(&mut transport),
1503            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
1504        ));
1505        assert_eq!(client.realtime_state(), RealtimeState::Lost);
1506        assert_eq!(transport.realtime_sync_calls, 1);
1507        assert_eq!(transport.sync_calls, 0);
1508        let snapshot = client
1509            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1510            .expect("diagnostics");
1511        assert_eq!(
1512            snapshot.host.realtime_reason_code.as_deref(),
1513            Some("sync.transport_failed")
1514        );
1515        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1516        match client.sync(&mut transport) {
1517            SyncOutcome::RealtimeUnavailable {
1518                state, reason_code, ..
1519            } => {
1520                assert_eq!(state, RealtimeState::Lost);
1521                assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
1522            }
1523            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1524        }
1525        assert_eq!(transport.sync_calls, 0);
1526        client.disconnect_realtime(&mut transport);
1527        assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
1528        assert_eq!(transport.closes, 1);
1529        client.connect_realtime(&mut transport).expect("reconnect");
1530        assert_eq!(client.realtime_state(), RealtimeState::Connected);
1531        assert_eq!(transport.connects, 2);
1532    }
1533
1534    #[test]
1535    fn off_policy_uses_http_and_refuses_an_explicit_connect() {
1536        let mut client = client();
1537        client
1538            .set_realtime_policy(RealtimePolicy::Off)
1539            .expect("set policy");
1540        let mut transport = CountingRealtimeTransport::default();
1541        assert!(client.connect_realtime(&mut transport).is_err());
1542        assert_eq!(client.realtime_state(), RealtimeState::Disabled);
1543        assert!(matches!(
1544            client.sync(&mut transport),
1545            SyncOutcome::Failed { .. }
1546        ));
1547        assert_eq!(transport.sync_calls, 1);
1548        assert_eq!(transport.realtime_sync_calls, 0);
1549        let snapshot = client
1550            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1551            .expect("diagnostics");
1552        assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
1553        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
1554    }
1555
1556    #[test]
1557    fn optional_policy_keeps_the_http_round() {
1558        let mut client = client();
1559        let mut transport = CountingRealtimeTransport::default();
1560        assert!(matches!(
1561            client.sync(&mut transport),
1562            SyncOutcome::Failed { .. }
1563        ));
1564        assert_eq!(transport.sync_calls, 1);
1565        assert_eq!(transport.realtime_sync_calls, 0);
1566        let snapshot = client
1567            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1568            .expect("diagnostics");
1569        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1570        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
1571    }
1572
1573    #[test]
1574    fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1575        let mut client = client();
1576        client
1577            .subscribe(
1578                "repair-tasks".to_owned(),
1579                "tasks".to_owned(),
1580                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1581                None,
1582            )
1583            .expect("subscribe");
1584        {
1585            let sub = client
1586                .subs
1587                .iter_mut()
1588                .find(|sub| sub.id == "repair-tasks")
1589                .expect("subscription");
1590            sub.cursor = 42;
1591            sub.synced_once = true;
1592            let persisted = sub.clone();
1593            client.persist_sub(&persisted).unwrap();
1594        }
1595        for table in ["_syncular_base_tasks", "tasks"] {
1596            client
1597                .conn
1598                .execute(
1599                    &format!(
1600                        "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1601                    ),
1602                    rusqlite::params!["server-row", "p1"],
1603                )
1604                .expect("seed server row");
1605        }
1606        let pending = client
1607            .mutate(vec![Mutation::Upsert {
1608                table: "tasks".to_owned(),
1609                values: Map::from_iter([
1610                    ("id".to_owned(), Value::from("offline-row")),
1611                    ("project_id".to_owned(), Value::from("p1")),
1612                ]),
1613                base_version: None,
1614            }])
1615            .expect("queue offline work");
1616        client.drain_change_batches();
1617        client.drain_sync_intents();
1618
1619        assert_eq!(
1620            client
1621                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1622                    rebootstrap_id: "support-case-001".to_owned(),
1623                })
1624                .expect("rebootstrap"),
1625            LocalDataRebootstrapResult {
1626                already_applied: false,
1627                retained_commits: 1,
1628                reset_subscriptions: 1,
1629            }
1630        );
1631        let visible_ids = client
1632            .conn
1633            .prepare("SELECT id FROM tasks ORDER BY id")
1634            .expect("prepare visible ids")
1635            .query_map([], |row| row.get::<_, String>(0))
1636            .expect("query visible ids")
1637            .collect::<Result<Vec<_>, _>>()
1638            .expect("collect visible ids");
1639        assert_eq!(visible_ids, vec!["offline-row"]);
1640        assert_eq!(client.pending_commit_ids(), vec![pending]);
1641        assert_eq!(
1642            client
1643                .subscription_state("repair-tasks")
1644                .expect("subscription")
1645                .cursor,
1646            -1
1647        );
1648        assert!(client.upgrading());
1649        assert!(client.sync_needed());
1650        assert!(matches!(
1651            client.drain_sync_intents().as_slice(),
1652            [SyncIntent::Interactive]
1653        ));
1654        assert_eq!(client.drain_change_batches().len(), 1);
1655
1656        assert_eq!(
1657            client
1658                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1659                    rebootstrap_id: "support-case-001".to_owned(),
1660                })
1661                .expect("idempotent retry"),
1662            LocalDataRebootstrapResult {
1663                already_applied: true,
1664                retained_commits: 1,
1665                reset_subscriptions: 1,
1666            }
1667        );
1668        assert!(client.drain_change_batches().is_empty());
1669    }
1670
1671    #[test]
1672    fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1673        let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1674        assert_eq!(
1675            decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1676            (3, 4)
1677        );
1678        assert_eq!(
1679            decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1680            (0, 0)
1681        );
1682        for malformed in [
1683            "",
1684            "{}",
1685            r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1686            r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1687            r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1688        ] {
1689            assert_eq!(
1690                decode_local_rebootstrap_receipt(malformed)
1691                    .expect_err("malformed receipt must fail"),
1692                "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1693            );
1694        }
1695    }
1696
1697    #[test]
1698    fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1699        let path = std::env::temp_dir().join(format!(
1700            "syncular-rebootstrap-receipt-{}.db",
1701            uuid::Uuid::new_v4()
1702        ));
1703        let schema = json!({
1704            "version": 1,
1705            "tables": [{
1706                "name": "tasks",
1707                "primaryKey": "id",
1708                "columns": [
1709                    { "name": "id", "type": "string", "nullable": false },
1710                    { "name": "project_id", "type": "string", "nullable": false }
1711                ],
1712                "scopes": [{ "pattern": "project:{project_id}" }]
1713            }]
1714        });
1715        let path_string = path.to_str().expect("UTF-8 temp path");
1716
1717        {
1718            let mut first = SyncClient::open_path(
1719                "repair-restart-client".to_owned(),
1720                &schema,
1721                ClientLimits::default(),
1722                path_string,
1723            )
1724            .expect("first open");
1725            first
1726                .subscribe(
1727                    "repair-tasks".to_owned(),
1728                    "tasks".to_owned(),
1729                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1730                    None,
1731                )
1732                .expect("subscribe");
1733            first
1734                .mutate(vec![Mutation::Upsert {
1735                    table: "tasks".to_owned(),
1736                    values: Map::from_iter([
1737                        ("id".to_owned(), Value::from("offline-row")),
1738                        ("project_id".to_owned(), Value::from("p1")),
1739                    ]),
1740                    base_version: None,
1741                }])
1742                .expect("queue offline work");
1743            assert_eq!(
1744                first
1745                    .rebootstrap_local_data(&LocalDataRebootstrapInput {
1746                        rebootstrap_id: "restart-receipt".to_owned(),
1747                    })
1748                    .expect("first rebootstrap"),
1749                LocalDataRebootstrapResult {
1750                    already_applied: false,
1751                    retained_commits: 1,
1752                    reset_subscriptions: 1,
1753                }
1754            );
1755        }
1756
1757        let mut reopened = SyncClient::open_path(
1758            "repair-restart-client".to_owned(),
1759            &schema,
1760            ClientLimits::default(),
1761            path_string,
1762        )
1763        .expect("reopen");
1764        reopened.drain_change_batches();
1765        reopened.drain_sync_intents();
1766        assert_eq!(
1767            reopened
1768                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1769                    rebootstrap_id: "restart-receipt".to_owned(),
1770                })
1771                .expect("receipt replay"),
1772            LocalDataRebootstrapResult {
1773                already_applied: true,
1774                retained_commits: 1,
1775                reset_subscriptions: 1,
1776            }
1777        );
1778        assert!(reopened.drain_change_batches().is_empty());
1779        assert!(reopened.drain_sync_intents().is_empty());
1780        drop(reopened);
1781        std::fs::remove_file(path).expect("remove temp database");
1782    }
1783
1784    #[test]
1785    fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1786        let mut malformed = client();
1787        malformed
1788            .subscribe(
1789                "repair-tasks".to_owned(),
1790                "tasks".to_owned(),
1791                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1792                None,
1793            )
1794            .expect("subscribe");
1795        malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1796        malformed.drain_change_batches();
1797        malformed.drain_sync_intents();
1798        let malformed_error = malformed
1799            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1800                rebootstrap_id: "malformed".to_owned(),
1801            })
1802            .expect_err("malformed receipt must fail closed");
1803        assert_eq!(
1804            malformed_error,
1805            "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1806        );
1807        assert!(!malformed.upgrading());
1808        assert_eq!(
1809            malformed
1810                .subscription_state("repair-tasks")
1811                .expect("unchanged subscription")
1812                .cursor,
1813            -1
1814        );
1815        assert!(malformed.drain_change_batches().is_empty());
1816        assert!(malformed.drain_sync_intents().is_empty());
1817
1818        let mut unreadable = client();
1819        unreadable
1820            .conn
1821            .execute(
1822                "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1823                rusqlite::params!["server-row", "p1"],
1824            )
1825            .expect("seed visible row");
1826        unreadable
1827            .conn
1828            .execute("DROP TABLE _syncular_meta", [])
1829            .expect("break marker storage");
1830        let unreadable_error = unreadable
1831            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1832                rebootstrap_id: "unreadable".to_owned(),
1833            })
1834            .expect_err("unreadable marker storage must fail closed");
1835        assert_eq!(
1836            unreadable_error,
1837            "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1838        );
1839        let visible_rows = unreadable
1840            .conn
1841            .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1842            .expect("visible projection remains");
1843        assert_eq!(visible_rows, 1);
1844        assert!(!unreadable.upgrading());
1845        assert!(unreadable.drain_change_batches().is_empty());
1846        assert!(unreadable.drain_sync_intents().is_empty());
1847    }
1848
1849    #[test]
1850    fn local_rebootstrap_cannot_bypass_schema_floor() {
1851        let mut client = client();
1852        client.set_schema_floor(Some(SchemaFloor {
1853            required_schema_version: Some(2),
1854            latest_schema_version: Some(2),
1855        }));
1856        let error = client
1857            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1858                rebootstrap_id: "blocked-floor".to_owned(),
1859            })
1860            .expect_err("schema floor must block repair");
1861        assert!(error.contains("cannot bypass an active schema-floor stop"));
1862    }
1863
1864    #[test]
1865    fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
1866        let mut client = client();
1867        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1868        const COMMIT_COUNT: usize = 32;
1869
1870        for index in 0..COMMIT_COUNT {
1871            client
1872                .mutate(vec![Mutation::Upsert {
1873                    table: "tasks".to_owned(),
1874                    values: Map::from_iter([
1875                        ("id".to_owned(), Value::from(format!("task-{index}"))),
1876                        ("project_id".to_owned(), Value::from("project-1")),
1877                    ]),
1878                    base_version: None,
1879                }])
1880                .expect("queue commit");
1881        }
1882
1883        let (_, request_meta) = client.build_request(false);
1884        assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
1885        let mut frames = vec![Frame::RespHeader {
1886            required_schema_version: None,
1887            latest_schema_version: None,
1888            log_epoch: Some("epoch-1".to_owned()),
1889            reset_required: Some(false),
1890        }];
1891        frames.extend(request_meta.pushed_ids.iter().enumerate().map(
1892            |(index, client_commit_id)| Frame::PushResult {
1893                client_commit_id: client_commit_id.clone(),
1894                status: PushStatus::Applied,
1895                commit_seq: Some(index as i64 + 1),
1896                results: vec![OpResult::Applied { op_index: 0 }],
1897            },
1898        ));
1899        let response = Message {
1900            wire_version: WIRE_VERSION,
1901            msg_kind: MsgKind::Response,
1902            frames,
1903        };
1904        let mut transport =
1905            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
1906
1907        client.overlay_rebuild_count.set(0);
1908        client.outcome_prune_count.set(0);
1909        let outcome = client.process_response(&mut transport, response, &request_meta);
1910        assert!(matches!(outcome, SyncOutcome::Ok(_)));
1911        assert!(client.pending_commit_ids().is_empty());
1912        assert_eq!(
1913            client.overlay_rebuild_count.get(),
1914            1,
1915            "one response must reconcile its acknowledged commits with one overlay rebuild"
1916        );
1917        assert_eq!(
1918            client.outcome_prune_count.get(),
1919            1,
1920            "one response must enforce outcome retention once"
1921        );
1922    }
1923
1924    #[test]
1925    fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
1926        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
1927            "columns": [
1928                {"name":"id", "type":"string", "nullable":false},
1929                {"name":"project_id", "type":"string", "nullable":false},
1930                {"name":"title", "type":"string", "nullable":false}
1931            ], "scopes": [{"pattern":"project:{project_id}"}],
1932            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1933            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
1934        }] });
1935        let mut incremental =
1936            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1937        let mut reference =
1938            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1939        let tables = vec!["tasks".to_owned()];
1940        for index in 0..32 {
1941            let id = format!("task-{}", index % 8);
1942            let values = Map::from_iter([
1943                ("id".to_owned(), Value::from(id.clone())),
1944                (
1945                    "project_id".to_owned(),
1946                    Value::from(format!("p{}", index % 2)),
1947                ),
1948                ("title".to_owned(), Value::from(format!("needle{index}"))),
1949            ]);
1950            let payload = encode_row_json(
1951                incremental.schema.table("tasks").unwrap(),
1952                &id,
1953                &values,
1954                &incremental.encryption,
1955            )
1956            .unwrap();
1957            let change = ssp2::model::Change {
1958                table_index: 0,
1959                row_id: id,
1960                op: if index % 5 == 0 {
1961                    Op::Delete
1962                } else {
1963                    Op::Upsert
1964                },
1965                row_version: Some(index + 1),
1966                scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
1967                row: Some(payload),
1968            };
1969            reference.overlay_dirty.set(true);
1970            for instance in [&mut incremental, &mut reference] {
1971                instance.begin_observation("mirror_test").unwrap();
1972                let mut batch = ChangeAccumulator::default();
1973                instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
1974                instance
1975                    .apply_commit_changes(&tables, std::slice::from_ref(&change))
1976                    .unwrap();
1977                instance.rebuild_overlay_if_dirty();
1978                instance.finish_observation("mirror_test", batch).unwrap();
1979            }
1980            for sql in [
1981                "SELECT * FROM tasks ORDER BY id",
1982                "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
1983            ] {
1984                assert_eq!(
1985                    incremental.query(sql, &[]).unwrap(),
1986                    reference.query(sql, &[]).unwrap()
1987                );
1988            }
1989            assert_eq!(incremental.local_revision(), reference.local_revision());
1990            assert_eq!(
1991                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1992                serde_json::to_value(reference.drain_change_batches()).unwrap()
1993            );
1994        }
1995        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
1996        assert_eq!(reference.overlay_rebuild_count.get(), 32);
1997    }
1998
1999    #[test]
2000    fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
2001        let cases = [
2002            (
2003                "string",
2004                vec![
2005                    json!("1"),
2006                    json!("01"),
2007                    json!(""),
2008                    json!("nul\u{0}id"),
2009                    json!("é"),
2010                ],
2011            ),
2012            (
2013                "integer",
2014                vec![
2015                    json!(i64::MIN),
2016                    json!(-1),
2017                    json!(0),
2018                    json!(1),
2019                    json!(i64::MAX),
2020                ],
2021            ),
2022            ("boolean", vec![json!(false), json!(true)]),
2023            ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
2024        ];
2025        for (kind, ids) in cases {
2026            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2027                "columns":[{"name":"id","type":kind,"nullable":false},
2028                    {"name":"project_id","type":"string","nullable":false}],
2029                "scopes":[{"pattern":"project:{project_id}"}]}]});
2030            let mut client =
2031                SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
2032            for (index, id) in ids.iter().enumerate() {
2033                let table = client.schema.table("tasks").unwrap();
2034                client
2035                    .write_base_row(
2036                        "tasks",
2037                        &vec![
2038                            json_to_column_value(&table.columns[0], Some(id)).unwrap(),
2039                            Some(ColumnValue::String(format!("p{index}"))),
2040                        ],
2041                        1,
2042                    )
2043                    .unwrap();
2044            }
2045            client.rebuild_overlay_if_dirty();
2046            for base in [true, false] {
2047                let full = if base {
2048                    base_table("tasks")
2049                } else {
2050                    visible_table("tasks")
2051                };
2052                let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
2053                for row_id in [
2054                    "1",
2055                    "01",
2056                    "1.0",
2057                    "1.0000000000000002",
2058                    "+1",
2059                    "1e0",
2060                    " 1",
2061                    "-1",
2062                    "0",
2063                    "9223372036854775807",
2064                    "-9223372036854775808",
2065                    "true",
2066                    "false",
2067                    "",
2068                    "nul\u{0}id",
2069                    "é",
2070                    "null",
2071                    "{\"a\":1}",
2072                ] {
2073                    let old_sql =
2074                        format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
2075                    let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
2076                    let old = client
2077                        .conn
2078                        .prepare(&old_sql)
2079                        .unwrap()
2080                        .query_map([row_id], |row| row.get::<_, String>(0))
2081                        .unwrap()
2082                        .collect::<Result<Vec<_>, _>>()
2083                        .unwrap();
2084                    let new = client
2085                        .conn
2086                        .prepare_cached(&new_sql)
2087                        .unwrap()
2088                        .query_map([row_id], |row| row.get::<_, String>(0))
2089                        .unwrap()
2090                        .collect::<Result<Vec<_>, _>>()
2091                        .unwrap();
2092                    assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
2093                    let mut batch = ChangeAccumulator::default();
2094                    assert_eq!(
2095                        client.record_row_scopes(&mut batch, "tasks", row_id, base),
2096                        !old.is_empty()
2097                    );
2098                    let expected = old.first().map(|scope| {
2099                        BTreeMap::from([(
2100                            "tasks".to_owned(),
2101                            Some(BTreeSet::from([format!("project:{scope}")])),
2102                        )])
2103                    });
2104                    assert_eq!(batch.tables, expected.unwrap_or_default());
2105                    client
2106                        .conn
2107                        .execute_batch("SAVEPOINT lookup_delete")
2108                        .unwrap();
2109                    if base {
2110                        client.delete_base_row("tasks", row_id).unwrap();
2111                    } else {
2112                        client.apply_outbox_ops(&[OutboxOp {
2113                            upsert: false,
2114                            table: "tasks".into(),
2115                            row_id: row_id.into(),
2116                            base_version: None,
2117                            values: None,
2118                        }]);
2119                    }
2120                    let remaining: i64 = client
2121                        .conn
2122                        .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
2123                            row.get(0)
2124                        })
2125                        .unwrap();
2126                    assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
2127                    client
2128                        .conn
2129                        .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
2130                        .unwrap();
2131                    client.overlay_dirty.set(false);
2132                }
2133                for (predicate, expected) in [
2134                    ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
2135                    (predicate, "SEARCH"),
2136                ] {
2137                    let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
2138                    let details = client
2139                        .conn
2140                        .prepare(&sql)
2141                        .unwrap()
2142                        .query_map(["1"], |row| row.get::<_, String>(3))
2143                        .unwrap()
2144                        .collect::<Result<Vec<_>, _>>()
2145                        .unwrap();
2146                    assert!(
2147                        details.iter().any(|detail| detail.contains(expected)),
2148                        "{kind}: {details:?}"
2149                    );
2150                }
2151            }
2152        }
2153    }
2154
2155    #[test]
2156    fn primary_keys_reject_every_ineligible_column_type() {
2157        // §2.4: a `rowId` is a string, so a primary key type whose string form
2158        // differs per renderer, or cannot be reproduced by local storage
2159        // comparison, is a schema error rather than a silent lookup miss.
2160        for ty in ["float", "bytes", "crdt", "blob_ref"] {
2161            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2162                "columns":[{"name":"id","type":ty,"nullable":false},
2163                    {"name":"project_id","type":"string","nullable":false}],
2164                "scopes":[{"pattern":"project:{project_id}"}]}]});
2165            let error =
2166                match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
2167                    Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
2168                    Err(error) => error,
2169                };
2170            assert!(
2171                error.contains("has an ineligible column type (§2.4)"),
2172                "{ty}: {error}"
2173            );
2174        }
2175        for ty in ["string", "integer", "boolean", "json"] {
2176            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2177                "columns":[{"name":"id","type":ty,"nullable":false},
2178                    {"name":"project_id","type":"string","nullable":false}],
2179                "scopes":[{"pattern":"project:{project_id}"}]}]});
2180            SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
2181                .unwrap_or_else(|error| panic!("{ty}: {error}"));
2182        }
2183    }
2184
2185    #[test]
2186    fn pending_row_reconciliation_matches_full_fifo_replay() {
2187        for unique in [false, true] {
2188            let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
2189                "name": name, "primaryKey": "id", "columns": [
2190                    {"name":"id", "type":"string", "nullable":false},
2191                    {"name":"project_id", "type":"string", "nullable":false},
2192                    {"name":"title", "type":"string", "nullable":false}],
2193                "scopes": [{"pattern":"project:{project_id}"}],
2194                "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
2195                "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
2196            })))});
2197            let mut incremental =
2198                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2199            let mut reference =
2200                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2201            let tables = vec!["tasks".to_owned(), "notes".to_owned()];
2202            for instance in [&mut incremental, &mut reference] {
2203                for table in &tables {
2204                    for index in 0..12 {
2205                        instance
2206                            .write_base_row(
2207                                table,
2208                                &vec![
2209                                    Some(ColumnValue::String(format!("row{index}"))),
2210                                    Some(ColumnValue::String("p0".into())),
2211                                    Some(ColumnValue::String(format!("base{index}"))),
2212                                ],
2213                                1,
2214                            )
2215                            .unwrap();
2216                    }
2217                }
2218                instance.rebuild_overlay_if_dirty();
2219            }
2220            for index in 0..80 {
2221                let mutations = (0..3)
2222                    .map(|offset| {
2223                        let table = tables[(index + offset) % 2].clone();
2224                        let id = format!("row{}", (index + offset) % 16);
2225                        if (index + offset) % 7 == 0 {
2226                            Mutation::Delete {
2227                                table,
2228                                row_id: id,
2229                                base_version: None,
2230                            }
2231                        } else {
2232                            Mutation::Upsert {
2233                                table,
2234                                values: Map::from_iter([
2235                                    ("id".into(), Value::from(id)),
2236                                    ("project_id".into(), Value::from(format!("p{}", index % 3))),
2237                                    (
2238                                        "title".into(),
2239                                        Value::from(format!("needle{}", (index + offset) % 16)),
2240                                    ),
2241                                ]),
2242                                base_version: None,
2243                            }
2244                        }
2245                    })
2246                    .collect::<Vec<_>>();
2247                for instance in [&mut incremental, &mut reference] {
2248                    instance.mutate(mutations.clone()).unwrap();
2249                }
2250                // Most frames touch only the table without unique constraints.
2251                // Mixed frames must retain full replay for both tables.
2252                let changes = (0..3)
2253                    .map(|offset| {
2254                        let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
2255                        let id = format!("row{}", (index + offset) % 16);
2256                        let values = Map::from_iter([
2257                            ("id".into(), Value::from(id.clone())),
2258                            (
2259                                "project_id".into(),
2260                                Value::from(format!("p{}", (index + 1) % 3)),
2261                            ),
2262                            (
2263                                "title".into(),
2264                                Value::from(format!("remote{index}_{offset}")),
2265                            ),
2266                        ]);
2267                        ssp2::model::Change {
2268                            table_index: table_index as u16,
2269                            row_id: id.clone(),
2270                            op: if (index + offset) % 5 == 0 {
2271                                Op::Delete
2272                            } else {
2273                                Op::Upsert
2274                            },
2275                            row_version: Some(index as i64 + 2),
2276                            scopes: vec![],
2277                            row: Some(
2278                                encode_row_json(
2279                                    incremental.schema.table(&tables[table_index]).unwrap(),
2280                                    &id,
2281                                    &values,
2282                                    &incremental.encryption,
2283                                )
2284                                .unwrap(),
2285                            ),
2286                        }
2287                    })
2288                    .collect::<Vec<_>>();
2289                reference.overlay_dirty.set(true);
2290                incremental.apply_commit_frame(&tables, &changes).unwrap();
2291                reference.apply_commit_frame(&tables, &changes).unwrap();
2292                for table in &tables {
2293                    for sql in [format!("SELECT * FROM {table} ORDER BY id"),
2294                        format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
2295                        format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
2296                        format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
2297                        assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
2298                    }
2299                }
2300                assert_eq!(incremental.local_revision(), reference.local_revision());
2301                assert_eq!(
2302                    serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2303                    serde_json::to_value(reference.drain_change_batches()).unwrap()
2304                );
2305            }
2306            assert_eq!(
2307                incremental.overlay_rebuild_count.get(),
2308                if unique { 81 } else { 21 }
2309            );
2310            assert_eq!(reference.overlay_rebuild_count.get(), 81);
2311        }
2312    }
2313
2314    #[test]
2315    fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
2316        let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
2317            "columns":[{"name":"id", "type":"string", "nullable":false},
2318                {"name":"title", "type":"string", "nullable":false}],
2319            "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
2320        let mut client =
2321            SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
2322        for (id, title) in [("a", "original"), ("b", "occupied")] {
2323            client
2324                .write_base_row(
2325                    "tasks",
2326                    &vec![
2327                        Some(ColumnValue::String(id.into())),
2328                        Some(ColumnValue::String(title.into())),
2329                    ],
2330                    1,
2331                )
2332                .unwrap();
2333        }
2334        client.rebuild_overlay_if_dirty();
2335        let pending = client
2336            .mutate(vec![Mutation::Upsert {
2337                table: "tasks".into(),
2338                values: Map::from_iter([
2339                    ("id".into(), Value::from("a")),
2340                    ("title".into(), Value::from("available")),
2341                ]),
2342                base_version: None,
2343            }])
2344            .unwrap();
2345        assert_eq!(
2346            client
2347                .conn
2348                .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2349                    .get::<_, String>(
2350                    0
2351                ))
2352                .unwrap(),
2353            "available"
2354        );
2355        for (title, expected) in [("available", "original"), ("occupied", "available")] {
2356            let change = ssp2::model::Change {
2357                table_index: 0,
2358                row_id: "b".into(),
2359                op: Op::Upsert,
2360                row_version: Some(2),
2361                scopes: vec![],
2362                row: Some(
2363                    encode_row_json(
2364                        client.schema.table("tasks").unwrap(),
2365                        "b",
2366                        &Map::from_iter([
2367                            ("id".into(), Value::from("b")),
2368                            ("title".into(), Value::from(title)),
2369                        ]),
2370                        &client.encryption,
2371                    )
2372                    .unwrap(),
2373                ),
2374            };
2375            client
2376                .apply_commit_frame(&["tasks".into()], &[change])
2377                .unwrap();
2378            assert_eq!(
2379                client
2380                    .conn
2381                    .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2382                        .get::<_, String>(
2383                        0
2384                    ))
2385                    .unwrap(),
2386                expected
2387            );
2388            assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
2389        }
2390        assert_eq!(client.overlay_rebuild_count.get(), 3);
2391    }
2392
2393    #[test]
2394    fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
2395        for failure in ["visible", "revision", "commit"] {
2396            let mut client = client();
2397            let id = client
2398                .mutate(vec![Mutation::Upsert {
2399                    table: "tasks".into(),
2400                    values: Map::from_iter([
2401                        ("id".into(), Value::from("t1")),
2402                        ("project_id".into(), Value::from("pending")),
2403                    ]),
2404                    base_version: None,
2405                }])
2406                .unwrap();
2407            client.drain_change_batches();
2408            let revision = client.local_revision();
2409            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
2410            let trigger = match failure {
2411                "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
2412                "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"),
2413                _ => "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(),
2414            };
2415            client.conn.execute_batch(&trigger).unwrap();
2416            let change = ssp2::model::Change {
2417                table_index: 0,
2418                row_id: "t1".into(),
2419                op: Op::Upsert,
2420                row_version: Some(2),
2421                scopes: vec![],
2422                row: Some(
2423                    encode_row_json(
2424                        client.schema.table("tasks").unwrap(),
2425                        "t1",
2426                        &Map::from_iter([
2427                            ("id".into(), Value::from("t1")),
2428                            ("project_id".into(), Value::from("remote")),
2429                        ]),
2430                        &client.encryption,
2431                    )
2432                    .unwrap(),
2433                ),
2434            };
2435            assert!(client
2436                .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change))
2437                .is_err());
2438            assert!(client.conn.is_autocommit());
2439            assert!(!client.overlay_dirty.get());
2440            assert_eq!(client.local_revision(), revision);
2441            assert!(client.drain_change_batches().is_empty());
2442            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
2443            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
2444            assert!(client
2445                .query("SELECT * FROM _syncular_base_tasks", &[])
2446                .unwrap()
2447                .is_empty());
2448            client
2449                .conn
2450                .execute_batch("DROP TRIGGER fail_pending")
2451                .unwrap();
2452            client
2453                .apply_commit_frame(&["tasks".into()], &[change])
2454                .unwrap();
2455            assert_eq!(client.local_revision(), revision + 1);
2456            assert_eq!(client.pending_commit_ids(), vec![id]);
2457            // The overlay keeps the pending write visible; the row now carries
2458            // the server row's version it is layered on (§7.1).
2459            let mut expected = before[0].clone();
2460            expected.insert("_syncular_version".to_owned(), Value::from(2));
2461            assert_eq!(
2462                client.query("SELECT * FROM tasks", &[]).unwrap(),
2463                vec![expected]
2464            );
2465            assert_eq!(
2466                client
2467                    .conn
2468                    .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
2469                        .get::<_, String>(0))
2470                    .unwrap(),
2471                "remote"
2472            );
2473        }
2474    }
2475
2476    #[test]
2477    fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
2478        let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
2479            "columns": [
2480                {"name":"id", "type":"string", "nullable":false},
2481                {"name":"project_id", "type":"string", "nullable":false},
2482                {"name":"title", "type":"string", "nullable":false}],
2483            "scopes": [{"pattern":"project:{project_id}"}],
2484            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2485            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
2486        let mut incremental =
2487            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2488        let mut reference =
2489            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2490        for instance in [&mut incremental, &mut reference] {
2491            for index in 0..16 {
2492                instance
2493                    .write_base_row(
2494                        "tasks",
2495                        &vec![
2496                            Some(ColumnValue::String(format!("task{index}"))),
2497                            Some(ColumnValue::String("p0".to_owned())),
2498                            Some(ColumnValue::String(format!("base{index}"))),
2499                        ],
2500                        1,
2501                    )
2502                    .unwrap();
2503            }
2504            instance.rebuild_overlay_if_dirty();
2505            instance.overlay_rebuild_count.set(0);
2506        }
2507        for index in 0..100 {
2508            let mutations = (0..1 + index % 4)
2509                .map(|offset| {
2510                    let id = format!("task{}", (index + offset) % 24);
2511                    if (index + offset) % 7 == 0 {
2512                        Mutation::Delete {
2513                            table: "tasks".to_owned(),
2514                            row_id: id,
2515                            base_version: None,
2516                        }
2517                    } else {
2518                        Mutation::Upsert {
2519                            table: "tasks".to_owned(),
2520                            values: Map::from_iter([
2521                                ("id".to_owned(), Value::from(id)),
2522                                (
2523                                    "project_id".to_owned(),
2524                                    Value::from(format!("p{}", index % 2)),
2525                                ),
2526                                (
2527                                    "title".to_owned(),
2528                                    Value::from(format!("needle{}", (index + offset) % 24)),
2529                                ),
2530                            ]),
2531                            base_version: None,
2532                        }
2533                    }
2534                })
2535                .collect::<Vec<_>>();
2536            reference.overlay_dirty.set(true);
2537            incremental.mutate(mutations.clone()).unwrap();
2538            reference.mutate(mutations).unwrap();
2539            for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2540                "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
2541                assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
2542            }
2543            assert_eq!(incremental.local_revision(), reference.local_revision());
2544            assert_eq!(
2545                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2546                serde_json::to_value(reference.drain_change_batches()).unwrap()
2547            );
2548        }
2549        assert_eq!(incremental.outbox.len(), 100);
2550        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2551        assert_eq!(reference.overlay_rebuild_count.get(), 100);
2552    }
2553
2554    #[test]
2555    fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
2556        for fail_at in ["outbox", "revision"] {
2557            let mut client = client();
2558            let values = Map::from_iter([
2559                ("id".to_owned(), Value::from("t1")),
2560                ("project_id".to_owned(), Value::from("p1")),
2561            ]);
2562            let mutation = Mutation::Upsert {
2563                table: "tasks".to_owned(),
2564                values,
2565                base_version: None,
2566            };
2567            let trigger = if fail_at == "outbox" {
2568                "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
2569            } else {
2570                format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
2571            };
2572            client.conn.execute_batch(&trigger).unwrap();
2573            let revision = client.local_revision();
2574            assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
2575            assert_eq!(client.local_revision(), revision);
2576            assert!(client.outbox.is_empty());
2577            assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2578            assert_eq!(
2579                client
2580                    .conn
2581                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
2582                        .get::<_, i64>(0))
2583                    .unwrap(),
2584                0
2585            );
2586            assert!(client.drain_change_batches().is_empty());
2587            assert!(!client.overlay_dirty.get());
2588            client
2589                .conn
2590                .execute_batch("DROP TRIGGER fail_append")
2591                .unwrap();
2592            client.mutate(vec![mutation]).unwrap();
2593            assert_eq!(client.outbox.len(), 1);
2594            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
2595        }
2596    }
2597
2598    #[test]
2599    fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
2600        let mut client = client();
2601        let tables = vec!["tasks".to_owned(), "missing".to_owned()];
2602        let values = Map::from_iter([
2603            ("id".to_owned(), Value::from("t1")),
2604            ("project_id".to_owned(), Value::from("p1")),
2605        ]);
2606        let payload = encode_row_json(
2607            client.schema.table("tasks").unwrap(),
2608            "t1",
2609            &values,
2610            &client.encryption,
2611        )
2612        .unwrap();
2613        let first = ssp2::model::Change {
2614            table_index: 0,
2615            row_id: "t1".to_owned(),
2616            op: Op::Upsert,
2617            row_version: Some(1),
2618            scopes: vec![("project_id".to_owned(), "p1".to_owned())],
2619            row: Some(payload),
2620        };
2621        let mut invalid = first.clone();
2622        invalid.table_index = 1;
2623        let revision = client.local_revision();
2624        client.begin_observation("failed_mirror").unwrap();
2625        assert!(client
2626            .apply_commit_changes(&tables, &[first, invalid])
2627            .is_err());
2628        client.rollback_observation("failed_mirror");
2629        client.rebuild_overlay_if_dirty();
2630        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2631        assert_eq!(
2632            client
2633                .conn
2634                .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
2635                    .get::<_, i64>(
2636                    0
2637                ))
2638                .unwrap(),
2639            0
2640        );
2641        assert_eq!(client.local_revision(), revision);
2642        assert!(client.drain_change_batches().is_empty());
2643    }
2644
2645    #[test]
2646    fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
2647        for failure in ["journal", "revision", "commit", "later-frame"] {
2648            let mut client = client();
2649            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2650            for id in ["first", "second"] {
2651                client
2652                    .mutate(vec![Mutation::Upsert {
2653                        table: "tasks".to_owned(),
2654                        values: Map::from_iter([
2655                            ("id".to_owned(), Value::from(id)),
2656                            ("project_id".to_owned(), Value::from("p1")),
2657                        ]),
2658                        base_version: None,
2659                    }])
2660                    .unwrap();
2661            }
2662            let (_, meta) = client.build_request(false);
2663            // A malicious or stale response must not drain an unsent local commit.
2664            client
2665                .mutate(vec![Mutation::Upsert {
2666                    table: "tasks".to_owned(),
2667                    values: Map::from_iter([
2668                        ("id".to_owned(), Value::from("later")),
2669                        ("project_id".to_owned(), Value::from("p1")),
2670                    ]),
2671                    base_version: None,
2672                }])
2673                .unwrap();
2674            let ids = client.pending_commit_ids();
2675            let rows = client
2676                .query("SELECT * FROM tasks ORDER BY id", &[])
2677                .unwrap();
2678            let revision = client.local_revision();
2679            client.drain_change_batches();
2680            if failure != "later-frame" {
2681                client.conn.execute_batch(match failure {
2682                    "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",
2683                    "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",
2684                    _ => "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",
2685                }).unwrap();
2686            }
2687            let mut response = Message {
2688                wire_version: WIRE_VERSION,
2689                msg_kind: MsgKind::Response,
2690                frames: vec![Frame::RespHeader {
2691                    required_schema_version: None,
2692                    latest_schema_version: None,
2693                    log_epoch: Some("epoch-1".to_owned()),
2694                    reset_required: Some(false),
2695                }],
2696            };
2697            for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
2698                response.frames.push(Frame::PushResult {
2699                    client_commit_id: id.to_owned(),
2700                    status: PushStatus::Applied,
2701                    commit_seq: Some(1),
2702                    results: vec![OpResult::Applied { op_index: 0 }],
2703                });
2704            }
2705            if failure == "later-frame" {
2706                response.frames.push(Frame::Error {
2707                    code: "sync.invalid_request".to_owned(),
2708                    message: "later error".to_owned(),
2709                    category: "protocol".to_owned(),
2710                    retryable: false,
2711                    recommended_action: "retry".to_owned(),
2712                    details: None,
2713                });
2714            }
2715            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2716            let outcome = client.process_response(&mut transport, response.clone(), &meta);
2717            assert!(
2718                matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
2719            );
2720            if failure == "later-frame" {
2721                assert_eq!(client.pending_commit_ids(), ids[2..]);
2722                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2723                assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
2724                let batches = client.drain_change_batches();
2725                assert_eq!(batches.len(), 1);
2726                assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
2727                assert_eq!(client.local_revision(), revision + 1);
2728                response.frames.pop();
2729            } else {
2730                assert_eq!(client.pending_commit_ids(), ids);
2731                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2732                assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
2733                assert_eq!(client.local_revision(), revision);
2734                assert!(client.drain_change_batches().is_empty());
2735                assert_eq!(
2736                    client
2737                        .query("SELECT * FROM tasks ORDER BY id", &[])
2738                        .unwrap(),
2739                    rows
2740                );
2741                client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
2742            }
2743            for frame in &mut response.frames {
2744                if let Frame::PushResult { status, .. } = frame {
2745                    *status = PushStatus::Cached;
2746                }
2747            }
2748            let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
2749            else {
2750                panic!("retry failed");
2751            };
2752            assert_eq!(
2753                report.applied,
2754                if failure == "later-frame" {
2755                    vec![]
2756                } else {
2757                    ids[..2].to_vec()
2758                }
2759            );
2760            assert_eq!(client.pending_commit_ids(), ids[2..]);
2761            let batches = client.drain_change_batches();
2762            assert_eq!(batches.len(), usize::from(failure != "later-frame"));
2763            if let Some(batch) = batches.first() {
2764                assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
2765            }
2766            assert_eq!(client.local_revision(), revision + 1);
2767        }
2768    }
2769
2770    #[test]
2771    fn failed_push_result_preserves_memory_disk_and_retry_identity() {
2772        for failure in ["revision", "journal", "commit"] {
2773            for status in ["applied", "cached", "rejected", "conflict"] {
2774                let mut client = client();
2775                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2776                for id in ["first", "later"] {
2777                    client
2778                        .mutate(vec![Mutation::Upsert {
2779                            table: "tasks".to_owned(),
2780                            values: Map::from_iter([
2781                                ("id".to_owned(), Value::from(id)),
2782                                ("project_id".to_owned(), Value::from("p1")),
2783                            ]),
2784                            base_version: None,
2785                        }])
2786                        .unwrap();
2787                }
2788                let (_, meta) = client.build_request(false);
2789                let ids = client.pending_commit_ids();
2790                let rows = client
2791                    .query("SELECT * FROM tasks ORDER BY id", &[])
2792                    .unwrap();
2793                let revision = client.local_revision();
2794                client.drain_change_batches();
2795                let (push_status, results) = match status {
2796                    "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
2797                    "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
2798                    "conflict" => (
2799                        PushStatus::Rejected,
2800                        vec![OpResult::Conflict {
2801                            op_index: 0,
2802                            code: "sync.version_conflict".to_owned(),
2803                            message: "conflict".to_owned(),
2804                            server_version: 1,
2805                            server_row: encode_row_json(
2806                                client.schema.table("tasks").unwrap(),
2807                                "first",
2808                                &Map::from_iter([
2809                                    ("id".to_owned(), Value::from("first")),
2810                                    ("project_id".to_owned(), Value::from("p1")),
2811                                ]),
2812                                &client.encryption,
2813                            )
2814                            .unwrap(),
2815                            conflict_columns: Vec::new(),
2816                        }],
2817                    ),
2818                    _ => (
2819                        PushStatus::Rejected,
2820                        vec![OpResult::Error {
2821                            op_index: 0,
2822                            code: "sync.validation_failed".to_owned(),
2823                            message: "rejected".to_owned(),
2824                            retryable: false,
2825                        }],
2826                    ),
2827                };
2828                let response = Message {
2829                    wire_version: WIRE_VERSION,
2830                    msg_kind: MsgKind::Response,
2831                    frames: vec![
2832                        Frame::RespHeader {
2833                            required_schema_version: None,
2834                            latest_schema_version: None,
2835                            log_epoch: Some("epoch-1".to_owned()),
2836                            reset_required: Some(false),
2837                        },
2838                        Frame::PushResult {
2839                            client_commit_id: ids[0].clone(),
2840                            status: push_status,
2841                            commit_seq: Some(1),
2842                            results,
2843                        },
2844                    ],
2845                };
2846                client.conn.execute_batch(match failure {
2847                    "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
2848                    "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
2849                    _ => "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",
2850                }).unwrap();
2851                let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2852                let outcome = client.process_response(&mut transport, response.clone(), &meta);
2853                assert_eq!(
2854                    client.pending_commit_ids(),
2855                    ids,
2856                    "{failure}/{status}: pending identity"
2857                );
2858                assert_eq!(
2859                    client.local_revision(),
2860                    revision,
2861                    "{failure}/{status}: revision"
2862                );
2863                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2864                assert!(client.conflicts().is_empty());
2865                assert!(client.rejections().is_empty());
2866                assert!(client.drain_change_batches().is_empty());
2867                assert_eq!(
2868                    client
2869                        .query("SELECT * FROM tasks ORDER BY id", &[])
2870                        .unwrap(),
2871                    rows
2872                );
2873                assert!(
2874                    matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
2875                    "{failure}/{status}: failed round"
2876                );
2877                client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
2878                assert!(matches!(
2879                    client.process_response(&mut transport, response, &meta),
2880                    SyncOutcome::Ok(_)
2881                ));
2882                assert_eq!(client.pending_commit_ids(), ids[1..]);
2883                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2884                assert_eq!(client.drain_change_batches().len(), 1);
2885            }
2886        }
2887    }
2888
2889    #[test]
2890    fn mixed_push_results_reconcile_and_prune_once_per_response() {
2891        let mut client = client();
2892        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2893        for index in 0..4 {
2894            client
2895                .mutate(vec![Mutation::Upsert {
2896                    table: "tasks".to_owned(),
2897                    values: Map::from_iter([
2898                        ("id".to_owned(), Value::from(format!("task-{index}"))),
2899                        ("project_id".to_owned(), Value::from("project-1")),
2900                    ]),
2901                    base_version: None,
2902                }])
2903                .expect("queue commit");
2904        }
2905
2906        let (_, request_meta) = client.build_request(false);
2907        let ids = &request_meta.pushed_ids;
2908        assert_eq!(ids.len(), 4);
2909        let response = Message {
2910            wire_version: WIRE_VERSION,
2911            msg_kind: MsgKind::Response,
2912            frames: vec![
2913                Frame::RespHeader {
2914                    required_schema_version: None,
2915                    latest_schema_version: None,
2916                    log_epoch: Some("epoch-1".to_owned()),
2917                    reset_required: Some(false),
2918                },
2919                Frame::PushResult {
2920                    client_commit_id: ids[0].clone(),
2921                    status: PushStatus::Applied,
2922                    commit_seq: Some(1),
2923                    results: vec![OpResult::Applied { op_index: 0 }],
2924                },
2925                Frame::PushResult {
2926                    client_commit_id: ids[1].clone(),
2927                    status: PushStatus::Cached,
2928                    commit_seq: Some(2),
2929                    results: vec![OpResult::Applied { op_index: 0 }],
2930                },
2931                Frame::PushResult {
2932                    client_commit_id: ids[2].clone(),
2933                    status: PushStatus::Rejected,
2934                    commit_seq: None,
2935                    results: vec![OpResult::Error {
2936                        op_index: 0,
2937                        code: "sync.validation_failed".to_owned(),
2938                        message: "rejected".to_owned(),
2939                        retryable: false,
2940                    }],
2941                },
2942                Frame::PushResult {
2943                    client_commit_id: ids[3].clone(),
2944                    status: PushStatus::Rejected,
2945                    commit_seq: None,
2946                    results: vec![OpResult::Error {
2947                        op_index: 0,
2948                        code: "sync.idempotency_cache_miss".to_owned(),
2949                        message: "retry".to_owned(),
2950                        retryable: true,
2951                    }],
2952                },
2953            ],
2954        };
2955        let mut transport =
2956            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
2957
2958        client.overlay_rebuild_count.set(0);
2959        client.outcome_prune_count.set(0);
2960        let outcome = client.process_response(&mut transport, response, &request_meta);
2961        let SyncOutcome::Ok(report) = outcome else {
2962            panic!("mixed push-result response failed");
2963        };
2964
2965        assert_eq!(report.applied, ids[..2]);
2966        assert_eq!(report.rejected, ids[2..3]);
2967        assert_eq!(report.retryable, ids[3..4]);
2968        assert_eq!(client.pending_commit_ids(), ids[3..4]);
2969        assert_eq!(
2970            client
2971                .query("SELECT id FROM tasks ORDER BY id", &[])
2972                .expect("query visible overlay"),
2973            vec![Map::from_iter([("id".to_owned(), Value::from("task-3"))])]
2974        );
2975        assert_eq!(client.overlay_rebuild_count.get(), 1);
2976        assert_eq!(client.outcome_prune_count.get(), 1);
2977    }
2978
2979    #[test]
2980    fn secondary_unique_collision_preserves_existing_synced_row() {
2981        let client = SyncClient::new(
2982            "unique-upsert-test".to_owned(),
2983            &json!({
2984                "version": 1,
2985                "tables": [{
2986                    "name": "tasks",
2987                    "primaryKey": "id",
2988                    "columns": [
2989                        { "name": "id", "type": "string", "nullable": false },
2990                        { "name": "project_id", "type": "string", "nullable": false },
2991                        { "name": "title", "type": "string", "nullable": false }
2992                    ],
2993                    "scopes": [{ "pattern": "project:{project_id}" }],
2994                    "indexes": [{
2995                        "name": "tasks_by_project_title",
2996                        "columns": ["project_id", "title"],
2997                        "unique": true
2998                    }]
2999                }]
3000            }),
3001            ClientLimits::default(),
3002        )
3003        .expect("test client");
3004        let table = client.schema.table("tasks").expect("tasks table");
3005        let sql = client.insert_row_sql(&base_table("tasks"), table);
3006
3007        client
3008            .conn
3009            .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
3010            .expect("insert first row");
3011        client
3012            .conn
3013            .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
3014            .expect("update same primary key");
3015        client
3016            .conn
3017            .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
3018            .expect("insert second row");
3019        assert!(client
3020            .conn
3021            .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
3022            .is_err());
3023
3024        let rows = client
3025            .conn
3026            .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
3027            .expect("prepare rows")
3028            .query_map([], |row| {
3029                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3030            })
3031            .expect("query rows")
3032            .collect::<Result<Vec<_>, _>>()
3033            .expect("collect rows");
3034        assert_eq!(
3035            rows,
3036            vec![
3037                ("t1".to_owned(), "updated".to_owned()),
3038                ("t2".to_owned(), "original".to_owned())
3039            ]
3040        );
3041    }
3042
3043    #[test]
3044    fn reopening_active_subscriptions_emits_a_catch_up_intent() {
3045        let path = std::env::temp_dir().join(format!(
3046            "syncular-startup-intent-{}.db",
3047            uuid::Uuid::new_v4()
3048        ));
3049        let schema = json!({
3050            "version": 1,
3051            "tables": [{
3052                "name": "tasks",
3053                "primaryKey": "id",
3054                "columns": [
3055                    { "name": "id", "type": "string", "nullable": false },
3056                    { "name": "project_id", "type": "string", "nullable": false }
3057                ],
3058                "scopes": [{ "pattern": "project:{project_id}" }]
3059            }]
3060        });
3061
3062        {
3063            let mut first = SyncClient::open_path_with_identity(
3064                None,
3065                &schema,
3066                ClientLimits::default(),
3067                path.to_str().expect("UTF-8 temp path"),
3068            )
3069            .expect("first open");
3070            first
3071                .set_window(
3072                    &WindowBase {
3073                        table: "tasks".to_owned(),
3074                        variable: "project_id".to_owned(),
3075                        fixed_scopes: Vec::new(),
3076                        params: None,
3077                    },
3078                    &["persisted".to_owned()],
3079                )
3080                .expect("persist window");
3081        }
3082
3083        let mut reopened = SyncClient::open_path_with_identity(
3084            None,
3085            &schema,
3086            ClientLimits::default(),
3087            path.to_str().expect("UTF-8 temp path"),
3088        )
3089        .expect("reopen");
3090        assert!(reopened.sync_needed());
3091        assert!(matches!(
3092            reopened.drain_sync_intents().as_slice(),
3093            [SyncIntent::Interactive]
3094        ));
3095        drop(reopened);
3096        std::fs::remove_file(path).expect("remove temp database");
3097    }
3098
3099    #[test]
3100    fn reopening_preserves_immutable_subscription_identity_and_progress() {
3101        let path = std::env::temp_dir().join(format!(
3102            "syncular-subscription-identity-{}.db",
3103            uuid::Uuid::new_v4()
3104        ));
3105        let schema = json!({
3106            "version": 1,
3107            "tables": [
3108                {
3109                    "name": "tasks",
3110                    "primaryKey": "id",
3111                    "columns": [
3112                        { "name": "id", "type": "string", "nullable": false },
3113                        { "name": "project_id", "type": "string", "nullable": false }
3114                    ],
3115                    "scopes": [{ "pattern": "project:{project_id}" }]
3116                },
3117                {
3118                    "name": "docs",
3119                    "primaryKey": "id",
3120                    "columns": [
3121                        { "name": "id", "type": "string", "nullable": false },
3122                        { "name": "org_id", "type": "string", "nullable": false },
3123                        { "name": "project_id", "type": "string", "nullable": false }
3124                    ],
3125                    "scopes": [
3126                        { "pattern": "org:{org_id}" },
3127                        { "pattern": "project:{projectId}", "column": "project_id" }
3128                    ]
3129                }
3130            ]
3131        });
3132
3133        {
3134            let mut first = SyncClient::open_path_with_identity(
3135                None,
3136                &schema,
3137                ClientLimits::default(),
3138                path.to_str().expect("UTF-8 temp path"),
3139            )
3140            .expect("first open");
3141            first
3142                .subscribe(
3143                    "stable-subscription".to_owned(),
3144                    "tasks".to_owned(),
3145                    vec![(
3146                        "project_id".to_owned(),
3147                        vec!["p2".to_owned(), "p1".to_owned()],
3148                    )],
3149                    Some(r#"{"view":"v1"}"#.to_owned()),
3150                )
3151                .expect("persist subscription");
3152            let persisted = {
3153                let subscription = first
3154                    .subs
3155                    .iter_mut()
3156                    .find(|subscription| subscription.id == "stable-subscription")
3157                    .expect("subscription");
3158                subscription.cursor = 41;
3159                subscription.bootstrap_state = Some("resume-token".to_owned());
3160                subscription.effective = Some(vec![(
3161                    "project_id".to_owned(),
3162                    vec!["p1".to_owned(), "p2".to_owned()],
3163                )]);
3164                subscription.synced_once = true;
3165                subscription.clone()
3166            };
3167            first.persist_sub(&persisted).unwrap();
3168        }
3169
3170        let mut reopened = SyncClient::open_path_with_identity(
3171            None,
3172            &schema,
3173            ClientLimits::default(),
3174            path.to_str().expect("UTF-8 temp path"),
3175        )
3176        .expect("reopen");
3177        let progress = reopened
3178            .subscription_state("stable-subscription")
3179            .expect("persisted state");
3180        assert_eq!(progress.cursor, 41);
3181        assert!(progress.has_resume_token);
3182
3183        reopened
3184            .subscribe(
3185                "stable-subscription".to_owned(),
3186                "tasks".to_owned(),
3187                vec![(
3188                    "project_id".to_owned(),
3189                    vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
3190                )],
3191                Some(r#"{"view":"v1"}"#.to_owned()),
3192            )
3193            .expect("canonical intent is idempotent");
3194        assert_eq!(
3195            reopened
3196                .subscription_state("stable-subscription")
3197                .expect("unchanged state")
3198                .cursor,
3199            progress.cursor
3200        );
3201
3202        for (table, scopes, params) in [
3203            (
3204                "tasks",
3205                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3206                Some(r#"{"view":"v1"}"#.to_owned()),
3207            ),
3208            (
3209                "tasks",
3210                vec![(
3211                    "project_id".to_owned(),
3212                    vec!["p2".to_owned(), "p1".to_owned()],
3213                )],
3214                Some(r#"{"view":"v2"}"#.to_owned()),
3215            ),
3216            (
3217                "docs",
3218                vec![
3219                    ("org_id".to_owned(), vec!["o1".to_owned()]),
3220                    ("projectId".to_owned(), vec!["p1".to_owned()]),
3221                ],
3222                Some(r#"{"view":"v1"}"#.to_owned()),
3223            ),
3224        ] {
3225            let error = reopened
3226                .subscribe(
3227                    "stable-subscription".to_owned(),
3228                    table.to_owned(),
3229                    scopes,
3230                    params,
3231                )
3232                .expect_err("identity rebind must fail");
3233            assert!(error.starts_with("client.subscription_intent_mismatch:"));
3234            assert_eq!(
3235                reopened
3236                    .subscription_state("stable-subscription")
3237                    .expect("unchanged state")
3238                    .cursor,
3239                progress.cursor
3240            );
3241        }
3242
3243        drop(reopened);
3244        std::fs::remove_file(path).expect("remove temp database");
3245    }
3246
3247    #[test]
3248    fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
3249        let path = std::env::temp_dir().join(format!(
3250            "syncular-satisfied-schema-floor-{}.db",
3251            uuid::Uuid::new_v4()
3252        ));
3253        let schema = json!({
3254            "version": 23,
3255            "tables": [{
3256                "name": "tasks",
3257                "primaryKey": "id",
3258                "columns": [
3259                    { "name": "id", "type": "string", "nullable": false },
3260                    { "name": "project_id", "type": "string", "nullable": false }
3261                ],
3262                "scopes": [{ "pattern": "project:{project_id}" }]
3263            }]
3264        });
3265
3266        {
3267            let mut first = SyncClient::open_path_with_identity(
3268                None,
3269                &schema,
3270                ClientLimits::default(),
3271                path.to_str().expect("UTF-8 temp path"),
3272            )
3273            .expect("first open");
3274            first
3275                .subscribe(
3276                    "tasks".to_owned(),
3277                    "tasks".to_owned(),
3278                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3279                    None,
3280                )
3281                .expect("persist subscription");
3282            first.set_schema_floor(Some(SchemaFloor {
3283                required_schema_version: Some(22),
3284                latest_schema_version: Some(22),
3285            }));
3286        }
3287
3288        let mut reopened = SyncClient::open_path_with_identity(
3289            None,
3290            &schema,
3291            ClientLimits::default(),
3292            path.to_str().expect("UTF-8 temp path"),
3293        )
3294        .expect("reopen");
3295        assert!(reopened.schema_floor().is_none());
3296        assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
3297        assert!(reopened.sync_needed());
3298        assert!(matches!(
3299            reopened.drain_sync_intents().as_slice(),
3300            [SyncIntent::Interactive]
3301        ));
3302        drop(reopened);
3303        std::fs::remove_file(path).expect("remove temp database");
3304    }
3305
3306    #[test]
3307    fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
3308        let path = std::env::temp_dir().join(format!(
3309            "syncular-unsatisfied-schema-floor-{}.db",
3310            uuid::Uuid::new_v4()
3311        ));
3312        let schema = json!({
3313            "version": 1,
3314            "tables": [{
3315                "name": "tasks",
3316                "primaryKey": "id",
3317                "columns": [
3318                    { "name": "id", "type": "string", "nullable": false },
3319                    { "name": "project_id", "type": "string", "nullable": false }
3320                ],
3321                "scopes": [{ "pattern": "project:{project_id}" }]
3322            }]
3323        });
3324
3325        {
3326            let mut first = SyncClient::open_path_with_identity(
3327                None,
3328                &schema,
3329                ClientLimits::default(),
3330                path.to_str().expect("UTF-8 temp path"),
3331            )
3332            .expect("first open");
3333            first
3334                .subscribe(
3335                    "tasks".to_owned(),
3336                    "tasks".to_owned(),
3337                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3338                    None,
3339                )
3340                .expect("persist subscription");
3341            first.set_schema_floor(Some(SchemaFloor {
3342                required_schema_version: Some(2),
3343                latest_schema_version: Some(2),
3344            }));
3345        }
3346
3347        let reopened = SyncClient::open_path_with_identity(
3348            None,
3349            &schema,
3350            ClientLimits::default(),
3351            path.to_str().expect("UTF-8 temp path"),
3352        )
3353        .expect("reopen");
3354        assert_eq!(
3355            reopened.schema_floor(),
3356            Some(&SchemaFloor {
3357                required_schema_version: Some(2),
3358                latest_schema_version: Some(2),
3359            })
3360        );
3361        assert!(!reopened.sync_needed());
3362        drop(reopened);
3363        std::fs::remove_file(path).expect("remove temp database");
3364    }
3365
3366    #[test]
3367    fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
3368        let path = std::env::temp_dir().join(format!(
3369            "syncular-indexed-column-bump-{}.db",
3370            uuid::Uuid::new_v4()
3371        ));
3372        let old_schema = json!({
3373            "version": 1,
3374            "tables": [
3375                {
3376                    "name": "tasks",
3377                    "primaryKey": "id",
3378                    "columns": [
3379                        { "name": "id", "type": "string", "nullable": false },
3380                        { "name": "project_id", "type": "string", "nullable": false }
3381                    ],
3382                    "scopes": [{ "pattern": "project:{project_id}" }]
3383                },
3384                {
3385                    "name": "legacy",
3386                    "primaryKey": "id",
3387                    "columns": [
3388                        { "name": "id", "type": "string", "nullable": false },
3389                        { "name": "project_id", "type": "string", "nullable": false }
3390                    ],
3391                    "scopes": [{ "pattern": "project:{project_id}" }]
3392                }
3393            ]
3394        });
3395        {
3396            let mut first = SyncClient::open_path_with_identity(
3397                None,
3398                &old_schema,
3399                ClientLimits::default(),
3400                path.to_str().expect("UTF-8 temp path"),
3401            )
3402            .expect("open old schema");
3403            first
3404                .subscribe(
3405                    "legacy-sub".to_owned(),
3406                    "legacy".to_owned(),
3407                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3408                    None,
3409                )
3410                .expect("persist legacy subscription");
3411        }
3412
3413        let new_schema = json!({
3414            "version": 2,
3415            "tables": [{
3416                "name": "tasks",
3417                "primaryKey": "id",
3418                "columns": [
3419                    { "name": "id", "type": "string", "nullable": false },
3420                    { "name": "project_id", "type": "string", "nullable": false },
3421                    { "name": "facility_membership_id", "type": "string", "nullable": true }
3422                ],
3423                "scopes": [{ "pattern": "project:{project_id}" }],
3424                "indexes": [{
3425                    "name": "tasks_by_membership",
3426                    "columns": ["project_id", "facility_membership_id"],
3427                    "unique": false
3428                }]
3429            }]
3430        });
3431        let upgraded = SyncClient::open_path_with_identity(
3432            None,
3433            &new_schema,
3434            ClientLimits::default(),
3435            path.to_str().expect("UTF-8 temp path"),
3436        )
3437        .expect("open upgraded schema");
3438        let columns = upgraded
3439            .conn
3440            .prepare("PRAGMA table_info(tasks)")
3441            .expect("prepare columns")
3442            .query_map([], |row| row.get::<_, String>(1))
3443            .expect("query columns")
3444            .collect::<Result<Vec<_>, _>>()
3445            .expect("collect columns");
3446        assert!(columns.contains(&"facility_membership_id".to_owned()));
3447        let index_count: i64 = upgraded
3448            .conn
3449            .query_row(
3450                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
3451                [],
3452                |row| row.get(0),
3453            )
3454            .expect("query index");
3455        assert_eq!(index_count, 1);
3456        assert!(upgraded.subscription_state("legacy-sub").is_none());
3457        drop(upgraded);
3458        std::fs::remove_file(path).expect("remove temp database");
3459    }
3460
3461    #[test]
3462    fn migrates_pre_envelope_outcome_journal_additively() {
3463        let path = std::env::temp_dir().join(format!(
3464            "syncular-outcome-migration-{}.db",
3465            uuid::Uuid::new_v4()
3466        ));
3467        let conn = Connection::open(&path).expect("open legacy database");
3468        conn.execute_batch(
3469            "CREATE TABLE _syncular_commit_outcomes (
3470               seq INTEGER PRIMARY KEY AUTOINCREMENT,
3471               client_commit_id TEXT NOT NULL UNIQUE,
3472               status TEXT NOT NULL,
3473               recorded_at_ms INTEGER NOT NULL,
3474               results_json TEXT NOT NULL,
3475               resolution TEXT NOT NULL DEFAULT 'active',
3476               resolved_at_ms INTEGER,
3477               replacement_client_commit_id TEXT);",
3478        )
3479        .expect("create legacy outcome journal");
3480        drop(conn);
3481        let schema = json!({
3482            "version": 1,
3483            "tables": [{
3484                "name": "tasks",
3485                "primaryKey": "id",
3486                "columns": [
3487                    { "name": "id", "type": "string", "nullable": false },
3488                    { "name": "project_id", "type": "string", "nullable": false }
3489                ],
3490                "scopes": [{ "pattern": "project:{project_id}" }]
3491            }]
3492        });
3493        let client = SyncClient::open_path(
3494            "migration-native".to_owned(),
3495            &schema,
3496            ClientLimits::default(),
3497            path.to_str().expect("UTF-8 temp path"),
3498        )
3499        .expect("migrate database");
3500        let has_operations = client
3501            .conn
3502            .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
3503            .expect("prepare table info")
3504            .query_map([], |row| row.get::<_, String>(1))
3505            .expect("query table info")
3506            .filter_map(Result::ok)
3507            .any(|column| column == "operations_json");
3508        assert!(has_operations);
3509        drop(client);
3510        std::fs::remove_file(path).expect("remove temp database");
3511    }
3512
3513    #[test]
3514    fn retained_rejection_survives_file_reopen_and_security_purge() {
3515        let path =
3516            std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
3517        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
3518            { "name": "id", "type": "string", "nullable": false },
3519            { "name": "project_id", "type": "string", "nullable": false },
3520            { "name": "title", "type": "string", "nullable": false }
3521        ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
3522        {
3523            let mut first = SyncClient::open_path(
3524                "retained-native".to_owned(),
3525                &schema,
3526                ClientLimits::default(),
3527                path.to_str().unwrap(),
3528            )
3529            .unwrap();
3530            first
3531                .write_base_row(
3532                    "tasks",
3533                    &vec![
3534                        Some(ColumnValue::String("t1".into())),
3535                        Some(ColumnValue::String("p1".into())),
3536                        Some(ColumnValue::String("server".into())),
3537                    ],
3538                    1,
3539                )
3540                .unwrap();
3541            first.rebuild_overlay();
3542            let operation = OutboxOp {
3543                table: "tasks".into(),
3544                row_id: "t1".into(),
3545                upsert: true,
3546                base_version: Some(1),
3547                values: Some(Map::from_iter([
3548                    ("id".into(), json!("t1")),
3549                    ("title".into(), json!("mine")),
3550                ])),
3551            };
3552            first
3553                .retain_failed_commit("retained", std::slice::from_ref(&operation))
3554                .unwrap();
3555            first
3556                .persist_commit_outcome(
3557                    "retained",
3558                    CommitOutcomeStatus::Rejected,
3559                    &[],
3560                    Some(&[operation]),
3561                )
3562                .unwrap();
3563            first.rebuild_overlay();
3564        }
3565        {
3566            let mut reopened = SyncClient::open_path(
3567                "retained-native".to_owned(),
3568                &schema,
3569                ClientLimits::default(),
3570                path.to_str().unwrap(),
3571            )
3572            .unwrap();
3573            let row = reopened
3574                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
3575                .unwrap();
3576            assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
3577            assert_eq!(
3578                reopened
3579                    .commit_outcome("retained")
3580                    .unwrap()
3581                    .unwrap()
3582                    .retained_rows
3583                    .unwrap()[0]
3584                    .server_row
3585                    .as_ref()
3586                    .unwrap()["title"],
3587                json!("server")
3588            );
3589            reopened
3590                .conn
3591                .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
3592                .unwrap();
3593            reopened.rebuild_overlay();
3594            assert!(reopened
3595                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
3596                .is_none());
3597            let retained = reopened.commit_outcome("retained").unwrap().unwrap();
3598            assert!(retained.retained_rows.is_some());
3599            assert_eq!(
3600                retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
3601                json!("mine")
3602            );
3603            reopened
3604                .purge_local_data(&LocalDataPurgeInput {
3605                    purge_id: "protected-purge".into(),
3606                    targets: vec![LocalDataPurgeTarget {
3607                        table: "tasks".into(),
3608                        selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
3609                    }],
3610                })
3611                .unwrap();
3612            assert!(reopened
3613                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
3614                .is_none());
3615            assert!(reopened
3616                .commit_outcome("retained")
3617                .unwrap()
3618                .unwrap()
3619                .retained_rows
3620                .is_none());
3621        }
3622        std::fs::remove_file(path).unwrap();
3623    }
3624
3625    #[test]
3626    fn durable_conflict_outcome_and_resolution_survive_reopen() {
3627        let path = std::env::temp_dir().join(format!(
3628            "syncular-durable-outcome-{}.db",
3629            uuid::Uuid::new_v4()
3630        ));
3631        let schema = json!({
3632            "version": 1,
3633            "tables": [{
3634                "name": "tasks",
3635                "primaryKey": "id",
3636                "columns": [
3637                    { "name": "id", "type": "string", "nullable": false },
3638                    { "name": "project_id", "type": "string", "nullable": false }
3639                ],
3640                "scopes": [{ "pattern": "project:{project_id}" }]
3641            }]
3642        });
3643        let conflict = ConflictRecord {
3644            client_commit_id: "losing-commit".to_owned(),
3645            op_index: 0,
3646            table: "tasks".to_owned(),
3647            row_id: "t1".to_owned(),
3648            code: "sync.version_conflict".to_owned(),
3649            message: "stale base version".to_owned(),
3650            server_version: 2,
3651            server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
3652            conflict_columns: vec!["project_id".to_owned()],
3653            operation: Some(CommitOperation {
3654                table: "tasks".to_owned(),
3655                row_id: "t1".to_owned(),
3656                op: "upsert".to_owned(),
3657                base_version: Some(1),
3658                values: None,
3659            }),
3660        };
3661        let failed_operations = vec![
3662            OutboxOp {
3663                upsert: true,
3664                table: "tasks".to_owned(),
3665                row_id: "t1".to_owned(),
3666                base_version: Some(1),
3667                values: None,
3668            },
3669            OutboxOp {
3670                upsert: true,
3671                table: "tasks".to_owned(),
3672                row_id: "status-event-1".to_owned(),
3673                base_version: Some(0),
3674                values: None,
3675            },
3676        ];
3677
3678        {
3679            let mut first = SyncClient::open_path(
3680                "durable-native".to_owned(),
3681                &schema,
3682                ClientLimits::default(),
3683                path.to_str().expect("UTF-8 temp path"),
3684            )
3685            .expect("first open");
3686            first
3687                .begin_observation("test_outcome")
3688                .expect("begin outcome");
3689            first
3690                .persist_commit_outcome(
3691                    "losing-commit",
3692                    CommitOutcomeStatus::Conflict,
3693                    &[CommitOperationOutcome::Conflict {
3694                        conflict: conflict.clone(),
3695                    }],
3696                    Some(&failed_operations),
3697                )
3698                .expect("persist outcome");
3699            first.conflicts.push(conflict);
3700            first
3701                .finish_observation(
3702                    "test_outcome",
3703                    ChangeAccumulator {
3704                        conflicts: true,
3705                        outcomes: true,
3706                        ..ChangeAccumulator::default()
3707                    },
3708                )
3709                .expect("commit outcome");
3710        }
3711
3712        {
3713            let mut reopened = SyncClient::open_path(
3714                "durable-native".to_owned(),
3715                &schema,
3716                ClientLimits::default(),
3717                path.to_str().expect("UTF-8 temp path"),
3718            )
3719            .expect("reopen");
3720            assert_eq!(reopened.conflicts().len(), 1);
3721            assert_eq!(
3722                reopened.conflicts()[0].conflict_columns,
3723                vec!["project_id".to_owned()],
3724                "conflictColumns survives the journal round trip"
3725            );
3726            let outcome = reopened
3727                .commit_outcome("losing-commit")
3728                .expect("read outcome")
3729                .expect("outcome");
3730            assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
3731            let operations = outcome.operations.expect("aggregate envelope");
3732            assert_eq!(operations.len(), 2);
3733            assert_eq!(operations[1].row_id, "status-event-1");
3734            let resolved = reopened
3735                .resolve_commit_outcome(ResolveCommitOutcomeInput {
3736                    client_commit_id: "losing-commit".to_owned(),
3737                    resolution: CommitOutcomeResolution::ResolvedKeepServer,
3738                    replacement_client_commit_id: None,
3739                })
3740                .expect("resolve");
3741            assert_eq!(
3742                resolved.resolution,
3743                CommitOutcomeResolution::ResolvedKeepServer
3744            );
3745            assert!(reopened.conflicts().is_empty());
3746        }
3747
3748        let reopened = SyncClient::open_path(
3749            "durable-native".to_owned(),
3750            &schema,
3751            ClientLimits::default(),
3752            path.to_str().expect("UTF-8 temp path"),
3753        )
3754        .expect("second reopen");
3755        assert!(reopened.conflicts().is_empty());
3756        assert_eq!(
3757            reopened
3758                .commit_outcome("losing-commit")
3759                .expect("read outcome")
3760                .expect("outcome")
3761                .resolution,
3762            CommitOutcomeResolution::ResolvedKeepServer
3763        );
3764        drop(reopened);
3765        std::fs::remove_file(path).expect("remove temp database");
3766    }
3767
3768    #[test]
3769    fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
3770        let path =
3771            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
3772        let schema = json!({
3773            "version": 1,
3774            "tables": [{
3775                "name": "tasks",
3776                "primaryKey": "id",
3777                "columns": [
3778                    { "name": "id", "type": "string", "nullable": false },
3779                    { "name": "project_id", "type": "string", "nullable": false }
3780                ],
3781                "scopes": [{ "pattern": "project:{project_id}" }]
3782            }]
3783        });
3784        let mut client = SyncClient::open_path_with_identity(
3785            Some("sidecar-client".to_owned()),
3786            &schema,
3787            ClientLimits::default(),
3788            path.to_str().expect("UTF-8 temp path"),
3789        )
3790        .expect("open owner");
3791        let base = WindowBase {
3792            table: "tasks".to_owned(),
3793            variable: "project_id".to_owned(),
3794            fixed_scopes: Vec::new(),
3795            params: None,
3796        };
3797        client
3798            .set_window(&base, &["one".to_owned()])
3799            .expect("set window");
3800        client
3801            .mutate(vec![Mutation::Upsert {
3802                table: "tasks".to_owned(),
3803                values: Map::from_iter([
3804                    ("id".to_owned(), Value::from("t1")),
3805                    ("project_id".to_owned(), Value::from("one")),
3806                ]),
3807                base_version: None,
3808            }])
3809            .expect("local mutate");
3810
3811        let coverage = [WindowCoverage {
3812            base,
3813            units: vec!["one".to_owned(), "missing".to_owned()],
3814        }];
3815        let owner = client
3816            .query_snapshot(
3817                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3818                &[],
3819                &coverage,
3820                None,
3821            )
3822            .expect("owner snapshot");
3823        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
3824        let sidecar = reader
3825            .query_snapshot(
3826                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3827                &[],
3828                &coverage,
3829            )
3830            .expect("sidecar snapshot");
3831
3832        assert_eq!(sidecar.revision, owner.revision);
3833        assert_eq!(sidecar.rows, owner.rows);
3834        assert_eq!(
3835            serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
3836            serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
3837        );
3838        assert_eq!(sidecar.revision, "2");
3839        assert_eq!(sidecar.rows[0]["id"], "t1");
3840        assert_eq!(sidecar.rows[0]["server_version"], -1);
3841        assert!(!sidecar.coverage.complete);
3842        assert_eq!(sidecar.coverage.pending.len(), 1);
3843        assert_eq!(sidecar.coverage.missing.len(), 1);
3844
3845        drop(reader);
3846        drop(client);
3847        std::fs::remove_file(path).expect("remove temp database");
3848    }
3849
3850    #[test]
3851    fn owned_query_failures_classify_order_bound_and_clear() {
3852        let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
3853            rusqlite::ffi::Error::new(266),
3854            Some("private SQLite prose".to_owned()),
3855        ));
3856        assert_eq!(io.code, Some("client.storage_io"));
3857        assert_eq!(io.sqlite_code, Some(266));
3858        assert_eq!(io.message, "local SQLite storage I/O failed");
3859        let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
3860            rusqlite::ffi::Error::new(26),
3861            None,
3862        ));
3863        assert_eq!(corrupt.code, Some("client.storage_corrupt"));
3864
3865        let mut client = client();
3866        let generic = QueryReadFailure::from("private query prose".to_owned());
3867        client.record_query_read(
3868            QueryOwner {
3869                id: "first",
3870                tables: &["tasks", "tasks"],
3871            },
3872            Some(&generic),
3873        );
3874        client.record_query_read(
3875            QueryOwner {
3876                id: "second",
3877                tables: &["tasks"],
3878            },
3879            Some(&generic),
3880        );
3881        client.record_query_read(
3882            QueryOwner {
3883                id: "first",
3884                tables: &["docs"],
3885            },
3886            Some(&io),
3887        );
3888        let failures = &client
3889            .diagnostics_snapshot(&Default::default())
3890            .expect("diagnostics")
3891            .query_failures;
3892        assert_eq!(
3893            failures
3894                .iter()
3895                .map(|entry| entry.id.as_str())
3896                .collect::<Vec<_>>(),
3897            vec!["second", "first"]
3898        );
3899        assert_eq!(failures[1].tables, vec!["docs"]);
3900        assert_eq!(failures[1].code, "client.storage_io");
3901
3902        for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
3903            client.record_query_read(
3904                QueryOwner {
3905                    id: &format!("query-{index}"),
3906                    tables: &["tasks"],
3907                },
3908                Some(&generic),
3909            );
3910        }
3911        let failures = &client
3912            .diagnostics_snapshot(&Default::default())
3913            .expect("bounded diagnostics")
3914            .query_failures;
3915        assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
3916        assert_eq!(failures.last().expect("last failure").id, "query-256");
3917
3918        client
3919            .query_snapshot(
3920                "SELECT id FROM tasks",
3921                &[],
3922                &[],
3923                Some(QueryOwner {
3924                    id: "query-256",
3925                    tables: &["tasks"],
3926                }),
3927            )
3928            .expect("successful owned read");
3929        assert!(client
3930            .diagnostics_snapshot(&Default::default())
3931            .expect("cleared diagnostics")
3932            .query_failures
3933            .iter()
3934            .all(|entry| entry.id != "query-256"));
3935    }
3936
3937    #[test]
3938    fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
3939        let mut client = client();
3940        let image = Connection::open_in_memory().unwrap();
3941        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
3942            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3943            INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
3944            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3945            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
3946        let table = client.schema.table("tasks").unwrap().clone();
3947        let scopes = vec![("project_id".into(), vec!["p1".into()])];
3948        assert!(client
3949            .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
3950            .is_err());
3951        assert!(client.conn.is_autocommit());
3952        assert_eq!(
3953            client
3954                .query("SELECT count(*) AS n FROM tasks", &[])
3955                .unwrap()[0]["n"],
3956            1024
3957        );
3958        assert_eq!(client.drain_change_batches().len(), 1);
3959        image
3960            .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
3961            .unwrap();
3962        assert!(matches!(
3963            client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
3964            Ok(2050)
3965        ));
3966        assert_eq!(
3967            client
3968                .query("SELECT count(*) AS n FROM tasks", &[])
3969                .unwrap()[0]["n"],
3970            2050
3971        );
3972        assert_eq!(client.drain_change_batches().len(), 3);
3973    }
3974
3975    #[test]
3976    fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
3977        let mut client = client();
3978        let base = WindowBase {
3979            table: "tasks".into(),
3980            variable: "project_id".into(),
3981            fixed_scopes: Vec::new(),
3982            params: None,
3983        };
3984        client
3985            .set_window(&base, &["p1".into(), "p2".into()])
3986            .unwrap();
3987        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3988            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3989            INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
3990            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3991            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3992        client.drain_change_batches();
3993        assert!(client.set_window(&base, &["p2".into()]).is_err());
3994        assert!(client.conn.is_autocommit());
3995        assert_eq!(
3996            client
3997                .query("SELECT count(*) AS n FROM tasks", &[])
3998                .unwrap()[0]["n"],
3999            1027
4000        );
4001        assert_eq!(client.window_state(&base).units, vec!["p2"]);
4002        assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
4003        assert_eq!(client.drain_change_batches().len(), 1);
4004        client
4005            .conn
4006            .execute_batch("DROP TRIGGER interrupt_evict")
4007            .unwrap();
4008        client.drain_pending_evictions().unwrap();
4009        assert!(client.load_pending_evictions().unwrap().is_empty());
4010        assert_eq!(
4011            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
4012            "held"
4013        );
4014        assert_eq!(client.drain_change_batches().len(), 2);
4015    }
4016
4017    #[test]
4018    fn eviction_marker_failures_roll_back_window_changes() {
4019        let mut client = client();
4020        let base = WindowBase {
4021            table: "tasks".into(),
4022            variable: "project_id".into(),
4023            fixed_scopes: Vec::new(),
4024            params: None,
4025        };
4026        client.set_window(&base, &["p1".into()]).unwrap();
4027        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4028            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
4029            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
4030            CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4031        let revision = client.local_revision();
4032        assert!(client.set_window(&base, &[]).is_err());
4033        assert!(client.conn.is_autocommit());
4034        assert_eq!(client.local_revision(), revision);
4035        assert_eq!(
4036            client
4037                .query("SELECT count(*) AS n FROM tasks", &[])
4038                .unwrap()[0]["n"],
4039            2050
4040        );
4041        assert_eq!(client.window_state(&base).units, vec!["p1"]);
4042        client.conn.execute_batch("DROP TRIGGER fail_marker;
4043            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
4044        assert!(client.set_window(&base, &[]).is_err());
4045        client.conn.execute_batch("DROP TRIGGER interrupt_evict;
4046            CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4047        assert!(client.drain_pending_evictions().is_err());
4048        assert!(client.conn.is_autocommit());
4049        assert_eq!(
4050            client
4051                .query("SELECT count(*) AS n FROM tasks", &[])
4052                .unwrap()[0]["n"],
4053            2
4054        );
4055        assert!(client.set_window(&base, &["p1".into()]).is_err());
4056        assert!(client.conn.is_autocommit());
4057        assert!(client.window_state(&base).units.is_empty());
4058        assert!(client.subs.is_empty());
4059        assert_eq!(
4060            client
4061                .query(
4062                    "SELECT count(*) AS n FROM _syncular_window_pending_evict",
4063                    &[]
4064                )
4065                .unwrap()[0]["n"],
4066            1
4067        );
4068        client
4069            .conn
4070            .execute_batch("DROP TRIGGER fail_marker")
4071            .unwrap();
4072        client.set_window(&base, &["p1".into()]).unwrap();
4073        assert_eq!(client.window_state(&base).units, vec!["p1"]);
4074    }
4075
4076    #[test]
4077    fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
4078        let mut client = client();
4079        client
4080            .conn
4081            .execute(
4082                "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
4083                [],
4084            )
4085            .unwrap();
4086        assert_eq!(
4087            client.drain_pending_evictions().unwrap_err(),
4088            "sync.local_corrupt: invalid pending eviction scopes"
4089        );
4090        assert!(client.conn.is_autocommit());
4091        assert!(client.enqueue_startup_sync_if_needed().is_err());
4092    }
4093
4094    #[test]
4095    fn image_chunks_reject_null_primary_keys() {
4096        let mut client = client();
4097        let image = Connection::open_in_memory().unwrap();
4098        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4099            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
4100            INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
4101            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4102            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
4103        let table = client.schema.table("tasks").unwrap().clone();
4104        assert!(client
4105            .apply_sqlite_image(
4106                &image,
4107                &table,
4108                true,
4109                &[("project_id".into(), vec!["p1".into()])],
4110                1025,
4111                7,
4112                "digest"
4113            )
4114            .is_err());
4115        assert!(client.conn.is_autocommit());
4116        assert_eq!(
4117            client
4118                .query("SELECT count(*) AS n FROM tasks", &[])
4119                .unwrap()[0]["n"],
4120            0
4121        );
4122    }
4123
4124    #[test]
4125    fn import_chunks_with_unique_constraints_match_full_pending_replay() {
4126        for images in [false, true] {
4127            let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4128                "name":name,"primaryKey":"id","columns":[
4129                    {"name":"id","type":"string","nullable":false},
4130                    {"name":"project_id","type":"string","nullable":false},
4131                    {"name":"title","type":"string","nullable":false}],
4132                "scopes":[{"pattern":"project:{project_id}"}],
4133                "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
4134                "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4135            })))});
4136            let mut actual =
4137                SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
4138            let mut reference =
4139                SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
4140            let scopes = vec![("project_id".into(), vec!["p1".into()])];
4141            for client in [&mut actual, &mut reference] {
4142                client
4143                    .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
4144                    .unwrap();
4145                client.subs[0].effective = Some(scopes.clone());
4146                for (table, id, title) in [
4147                    ("tasks", "a", "original"),
4148                    ("tasks", "b", "occupied"),
4149                    ("tasks", "c", "deleted"),
4150                    ("notes", "n", "untouched"),
4151                ] {
4152                    client
4153                        .write_base_row(
4154                            table,
4155                            &vec![
4156                                Some(ColumnValue::String(id.into())),
4157                                Some(ColumnValue::String("p1".into())),
4158                                Some(ColumnValue::String(title.into())),
4159                            ],
4160                            1,
4161                        )
4162                        .unwrap();
4163                }
4164                client.rebuild_overlay_if_dirty();
4165                client
4166                    .mutate(vec![
4167                        Mutation::Upsert {
4168                            table: "tasks".into(),
4169                            values: Map::from_iter([
4170                                ("id".into(), Value::from("a")),
4171                                ("project_id".into(), Value::from("p1")),
4172                                ("title".into(), Value::from("available")),
4173                            ]),
4174                            base_version: None,
4175                        },
4176                        Mutation::Delete {
4177                            table: "tasks".into(),
4178                            row_id: "c".into(),
4179                            base_version: None,
4180                        },
4181                        Mutation::Upsert {
4182                            table: "tasks".into(),
4183                            values: Map::from_iter([
4184                                ("id".into(), Value::from("local")),
4185                                ("project_id".into(), Value::from("p1")),
4186                                ("title".into(), Value::from("local value")),
4187                            ]),
4188                            base_version: None,
4189                        },
4190                        Mutation::Upsert {
4191                            table: "notes".into(),
4192                            values: Map::from_iter([
4193                                ("id".into(), Value::from("n")),
4194                                ("project_id".into(), Value::from("p1")),
4195                                ("title".into(), Value::from("pending note")),
4196                            ]),
4197                            base_version: None,
4198                        },
4199                    ])
4200                    .unwrap();
4201            }
4202            let pending = actual.pending_commit_ids();
4203            actual.overlay_rebuild_count.set(0);
4204            for (id, title) in [
4205                ("b", "available"),
4206                ("b", "occupied"),
4207                ("a", "server original"),
4208                ("d", "local value"),
4209                ("d", "released"),
4210                ("c", "redelivered"),
4211            ] {
4212                let table = actual.schema.table("tasks").unwrap().clone();
4213                let values = vec![
4214                    Some(ColumnValue::String(id.into())),
4215                    Some(ColumnValue::String("p1".into())),
4216                    Some(ColumnValue::String(title.into())),
4217                ];
4218                if images {
4219                    let image = Connection::open_in_memory().unwrap();
4220                    image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4221                        CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4222                        INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4223                    image
4224                        .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
4225                        .unwrap();
4226                    assert!(matches!(
4227                        actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
4228                        Ok(1)
4229                    ));
4230                } else {
4231                    let segment = RowsSegment {
4232                        table: "tasks".into(),
4233                        schema_version: 1,
4234                        columns: table.wire_columns.clone(),
4235                        blocks: vec![vec![ssp2::segment::SegmentRow {
4236                            server_version: 2,
4237                            values: values.clone(),
4238                        }]],
4239                    };
4240                    assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
4241                }
4242                reference.write_base_row("tasks", &values, 2).unwrap();
4243                reference.rebuild_overlay();
4244                for table in ["tasks", "notes"] {
4245                    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")] {
4246                        assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
4247                    }
4248                }
4249                assert_eq!(actual.pending_commit_ids(), pending);
4250            }
4251            assert_eq!(
4252                actual.overlay_rebuild_count.get(),
4253                0,
4254                "each chunk must leave unrelated tables alone"
4255            );
4256            let before = actual
4257                .query("SELECT * FROM tasks ORDER BY id", &[])
4258                .unwrap();
4259            let fts_before = actual
4260                .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4261                .unwrap();
4262            let revision = actual.local_revision();
4263            actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
4264            let table = actual.schema.table("tasks").unwrap().clone();
4265            let segment = RowsSegment {
4266                table: "tasks".into(),
4267                schema_version: 1,
4268                columns: table.wire_columns.clone(),
4269                blocks: vec![vec![ssp2::segment::SegmentRow {
4270                    server_version: 3,
4271                    values: vec![
4272                        Some(ColumnValue::String("b".into())),
4273                        Some(ColumnValue::String("p1".into())),
4274                        Some(ColumnValue::String("fresh".into())),
4275                    ],
4276                }]],
4277            };
4278            assert!(actual.apply_segment(0, &segment, false).is_err());
4279            assert!(actual.conn.is_autocommit());
4280            assert_eq!(
4281                actual
4282                    .query("SELECT * FROM tasks ORDER BY id", &[])
4283                    .unwrap(),
4284                before
4285            );
4286            assert_eq!(
4287                actual
4288                    .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4289                    .unwrap(),
4290                fts_before
4291            );
4292            assert_eq!(actual.local_revision(), revision);
4293            assert_eq!(actual.pending_commit_ids(), pending);
4294        }
4295    }
4296
4297    #[test]
4298    fn import_reconciliation_preserves_typed_pending_primary_keys() {
4299        for (kind, a, b) in [
4300            ("string", json!("a"), json!("b")),
4301            ("integer", json!(1), json!(2)),
4302            ("boolean", json!(true), json!(false)),
4303            ("json", json!("1"), json!("2")),
4304        ] {
4305            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}]}]});
4306            for images in [false, true] {
4307                let mut client =
4308                    SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
4309                let table = client.schema.table("tasks").unwrap().clone();
4310                client
4311                    .subscribe("tasks".into(), "tasks".into(), vec![], None)
4312                    .unwrap();
4313                for (id, title) in [(&a, "original"), (&b, "occupied")] {
4314                    client
4315                        .write_base_row(
4316                            "tasks",
4317                            &vec![
4318                                json_to_column_value(&table.columns[0], Some(id)).unwrap(),
4319                                Some(ColumnValue::String(title.into())),
4320                            ],
4321                            1,
4322                        )
4323                        .unwrap();
4324                }
4325                client.rebuild_overlay_if_dirty();
4326                client
4327                    .mutate(vec![Mutation::Upsert {
4328                        table: "tasks".into(),
4329                        values: Map::from_iter([
4330                            ("id".into(), a.clone()),
4331                            ("title".into(), json!("available")),
4332                        ]),
4333                        base_version: None,
4334                    }])
4335                    .unwrap();
4336                client.overlay_rebuild_count.set(0);
4337                for (title, expected) in [("available", "original"), ("occupied", "available")] {
4338                    let values = vec![
4339                        json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
4340                        Some(ColumnValue::String(title.into())),
4341                    ];
4342                    if images {
4343                        let image = Connection::open_in_memory().unwrap();
4344                        image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4345                            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4346                            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4347                        image
4348                            .execute(
4349                                "INSERT INTO tasks VALUES (?1,?2,2)",
4350                                rusqlite::params![RowParam::Cell(&values[0]), title],
4351                            )
4352                            .unwrap();
4353                        assert!(
4354                            matches!(
4355                                client.apply_sqlite_image(
4356                                    &image,
4357                                    &table,
4358                                    false,
4359                                    &[],
4360                                    1,
4361                                    7,
4362                                    "digest"
4363                                ),
4364                                Ok(1)
4365                            ),
4366                            "{kind}, images={images}"
4367                        );
4368                    } else {
4369                        let segment = RowsSegment {
4370                            table: "tasks".into(),
4371                            schema_version: 1,
4372                            columns: table.wire_columns.clone(),
4373                            blocks: vec![vec![ssp2::segment::SegmentRow {
4374                                server_version: 2,
4375                                values,
4376                            }]],
4377                        };
4378                        assert!(
4379                            matches!(client.apply_segment(0, &segment, false), Ok(1)),
4380                            "{kind}"
4381                        );
4382                    }
4383                    let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
4384                    let actual: String = client
4385                        .conn
4386                        .query_row(
4387                            "SELECT title FROM tasks WHERE id=?1",
4388                            [RowParam::Cell(&key)],
4389                            |row| row.get(0),
4390                        )
4391                        .unwrap();
4392                    assert_eq!(actual, expected, "{kind}, images={images}");
4393                }
4394                assert_eq!(client.overlay_rebuild_count.get(), 0);
4395            }
4396        }
4397    }
4398
4399    fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
4400        let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4401            "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
4402            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
4403            "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4404        })))});
4405        let mut client =
4406            SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
4407        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();
4408        client.rebuild_overlay();
4409        client
4410            .mutate(vec![Mutation::Upsert {
4411                table: "tasks".into(),
4412                values: Map::from_iter([
4413                    ("id".into(), json!("offline")),
4414                    ("project_id".into(), json!("p1")),
4415                    ("title".into(), json!("pending edit")),
4416                ]),
4417                base_version: None,
4418            }])
4419            .unwrap();
4420        let image = Connection::open_in_memory().unwrap();
4421        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4422            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
4423        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();
4424        image
4425            .execute(
4426                "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
4427                [rows],
4428            )
4429            .unwrap();
4430        client.overlay_rebuild_count.set(0);
4431        let table = client.schema.table("tasks").unwrap().clone();
4432        (client, image, table)
4433    }
4434
4435    #[test]
4436    fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
4437        let (mut client, image, table) = unique_import_fixture(1);
4438        image
4439            .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
4440            .unwrap();
4441        client
4442            .conn
4443            .execute_batch("PRAGMA max_page_count = 1")
4444            .unwrap();
4445        let effective = [("project_id".into(), vec!["p1".into()])];
4446        assert!(client
4447            .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
4448            .is_err());
4449        let failure = client.storage_failure.borrow().clone().unwrap();
4450        assert_eq!(failure.code, Some("client.storage_full"));
4451        assert_eq!(failure.sqlite_code, Some(13));
4452        assert!(!failure.message.contains("rollback"));
4453        assert!(client.conn.is_autocommit());
4454        client
4455            .conn
4456            .execute_batch("PRAGMA max_page_count = 1073741823")
4457            .unwrap();
4458        assert!(matches!(
4459            client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
4460            Ok(1)
4461        ));
4462        assert!(client.conn.is_autocommit());
4463    }
4464
4465    #[test]
4466    fn large_unique_import_copies_no_unrelated_tables() {
4467        let (mut client, image, table) = unique_import_fixture(10240);
4468        let pending = client.pending_commit_ids();
4469        client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
4470            CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
4471        assert!(matches!(
4472            client.apply_sqlite_image(
4473                &image,
4474                &table,
4475                true,
4476                &[("project_id".into(), vec!["p1".into()])],
4477                10240,
4478                7,
4479                "digest"
4480            ),
4481            Ok(10240)
4482        ));
4483        assert_eq!(client.overlay_rebuild_count.get(), 0);
4484        assert_eq!(client.pending_commit_ids(), pending);
4485        assert_eq!(
4486            client
4487                .query("SELECT count(*) AS n FROM tasks", &[])
4488                .unwrap()[0]["n"],
4489            10241
4490        );
4491        assert_eq!(
4492            client
4493                .query("SELECT count(*) AS n FROM tasks_fts", &[])
4494                .unwrap()[0]["n"],
4495            10241
4496        );
4497        assert_eq!(
4498            client
4499                .query("SELECT count(*) AS n FROM notes_fts", &[])
4500                .unwrap()[0]["n"],
4501            8192
4502        );
4503    }
4504
4505    #[test]
4506    #[ignore = "manual scaling benchmark; see bench/README.md"]
4507    fn benchmark_unique_image_import() {
4508        let mut samples = Vec::new();
4509        for rows in [10240, 51200, 102400] {
4510            for trial in 0..4 {
4511                let (mut client, image, table) = unique_import_fixture(rows);
4512                let start = std::time::Instant::now();
4513                assert!(
4514                    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)
4515                );
4516                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
4517                assert_eq!(
4518                    client
4519                        .query("SELECT count(*) AS n FROM tasks", &[])
4520                        .unwrap()[0]["n"],
4521                    rows + 1
4522                );
4523                assert_eq!(
4524                    client
4525                        .query("SELECT count(*) AS n FROM tasks_fts", &[])
4526                        .unwrap()[0]["n"],
4527                    rows + 1
4528                );
4529                assert_eq!(
4530                    client
4531                        .query("SELECT count(*) AS n FROM notes_fts", &[])
4532                        .unwrap()[0]["n"],
4533                    8192
4534                );
4535                assert_eq!(client.pending_commit_ids().len(), 1);
4536                if trial > 0 {
4537                    samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
4538                }
4539            }
4540        }
4541        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});
4542        let output = serde_json::to_string_pretty(&artifact).unwrap();
4543        if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
4544            use std::io::Write;
4545            let mut file = std::fs::OpenOptions::new()
4546                .write(true)
4547                .create_new(true)
4548                .open(path)
4549                .unwrap();
4550            file.write_all(output.as_bytes()).unwrap();
4551        } else {
4552            println!("{output}");
4553        }
4554    }
4555
4556    #[test]
4557    fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
4558        let schema = json!({
4559            "version": 1,
4560            "tables": [{
4561                "name": "catalogue_codes",
4562                "primaryKey": "id",
4563                "columns": [
4564                    { "name": "id", "type": "string", "nullable": false },
4565                    { "name": "release_id", "type": "string", "nullable": false },
4566                    { "name": "code", "type": "string", "nullable": false },
4567                    { "name": "title", "type": "string", "nullable": false }
4568                ],
4569                "scopes": [{ "pattern": "release:{release_id}" }],
4570                "ftsIndexes": [{
4571                    "name": "catalogue_codes_fts",
4572                    "columns": ["code", "title"],
4573                    "tokenize": "unicode61 remove_diacritics 2"
4574                }]
4575            }]
4576        });
4577        let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
4578            .expect("FTS5 client");
4579        let insert_trigger: String = client
4580            .conn
4581            .query_row(
4582                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
4583                [],
4584                |row| row.get(0),
4585            )
4586            .expect("insert trigger");
4587        let replace_guard: String = client
4588            .conn
4589            .query_row(
4590                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
4591                [],
4592                |row| row.get(0),
4593            )
4594            .expect("replace guard");
4595        assert!(!insert_trigger.contains("DELETE FROM"));
4596        assert!(replace_guard.contains("BEFORE INSERT"));
4597        assert!(replace_guard.contains("WHEN EXISTS"));
4598        let search = |client: &SyncClient, query: &str| {
4599            client
4600                .query(
4601                    "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",
4602                    &[Value::from(query)],
4603                )
4604                .expect("FTS query")
4605        };
4606
4607        client
4608            .mutate(vec![Mutation::Upsert {
4609                table: "catalogue_codes".to_owned(),
4610                values: Map::from_iter([
4611                    ("id".to_owned(), Value::from("c1")),
4612                    ("release_id".to_owned(), Value::from("r1")),
4613                    ("code".to_owned(), Value::from("A01")),
4614                    ("title".to_owned(), Value::from("Cholera")),
4615                ]),
4616                base_version: None,
4617            }])
4618            .expect("insert code");
4619        assert_eq!(search(&client, "cholera").len(), 1);
4620        let prior = client
4621            .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4622            .unwrap();
4623        client
4624            .conn
4625            .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
4626            .unwrap();
4627        client.create_synced_tables().unwrap();
4628        assert_eq!(
4629            client
4630                .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4631                .unwrap(),
4632            prior
4633        );
4634        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();
4635        let rendered = serde_json::to_string(&plan).unwrap();
4636        assert!(rendered.contains("COVERING INDEX"));
4637        assert!(rendered.contains("INDEX 0:="));
4638        client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
4639        assert_eq!(search(&client, "cholera").len(), 1);
4640
4641        client
4642            .mutate(vec![Mutation::Upsert {
4643                table: "catalogue_codes".to_owned(),
4644                values: Map::from_iter([
4645                    ("id".to_owned(), Value::from("c1")),
4646                    ("release_id".to_owned(), Value::from("r1")),
4647                    ("code".to_owned(), Value::from("A01")),
4648                    ("title".to_owned(), Value::from("Enteric infection")),
4649                ]),
4650                base_version: None,
4651            }])
4652            .expect("update code");
4653        assert!(search(&client, "cholera").is_empty());
4654        assert_eq!(search(&client, "enteric").len(), 1);
4655
4656        client
4657            .mutate(vec![Mutation::Delete {
4658                table: "catalogue_codes".to_owned(),
4659                row_id: "c1".to_owned(),
4660                base_version: None,
4661            }])
4662            .expect("delete code");
4663        assert!(search(&client, "enteric").is_empty());
4664    }
4665
4666    #[test]
4667    fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
4668        let schema = json!({
4669            "version": 1,
4670            "tables": [{
4671                "name": "patient_notes",
4672                "primaryKey": "id",
4673                "columns": [
4674                    { "name": "id", "type": "string", "nullable": false },
4675                    { "name": "practice_id", "type": "string", "nullable": false },
4676                    { "name": "encryption_key_id", "type": "string", "nullable": false },
4677                    { "name": "title", "type": "string", "nullable": false }
4678                ],
4679                "scopes": [{ "pattern": "practice:{practice_id}" }],
4680                "ftsIndexes": [{
4681                    "name": "patient_notes_fts",
4682                    "columns": ["title"],
4683                    "tokenize": "unicode61 remove_diacritics 2"
4684                }]
4685            }]
4686        });
4687        let mut client = SyncClient::new(
4688            "local-purge-test".to_owned(),
4689            &schema,
4690            ClientLimits::default(),
4691        )
4692        .expect("local purge client");
4693        client
4694            .conn
4695            .execute(
4696                "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
4697                 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
4698                 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
4699                [],
4700            )
4701            .expect("seed base rows");
4702        client.overlay_dirty.set(true);
4703        client.rebuild_overlay_if_dirty();
4704
4705        let note = |id: &str, key_id: &str, title: &str| {
4706            Map::from_iter([
4707                ("id".to_owned(), Value::from(id)),
4708                ("practice_id".to_owned(), Value::from("practice-1")),
4709                ("encryption_key_id".to_owned(), Value::from(key_id)),
4710                ("title".to_owned(), Value::from(title)),
4711            ])
4712        };
4713        let doomed = client
4714            .mutate(vec![
4715                Mutation::Upsert {
4716                    table: "patient_notes".to_owned(),
4717                    values: note("target", "key-revoked", "Target changed"),
4718                    base_version: None,
4719                },
4720                Mutation::Upsert {
4721                    table: "patient_notes".to_owned(),
4722                    values: note("unrelated", "key-held", "Unrelated changed"),
4723                    base_version: None,
4724                },
4725            ])
4726            .expect("doomed commit");
4727        let kept = client
4728            .mutate(vec![Mutation::Upsert {
4729                table: "patient_notes".to_owned(),
4730                values: note("kept", "key-held", "Kept optimistic"),
4731                base_version: None,
4732            }])
4733            .expect("kept commit");
4734        client.drain_change_batches();
4735
4736        let input = LocalDataPurgeInput {
4737            purge_id: "purge-001".to_owned(),
4738            targets: vec![LocalDataPurgeTarget {
4739                table: "patient_notes".to_owned(),
4740                selectors: BTreeMap::from([(
4741                    "encryption_key_id".to_owned(),
4742                    vec!["key-revoked".to_owned()],
4743                )]),
4744            }],
4745        };
4746        assert_eq!(
4747            client.purge_local_data(&input).expect("apply purge"),
4748            LocalDataPurgeResult {
4749                already_applied: false,
4750                purged_rows: 1,
4751                dropped_commits: 1,
4752            }
4753        );
4754        let rows = client
4755            .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
4756            .expect("visible rows");
4757        assert_eq!(rows.len(), 2);
4758        assert_eq!(rows[0]["id"], "kept");
4759        assert_eq!(rows[1]["id"], "unrelated");
4760        assert_eq!(rows[1]["title"], "Unrelated original");
4761        let fts = client
4762            .query(
4763                "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'",
4764                &[],
4765            )
4766            .expect("fts query");
4767        assert!(fts.is_empty());
4768        assert_eq!(client.outbox.len(), 1);
4769        assert_eq!(client.outbox[0].client_commit_id, kept);
4770        let outcome = client
4771            .commit_outcome(&doomed)
4772            .expect("read doomed outcome")
4773            .expect("doomed outcome");
4774        assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
4775        match &outcome.results[0] {
4776            CommitOperationOutcome::Error { rejection } => {
4777                assert_eq!(rejection.code, "client.local_data_purged");
4778                assert_eq!(rejection.client_commit_id, doomed);
4779            }
4780            other => panic!("expected local purge rejection, got {other:?}"),
4781        }
4782        assert_eq!(client.drain_change_batches().len(), 1);
4783        assert_eq!(
4784            client.purge_local_data(&input).expect("retry purge"),
4785            LocalDataPurgeResult {
4786                already_applied: true,
4787                purged_rows: 0,
4788                dropped_commits: 0,
4789            }
4790        );
4791        let conflicting = LocalDataPurgeInput {
4792            purge_id: input.purge_id.clone(),
4793            targets: vec![LocalDataPurgeTarget {
4794                table: "patient_notes".to_owned(),
4795                selectors: BTreeMap::from([(
4796                    "encryption_key_id".to_owned(),
4797                    vec!["key-held".to_owned()],
4798                )]),
4799            }],
4800        };
4801        assert!(client
4802            .purge_local_data(&conflicting)
4803            .expect_err("id collision must fail")
4804            .contains("already used with a different plan"));
4805    }
4806}
4807
4808impl SubState {
4809    fn name(self) -> &'static str {
4810        match self {
4811            SubState::Active => "active",
4812            SubState::Revoked => "revoked",
4813            SubState::Failed => "failed",
4814        }
4815    }
4816
4817    fn parse(value: &str) -> Self {
4818        match value {
4819            "revoked" => Self::Revoked,
4820            "failed" => Self::Failed,
4821            _ => Self::Active,
4822        }
4823    }
4824}
4825
4826#[derive(Debug, Clone)]
4827struct Subscription {
4828    id: String,
4829    table: String,
4830    requested: Vec<(String, Vec<String>)>,
4831    params: Option<String>,
4832    cursor: i64,
4833    /// §4.7 resume token, round-tripped opaquely.
4834    bootstrap_state: Option<String>,
4835    state: SubState,
4836    reason_code: Option<String>,
4837    /// Last effective scopes echoed while active (§3.3: persisted for the
4838    /// purge contract; each active echo replaces it).
4839    effective: Option<Vec<(String, Vec<String>)>>,
4840    synced_once: bool,
4841}
4842
4843#[derive(Debug, Clone)]
4844struct OutboxOp {
4845    upsert: bool,
4846    table: String,
4847    row_id: String,
4848    base_version: Option<i64>,
4849    /// Schema-agnostic local form (§0): driver JSON values, encoded with
4850    /// the current codec at send time. A key's presence IS the §6.1 sparse
4851    /// presence set — both the overlay and the wire payload derive from it.
4852    values: Option<Map<String, Value>>,
4853}
4854
4855impl From<&OutboxOp> for CommitOperation {
4856    fn from(operation: &OutboxOp) -> Self {
4857        Self {
4858            table: operation.table.clone(),
4859            row_id: operation.row_id.clone(),
4860            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
4861            base_version: operation.base_version,
4862            values: operation.values.clone(),
4863        }
4864    }
4865}
4866
4867#[derive(Debug, Clone)]
4868struct OutboxCommit {
4869    client_commit_id: String,
4870    ops: Vec<OutboxOp>,
4871}
4872
4873/// One commit's `(table, value keys)` per upsert operation — the classification
4874/// shape the §7.4.4 send-time drop and the RFC 0005 D6 audit share, so the two
4875/// can never disagree about which commits cannot re-encode.
4876fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
4877    commit
4878        .ops
4879        .iter()
4880        .filter(|operation| operation.upsert)
4881        .map(|operation| {
4882            (
4883                operation.table.as_str(),
4884                operation
4885                    .values
4886                    .as_ref()
4887                    .map(|values| values.keys().map(String::as_str).collect())
4888                    .unwrap_or_default(),
4889            )
4890        })
4891        .collect()
4892}
4893
4894type FailedCommit = (OutboxCommit, Map<String, Value>);
4895
4896#[derive(Debug, Clone)]
4897struct CompiledLocalDataPurgeTarget {
4898    table: String,
4899    selectors: Vec<(String, Vec<String>)>,
4900}
4901
4902struct StoredCommitOutcomeRow {
4903    sequence: i64,
4904    client_commit_id: String,
4905    status: String,
4906    recorded_at_ms: i64,
4907    results_json: String,
4908    operations_json: Option<String>,
4909    resolution: String,
4910    resolved_at_ms: Option<i64>,
4911    replacement_client_commit_id: Option<String>,
4912}
4913
4914/// Section outcome distinguishing the §5.6 subscription-local fail-closed
4915/// path from a round-aborting failure (§1.4 rule 5).
4916enum SectionError {
4917    FailClosed,
4918    Abort(String, String),
4919}
4920
4921struct RequestMeta {
4922    pushed_ids: Vec<String>,
4923    /// Subscription id → the request carried `cursor < 0` and no resume
4924    /// token (§5.6 first-page detection: a *fresh* bootstrap).
4925    fresh: Vec<(String, bool)>,
4926    accept: u8,
4927    /// §6.1 splitBatch: outbox commits held back from THIS request because
4928    /// the running operation count reached the push cap — the next round
4929    /// pushes them (`sync_needed` stays set while any remain).
4930    deferred_commits: usize,
4931}
4932
4933#[derive(Default)]
4934struct ChangeAccumulator {
4935    /// None means table-wide; Some is the exact scoped domain.
4936    tables: BTreeMap<String, Option<BTreeSet<String>>>,
4937    windows: BTreeMap<(String, String), BTreeSet<String>>,
4938    status: bool,
4939    conflicts: bool,
4940    rejections: bool,
4941    outcomes: bool,
4942}
4943
4944impl ChangeAccumulator {
4945    fn table(&mut self, table: &str) {
4946        self.tables.insert(table.to_owned(), None);
4947    }
4948
4949    fn scope(&mut self, table: &str, key: String) {
4950        match self.tables.get_mut(table) {
4951            Some(None) => {}
4952            Some(Some(keys)) => {
4953                keys.insert(key);
4954            }
4955            None => {
4956                self.tables
4957                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
4958            }
4959        }
4960    }
4961
4962    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
4963        self.windows
4964            .entry((base_key.to_owned(), table.to_owned()))
4965            .or_default()
4966            .insert(unit.to_owned());
4967    }
4968
4969    fn touched(&self) -> bool {
4970        !self.tables.is_empty()
4971            || !self.windows.is_empty()
4972            || self.status
4973            || self.conflicts
4974            || self.rejections
4975            || self.outcomes
4976    }
4977}
4978
4979/// §6.1: the server caps total operations per request (reference default
4980/// 500) and rejects the whole batch with `sync.too_many_operations`; the
4981/// client "splits and retries". Splitting happens at build time: commits are
4982/// included IN ORDER until the operation budget is spent, the rest wait for
4983/// the next round.
4984const PUSH_OPS_PER_REQUEST: usize = 500;
4985const MAX_LOCAL_PURGE_TARGETS: usize = 64;
4986const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
4987const MAX_LOCAL_PURGE_VALUES: usize = 128;
4988const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
4989pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
4990
4991pub struct SyncClient {
4992    #[cfg(feature = "bench-internals")]
4993    benchmark_phases: Recorder,
4994    conn: Connection,
4995    schema: ClientSchema,
4996    client_id: String,
4997    limits: ClientLimits,
4998    subs: Vec<Subscription>,
4999    outbox: Vec<OutboxCommit>,
5000    failed_commits: Vec<FailedCommit>,
5001    retain_failed_commits: bool,
5002    conflicts: Vec<ConflictRecord>,
5003    rejections: Vec<RejectionRecord>,
5004    schema_floor: Option<SchemaFloor>,
5005    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
5006    lease_state: Option<LeaseState>,
5007    /// §1.6: the schema-floor response stops syncing until an upgrade.
5008    stopped: bool,
5009    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
5010    upgrading: bool,
5011    /// §8.4 coalesced sync-needed signal.
5012    sync_needed: bool,
5013    /// §8.8: how sync rounds treat the realtime binding, and its exact
5014    /// availability state with the evidence behind a lost or refused one.
5015    realtime_policy: RealtimePolicy,
5016    realtime_state: RealtimeState,
5017    realtime_reason_code: Option<String>,
5018    realtime_retry_delay_ms: Option<u64>,
5019    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
5020    presence: HashMap<String, HashMap<String, PresencePeer>>,
5021    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
5022    /// host may pin it (conformance runs on a virtual clock).
5023    now_ms: Option<i64>,
5024    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
5025    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
5026    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
5027    encryption: crate::values::EncryptionConfig,
5028    /// Fail-closed host bootstrap gate. While set, command hosts permit only
5029    /// status/lifecycle inspection and an exact authorized local purge.
5030    security_preflight: bool,
5031    /// RFC 0005 D2/D8: the previous-version capture config. `None` (default)
5032    /// means the feature is off; the descriptor write and the container orphan
5033    /// sweep still happen.
5034    previous_version: Option<PreviousVersionContextConfig>,
5035    /// Per-table primary-key upsert SQL, built once per (full table name) —
5036    /// the row write path runs per row during bootstrap (§5.6), so the SQL
5037    /// string (and, via `prepare_cached`, its compiled statement) is reused
5038    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
5039    /// schema reset (the column lists may have changed).
5040    insert_sql: RefCell<HashMap<String, String>>,
5041    /// §7.1 rebuild gate: true whenever the base tables or the outbox have
5042    /// diverged from the visible overlay since the last rebuild. Lets a
5043    /// no-op sync round skip the full base→visible copy.
5044    overlay_dirty: Cell<bool>,
5045    // First SQLite failure of the current operation, before savepoint cleanup.
5046    storage_failure: RefCell<Option<QueryReadFailure>>,
5047    /// Test-only structural performance signal: response processing must not
5048    /// turn a batch of acknowledgements into one full overlay rebuild each.
5049    #[cfg(test)]
5050    overlay_rebuild_count: Cell<usize>,
5051    /// Test-only structural performance signal: outcome retention is enforced
5052    /// once per response rather than once per acknowledgement.
5053    #[cfg(test)]
5054    outcome_prune_count: Cell<usize>,
5055    /// Exact observer-transaction output drained by command/FFI hosts.
5056    progress: ProgressObserver,
5057    change_queue: VecDeque<ClientChangeBatch>,
5058    sync_intent_queue: VecDeque<SyncIntent>,
5059    /// Explicit exponential retry policy for transient transport failures.
5060    retry_delay_ms: u64,
5061    /// §7.6: the background retry the current round scheduled, if any.
5062    round_retry_delay_ms: Option<u64>,
5063    last_round: Option<DiagnosticLastRound>,
5064    last_change: Option<DiagnosticLastChange>,
5065    /// §7.6: latest failed owned snapshot read per owner id, oldest first.
5066    query_failures: Vec<DiagnosticQueryFailure>,
5067}
5068
5069pub(crate) fn quote_ident(name: &str) -> String {
5070    format!("\"{}\"", name.replace('"', "\"\""))
5071}
5072/// §7.4.1 `_syncular_meta` helpers. Free functions so RFC 0005's
5073/// previous-version module reads and writes the SAME records as the client
5074/// instead of growing a parallel meta layer.
5075pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
5076    conn.query_row(
5077        "SELECT value FROM _syncular_meta WHERE key = ?1",
5078        rusqlite::params![key],
5079        |row| row.get::<_, String>(0),
5080    )
5081    .ok()
5082}
5083
5084pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
5085    let _ = conn.execute(
5086        "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
5087        rusqlite::params![key, value],
5088    );
5089}
5090
5091pub(crate) fn meta_delete(conn: &Connection, key: &str) {
5092    let _ = conn.execute(
5093        "DELETE FROM _syncular_meta WHERE key = ?1",
5094        rusqlite::params![key],
5095    );
5096}
5097
5098/// Seek by the existing key while retaining the exact legacy text match.
5099fn row_id_predicate(table: &TableSchema) -> String {
5100    let key = quote_ident(&table.primary_key);
5101    let text_match = format!("CAST({key} AS TEXT) = ?1");
5102    match table.columns[table.pk_index].ty {
5103        ColumnType::String | ColumnType::Json => {
5104            format!("{key} = ?1 AND {text_match}")
5105        }
5106        // Native columns have no declared SQLite affinity. Match the stored
5107        // integer bind type explicitly. Unary + removes expression affinity so
5108        // SQLite can seek the typeless key; the text check preserves exact IDs.
5109        ColumnType::Integer | ColumnType::Boolean => {
5110            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
5111        }
5112        // REAL-to-text conversion can round distinct values to the same text.
5113        // Narrowing those matches by numeric equality would change behavior.
5114        _ => text_match,
5115    }
5116}
5117
5118fn is_local_operation_code_like(value: &str) -> bool {
5119    let bytes = value.as_bytes();
5120    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
5121        && bytes
5122            .iter()
5123            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
5124}
5125
5126fn base_table(name: &str) -> String {
5127    quote_ident(&format!("_syncular_base_{name}"))
5128}
5129
5130/// §4.8: a stable, server-opaque key for a window base — table + variable +
5131/// canonical fixed scopes. Two `set_window` calls with the same base
5132/// address the same registry rows.
5133/// §4.8 deferred eviction record: (sub id, table, effective scope map).
5134type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
5135
5136fn window_base_key(base: &WindowBase) -> String {
5137    format!(
5138        "{}\0{}\0{}",
5139        base.table,
5140        base.variable,
5141        canonical_scope_json(&base.fixed_scopes)
5142    )
5143}
5144
5145/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
5146fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
5147    let mut scopes = base.fixed_scopes.clone();
5148    scopes.retain(|(k, _)| k != &base.variable);
5149    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
5150    scopes
5151}
5152
5153/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
5154/// echoed not interpreted by the server, so the exact hash is client
5155/// convention; SHA-256 matches the SPEC's worked example.
5156fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
5157    let canonical = canonical_scope_json(&unit_scopes(base, unit));
5158    let digest = Sha256::digest(canonical.as_bytes());
5159    let hex = bytes_to_hex(&digest);
5160    format!("w:{}:{}", base.table, &hex[..16])
5161}
5162
5163fn visible_table(name: &str) -> String {
5164    quote_ident(name)
5165}
5166
5167const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
5168
5169/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
5170/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
5171fn is_synced_table_name(name: &str) -> bool {
5172    if name.starts_with("sqlite_") {
5173        return false;
5174    }
5175    if name.starts_with("_syncular_base_") {
5176        return true; // the base half of a synced table pair
5177    }
5178    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
5179    !name.starts_with("_syncular_")
5180}
5181
5182/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
5183fn blob_id_for(bytes: &[u8]) -> String {
5184    let digest = Sha256::digest(bytes);
5185    format!("sha256:{}", bytes_to_hex(&digest))
5186}
5187
5188/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
5189/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
5190/// per row on the §5.6 bootstrap path), scalars bind owned.
5191enum RowParam<'a> {
5192    Cell(&'a Option<ColumnValue>),
5193    Version(i64),
5194}
5195
5196impl rusqlite::ToSql for RowParam<'_> {
5197    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
5198        Ok(match self {
5199            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
5200            RowParam::Cell(cell) => match cell {
5201                None => ToSqlOutput::Owned(SqlValue::Null),
5202                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
5203                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
5204                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
5205                Some(ColumnValue::Boolean(b)) => {
5206                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
5207                }
5208                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
5209                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
5210                }
5211                // §5.10: crdt bytes store as BLOB, like bytes.
5212                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
5213                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
5214                }
5215            },
5216        })
5217    }
5218}
5219
5220// Retain a typed primary key across the current import chunk without copying row payloads.
5221fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
5222    match param.to_sql().map_err(|error| error.to_string())? {
5223        ToSqlOutput::Owned(value) => Ok(value),
5224        ToSqlOutput::Borrowed(value) => {
5225            SqlValue::try_from(value).map_err(|error| error.to_string())
5226        }
5227        _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
5228    }
5229}
5230
5231/// §5.3 image cell → bind parameter, strict per the declared column type
5232/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
5233/// nullable). Mismatches are image-producer violations. Returns the cell
5234/// borrowed when the stored representation already matches what the row
5235/// codec would write, or the normalized scalar (boolean → 0/1, float from
5236/// INTEGER → REAL) otherwise — the local write is byte-identical to the
5237/// old convert-then-insert path without allocating per cell.
5238fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
5239    use ssp2::segment::ColumnType;
5240    let mismatch = || {
5241        Err(format!(
5242            "image column {:?} holds a value of the wrong type",
5243            column.name
5244        ))
5245    };
5246    match value {
5247        ValueRef::Null => {
5248            if !column.nullable {
5249                return Err(format!(
5250                    "image column {:?} is NULL but not nullable",
5251                    column.name
5252                ));
5253            }
5254            Ok(ToSqlOutput::Owned(SqlValue::Null))
5255        }
5256        ValueRef::Integer(i) => match column.ty {
5257            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
5258            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
5259            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
5260            _ => mismatch(),
5261        },
5262        ValueRef::Real(_) => match column.ty {
5263            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
5264            _ => mismatch(),
5265        },
5266        ValueRef::Text(t) => {
5267            std::str::from_utf8(t)
5268                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
5269            match column.ty {
5270                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
5271                    Ok(ToSqlOutput::Borrowed(value))
5272                }
5273                _ => mismatch(),
5274            }
5275        }
5276        ValueRef::Blob(_) => match column.ty {
5277            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
5278            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
5279            _ => mismatch(),
5280        },
5281    }
5282}
5283
5284pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
5285    use rusqlite::types::ValueRef;
5286    match value {
5287        ValueRef::Null => Value::Null,
5288        ValueRef::Integer(i) => match column.ty {
5289            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
5290            ssp2::segment::ColumnType::Float => {
5291                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
5292            }
5293            _ => Value::from(i),
5294        },
5295        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5296        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5297        ValueRef::Blob(b) => {
5298            let mut map = Map::new();
5299            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5300            Value::Object(map)
5301        }
5302    }
5303}
5304
5305/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
5306/// Objects are accepted in lossless `{"$bytes": hex}` and
5307/// `{"$bigint": decimal}` envelope forms.
5308fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
5309    Ok(match value {
5310        Value::Null => SqlValue::Null,
5311        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
5312        Value::Number(n) => {
5313            if let Some(i) = n.as_i64() {
5314                SqlValue::Integer(i)
5315            } else if let Some(f) = n.as_f64() {
5316                SqlValue::Real(f)
5317            } else {
5318                return Err(format!("query param number {n} is out of range"));
5319            }
5320        }
5321        Value::String(s) => SqlValue::Text(s.clone()),
5322        Value::Object(_) => {
5323            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
5324                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
5325            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
5326                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
5327                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
5328                })?)
5329            } else {
5330                return Err(
5331                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
5332                        .to_owned(),
5333                );
5334            }
5335        }
5336        Value::Array(_) => return Err("query array params are not supported".to_owned()),
5337    })
5338}
5339
5340/// Map a rusqlite value with no schema column to consult (arbitrary query
5341/// output): integers/reals/text pass through by stored affinity, blobs ride
5342/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
5343/// schema column type to recover booleans/floats/json.
5344fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
5345    use rusqlite::types::ValueRef;
5346    match value {
5347        ValueRef::Null => Value::Null,
5348        ValueRef::Integer(i) => {
5349            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
5350            // ordinary UI-sized integers ergonomic and envelope only values
5351            // beyond JavaScript's safe range.
5352            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
5353            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
5354                Value::from(i)
5355            } else {
5356                let mut map = Map::new();
5357                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
5358                Value::Object(map)
5359            }
5360        }
5361        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5362        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5363        ValueRef::Blob(b) => {
5364            let mut map = Map::new();
5365            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5366            Value::Object(map)
5367        }
5368    }
5369}
5370
5371/// §7.5: SQLite failures classify by result code into [`QueryReadFailure`].
5372fn query_connection(
5373    conn: &Connection,
5374    sql: &str,
5375    params: &[QueryValue],
5376) -> Result<Vec<QueryRow>, QueryReadFailure> {
5377    crate::query_guard::assert_read_only_query(sql)?;
5378    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
5379    let bound: Vec<SqlValue> = params
5380        .iter()
5381        .map(json_param_to_sql)
5382        .collect::<Result<_, _>>()?;
5383    let mut stmt = conn.prepare(&lowered_sql)?;
5384    let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
5385    let bound_refs: Vec<&dyn rusqlite::ToSql> =
5386        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
5387    let mut sql_rows = stmt.query(bound_refs.as_slice())?;
5388    let mut out = Vec::new();
5389    while let Some(row) = sql_rows.next()? {
5390        let mut record = Map::new();
5391        for (i, name) in column_names.iter().enumerate() {
5392            let value = row.get_ref(i)?;
5393            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
5394        }
5395        out.push(record);
5396    }
5397    Ok(out)
5398}
5399
5400fn persisted_window_state(
5401    conn: &Connection,
5402    base: &WindowBase,
5403) -> Result<WindowState, QueryReadFailure> {
5404    let mut stmt = conn.prepare(
5405        "SELECT windows.unit, subscriptions.state_json
5406           FROM _syncular_windows AS windows
5407           JOIN _syncular_subscriptions AS subscriptions
5408             ON subscriptions.id = windows.sub_id
5409          WHERE windows.base = ?1
5410          ORDER BY windows.unit ASC",
5411    )?;
5412    let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
5413        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5414    })?;
5415    let mut units = Vec::new();
5416    let mut pending = Vec::new();
5417    for row in rows {
5418        let (unit, raw) = row?;
5419        let state: Value = serde_json::from_str(&raw)
5420            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
5421        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
5422            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
5423            || state
5424                .get("bootstrapState")
5425                .is_some_and(|value| !value.is_null());
5426        if is_pending {
5427            pending.push(unit.clone());
5428        }
5429        units.push(unit);
5430    }
5431    Ok(WindowState { units, pending })
5432}
5433
5434fn snapshot_connection(
5435    conn: &Connection,
5436    sql: &str,
5437    params: &[Value],
5438    coverage: &[WindowCoverage],
5439) -> Result<QuerySnapshot, QueryReadFailure> {
5440    conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
5441    let result: Result<QuerySnapshot, QueryReadFailure> = (|| {
5442        let revision = conn
5443            .query_row(
5444                "SELECT value FROM _syncular_meta WHERE key = ?1",
5445                rusqlite::params![LOCAL_REVISION_KEY],
5446                |row| row.get::<_, String>(0),
5447            )
5448            .ok()
5449            .and_then(|value| value.parse::<u64>().ok())
5450            .unwrap_or(0);
5451        let rows = query_connection(conn, sql, params)?;
5452        let mut pending = Vec::new();
5453        let mut missing = Vec::new();
5454        for requested in coverage {
5455            let base_key = window_base_key(&requested.base);
5456            let state = persisted_window_state(conn, &requested.base)?;
5457            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
5458                let reference = WindowUnitRef {
5459                    base_key: base_key.clone(),
5460                    unit: unit.clone(),
5461                };
5462                if !state.units.iter().any(|held| held == &unit) {
5463                    missing.push(reference);
5464                } else if state.pending.iter().any(|held| held == &unit) {
5465                    pending.push(reference);
5466                }
5467            }
5468        }
5469        Ok(QuerySnapshot {
5470            revision: revision.to_string(),
5471            rows,
5472            coverage: CoverageSnapshot {
5473                complete: pending.is_empty() && missing.is_empty(),
5474                pending,
5475                missing,
5476            },
5477        })
5478    })();
5479    match result {
5480        Ok(snapshot) => {
5481            conn.execute_batch("RELEASE syncular_snapshot_read")?;
5482            Ok(snapshot)
5483        }
5484        Err(mut error) => {
5485            if let Err(rollback) = conn
5486                .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
5487            {
5488                error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
5489            }
5490            Err(error)
5491        }
5492    }
5493}
5494
5495/// A long-lived read-only SQLite sidecar for latency-critical native views.
5496/// Network rounds stay serialized on the mutable core owner, while atomic
5497/// query snapshots use this independent connection and therefore never queue
5498/// behind HTTP/WebSocket latency.
5499pub struct FileQuerySnapshotReader {
5500    path: String,
5501    conn: Option<Connection>,
5502}
5503
5504impl FileQuerySnapshotReader {
5505    #[must_use]
5506    pub fn new(path: impl Into<String>) -> Self {
5507        Self {
5508            path: path.into(),
5509            conn: None,
5510        }
5511    }
5512
5513    fn connection(&mut self) -> Result<&Connection, String> {
5514        if self.conn.is_none() {
5515            let conn = Connection::open_with_flags(
5516                &self.path,
5517                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
5518            )
5519            .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
5520            conn.busy_timeout(std::time::Duration::from_millis(250))
5521                .map_err(|error| error.to_string())?;
5522            self.conn = Some(conn);
5523        }
5524        self.conn
5525            .as_ref()
5526            .ok_or_else(|| "read sidecar connection missing".to_owned())
5527    }
5528
5529    /// Hosts report an owned failure, and the first success after it, to the
5530    /// owning core through [`SyncClient::record_query_read`] (§7.6).
5531    pub fn query_snapshot(
5532        &mut self,
5533        sql: &str,
5534        params: &[Value],
5535        coverage: &[WindowCoverage],
5536    ) -> Result<QuerySnapshot, QueryReadFailure> {
5537        snapshot_connection(self.connection()?, sql, params, coverage)
5538    }
5539}
5540
5541impl SyncClient {
5542    pub fn new_with_identity(
5543        client_id: Option<String>,
5544        schema_json: &Value,
5545        limits: ClientLimits,
5546    ) -> Result<Self, String> {
5547        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5548        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5549        Self::with_connection(resolved, schema_json, limits, conn)
5550    }
5551
5552    pub fn new(
5553        client_id: String,
5554        schema_json: &Value,
5555        limits: ClientLimits,
5556    ) -> Result<Self, String> {
5557        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5558        Self::with_connection(client_id, schema_json, limits, conn)
5559    }
5560
5561    /// Build a client backed by an on-disk SQLite database at `path` — the
5562    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
5563    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
5564    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
5565    /// of the command router's dependency set (the router only holds a path).
5566    pub fn open_path(
5567        client_id: String,
5568        schema_json: &Value,
5569        limits: ClientLimits,
5570        path: &str,
5571    ) -> Result<Self, String> {
5572        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5573        Self::with_connection(client_id, schema_json, limits, conn)
5574    }
5575
5576    pub fn open_path_with_identity(
5577        client_id: Option<String>,
5578        schema_json: &Value,
5579        limits: ClientLimits,
5580        path: &str,
5581    ) -> Result<Self, String> {
5582        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5583        // File-backed native clients use an independent read connection for
5584        // latency-critical snapshots. WAL is SQLite's intended reader/writer
5585        // concurrency mode: a view read never holds a rollback-journal lock
5586        // that delays the mutable client's next commit, and a short busy
5587        // timeout absorbs the tiny checkpoint/schema-lock windows.
5588        conn.busy_timeout(std::time::Duration::from_millis(250))
5589            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
5590        conn.pragma_update(None, "journal_mode", "WAL")
5591            .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
5592        let persisted = conn
5593            .query_row(
5594                "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
5595                [],
5596                |row| row.get::<_, String>(0),
5597            )
5598            .ok();
5599        let resolved = persisted
5600            .clone()
5601            .or(client_id.clone())
5602            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5603        if let (Some(existing), Some(requested)) = (persisted, client_id) {
5604            if existing != requested {
5605                return Err(format!(
5606                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
5607                ));
5608            }
5609        }
5610        Self::with_connection(resolved, schema_json, limits, conn)
5611    }
5612
5613    #[must_use]
5614    pub fn client_id(&self) -> &str {
5615        &self.client_id
5616    }
5617
5618    /// Build a client over a caller-supplied rusqlite connection — the seam a
5619    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
5620    /// with an on-disk database (`Connection::open(path)`) rather than the
5621    /// default `:memory:`. The connection MUST be fresh (no pre-existing
5622    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
5623    /// the same file across process restarts reuses the persisted rows.
5624    pub fn with_connection(
5625        client_id: String,
5626        schema_json: &Value,
5627        limits: ClientLimits,
5628        conn: Connection,
5629    ) -> Result<Self, String> {
5630        if limits.outcome_retention_max_entries == Some(0) {
5631            return Err(
5632                "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
5633            );
5634        }
5635        let schema = parse_schema_json(schema_json)?;
5636        // RFC 0005 D8: resolve the previous-version config before the opening
5637        // reset runs — the capture happens inside it. Bad bounds fail loud.
5638        let previous_version = match limits.previous_version_context {
5639            Some(config) => {
5640                config.validate()?;
5641                Some(config)
5642            }
5643            None => None,
5644        };
5645        let mut client = SyncClient {
5646            #[cfg(feature = "bench-internals")]
5647            benchmark_phases: Recorder::default(),
5648            conn,
5649            schema,
5650            client_id,
5651            limits,
5652            subs: Vec::new(),
5653            outbox: Vec::new(),
5654            failed_commits: Vec::new(),
5655            retain_failed_commits: false,
5656            conflicts: Vec::new(),
5657            rejections: Vec::new(),
5658            schema_floor: None,
5659            lease_state: None,
5660            stopped: false,
5661            upgrading: false,
5662            sync_needed: false,
5663            realtime_policy: RealtimePolicy::Optional,
5664            realtime_state: RealtimeState::Disconnected,
5665            realtime_reason_code: None,
5666            realtime_retry_delay_ms: None,
5667            presence: HashMap::new(),
5668            now_ms: None,
5669            encryption: crate::values::EncryptionConfig::default(),
5670            security_preflight: false,
5671            previous_version,
5672            insert_sql: RefCell::new(HashMap::new()),
5673            overlay_dirty: Cell::new(false),
5674            storage_failure: RefCell::new(None),
5675            #[cfg(test)]
5676            overlay_rebuild_count: Cell::new(0),
5677            #[cfg(test)]
5678            outcome_prune_count: Cell::new(0),
5679            progress: ProgressObserver::default(),
5680            change_queue: VecDeque::new(),
5681            sync_intent_queue: VecDeque::new(),
5682            retry_delay_ms: 250,
5683            round_retry_delay_ms: None,
5684            last_round: None,
5685            last_change: None,
5686            query_failures: Vec::new(),
5687        };
5688        // The row write path leans on the prepared-statement cache (two
5689        // insert statements per synced table, plus the bookkeeping
5690        // statements); size it so a multi-table schema never thrashes.
5691        client
5692            .conn
5693            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
5694        // Protected bookkeeping must exist before inspecting the persisted
5695        // schema marker. New app indexes may reference columns that only
5696        // exist after a version-bump reset.
5697        client.create_bookkeeping_tables()?;
5698        match client.get_meta(CLIENT_ID_KEY) {
5699            Some(existing) if existing != client.client_id => {
5700                return Err(format!(
5701                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
5702                    client.client_id
5703                ));
5704            }
5705            None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
5706            _ => {}
5707        }
5708        client.restore_persisted_state()?;
5709        client.failed_commits = client.load_failed_commits()?;
5710        let marker = client
5711            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
5712            .and_then(|value| value.parse::<i32>().ok());
5713        match marker {
5714            None => {
5715                client.create_synced_tables()?;
5716                client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
5717                // D1: the descriptor is written beside every marker write.
5718                set_local_schema_descriptor(&client.conn, &client.schema);
5719            }
5720            Some(version) if version == client.schema.version => {
5721                client.create_synced_tables()?;
5722                // RFC 0005 D1: the same-version open backfills the descriptor
5723                // for a database first opened by an unaware binary, so the
5724                // NEXT bump can capture without needing a schema bump first.
5725                set_local_schema_descriptor(&client.conn, &client.schema);
5726            }
5727            Some(_) => client.run_schema_reset()?,
5728        }
5729        // RFC 0005 D9: the reset sweep cannot see a container whose capture
5730        // committed in its OWN file while the replica's savepoint never
5731        // released — a crash at that seam leaves a container beside a matching
5732        // OLD marker, and the next open at that version runs no reset. Discard
5733        // it before anything can read it. No-op when no container exists.
5734        client.reconcile_previous_version_at_boot()?;
5735        client.clear_satisfied_persisted_schema_floor();
5736        client.prune_unknown_subscriptions()?;
5737        if marker == Some(client.schema.version)
5738            && (!client.outbox.is_empty() || !client.failed_commits.is_empty())
5739        {
5740            // Reconstruct the visible optimistic overlay from the durable base
5741            // plus outbox instead of trusting a process-interrupted mirror.
5742            client.overlay_dirty.set(true);
5743            client.rebuild_overlay();
5744        }
5745        // Every persisted active subscription needs one catch-up pull on open:
5746        // realtime only covers changes after connection, while an idempotent
5747        // setWindow correctly creates no fresh command effect. Pending outbox
5748        // work has the same restart requirement. The core owns this intent so
5749        // native hosts never poll or require an application-issued sync().
5750        client.enqueue_startup_sync_if_needed()?;
5751        Ok(client)
5752    }
5753
5754    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
5755    /// against this instead of system time (conformance virtual clock).
5756    pub fn set_now_ms(&mut self, now_ms: i64) {
5757        self.now_ms = Some(now_ms);
5758    }
5759
5760    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
5761    /// The command router parses these from the `create` command's
5762    /// `encryption` config (keys as `{$bytes: hex}`).
5763    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
5764        self.encryption = encryption;
5765    }
5766
5767    #[must_use]
5768    pub fn security_lifecycle(&self) -> &'static str {
5769        if self.security_preflight {
5770            "preflight"
5771        } else {
5772            "active"
5773        }
5774    }
5775
5776    #[must_use]
5777    pub fn security_preflight(&self) -> bool {
5778        self.security_preflight
5779    }
5780
5781    /// Quarantine this replica: enter the fail-closed gate, release all
5782    /// core-owned key material, and record the gate in the database so
5783    /// reopening re-enters preflight until `activate_security` clears it.
5784    ///
5785    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
5786    /// which must NOT leave that durable mark.
5787    pub fn begin_security_preflight(&mut self) {
5788        self.seal_security_on_teardown();
5789        // Persist the quarantine so it survives handle teardown and restart:
5790        // reopening this replica re-enters preflight until activation clears it.
5791        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
5792    }
5793
5794    /// Release core-owned key material as a host tears the client down,
5795    /// WITHOUT recording a quarantine.
5796    ///
5797    /// Shutting an activated client down is not a quarantine event. Persisting
5798    /// the gate here marks every cleanly closed replica as pending, and the
5799    /// next plain `create` is then refused permanently: the reopen path
5800    /// restores the flag from the marker and the create guard rejects it. The
5801    /// in-memory flag still closes the gate for anything still holding this
5802    /// instance, which is all a teardown barrier needs.
5803    pub fn seal_security_on_teardown(&mut self) {
5804        self.security_preflight = true;
5805        self.encryption = crate::values::EncryptionConfig::default();
5806        self.sync_intent_queue.clear();
5807    }
5808
5809    /// Install the post-authentication keyring and release the host loop.
5810    pub fn activate_security(
5811        &mut self,
5812        encryption: crate::values::EncryptionConfig,
5813    ) -> Result<(), String> {
5814        if !self.security_preflight {
5815            return Err(
5816                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
5817            );
5818        }
5819        self.enqueue_startup_sync_if_needed()?;
5820        self.encryption = encryption;
5821        self.security_preflight = false;
5822        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
5823        Ok(())
5824    }
5825
5826    fn clock_now_ms(&self) -> i64 {
5827        self.now_ms.unwrap_or_else(|| {
5828            std::time::SystemTime::now()
5829                .duration_since(std::time::UNIX_EPOCH)
5830                .map(|d| d.as_millis() as i64)
5831                .unwrap_or(0)
5832        })
5833    }
5834
5835    fn create_bookkeeping_tables(&self) -> Result<(), String> {
5836        // Durable client bookkeeping (outbox + subscription + meta).
5837        self.conn
5838            .execute_batch(
5839                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
5840                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
5841                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
5842                 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
5843                   seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
5844                   operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
5845                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
5846                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
5847                   client_commit_id TEXT NOT NULL UNIQUE,
5848                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
5849                   recorded_at_ms INTEGER NOT NULL,
5850                   results_json TEXT NOT NULL,
5851                   operations_json TEXT,
5852                   resolution TEXT NOT NULL DEFAULT 'active'
5853                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
5854                   resolved_at_ms INTEGER,
5855                   replacement_client_commit_id TEXT);
5856                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
5857                   ON _syncular_commit_outcomes(resolution, seq);
5858                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
5859                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
5860                 CREATE TABLE IF NOT EXISTS _syncular_meta (
5861                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
5862                 CREATE TABLE IF NOT EXISTS _syncular_windows (
5863                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
5864                   PRIMARY KEY (base, unit));
5865                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
5866                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
5867                   effective_scopes TEXT NOT NULL);
5868                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
5869                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
5870                   PRIMARY KEY(commit_id, blob_id));
5871                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
5872                   ON _syncular_blob_commit_refs(blob_id);",
5873            )
5874            .map_err(|e| e.to_string())?;
5875        // Migrate an outcome journal created before failed aggregate
5876        // envelopes were retained. Historical rows intentionally stay NULL.
5877        let _ = self
5878            .conn
5879            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
5880        if self.get_meta(LOCAL_REVISION_KEY).is_none() {
5881            self.set_meta(LOCAL_REVISION_KEY, "0");
5882        }
5883        // §5.9.7 blob cache. Mutable upload state stays off the body row so
5884        // completing a large upload does not rewrite its BLOB record.
5885        if self.schema_has_blobs() {
5886            let exists = self
5887                .conn
5888                .query_row(
5889                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
5890                    [],
5891                    |row| row.get::<_, bool>(0),
5892                )
5893                .map_err(|e| e.to_string())?;
5894            if exists {
5895                let mut statement = self
5896                    .conn
5897                    .prepare("PRAGMA table_info(_syncular_blobs)")
5898                    .map_err(|e| e.to_string())?;
5899                let mut columns = statement
5900                    .query_map([], |row| row.get::<_, String>(1))
5901                    .map_err(|e| e.to_string())?
5902                    .collect::<Result<Vec<_>, _>>()
5903                    .map_err(|e| e.to_string())?;
5904                columns.sort();
5905                let mut expected = vec![
5906                    "blob_id",
5907                    "byte_length",
5908                    "bytes",
5909                    "created_at_ms",
5910                    "media_type",
5911                ];
5912                expected.sort_unstable();
5913                if columns != expected {
5914                    return Err(
5915                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
5916                    );
5917                }
5918            } else {
5919                self.conn
5920                    .execute_batch(
5921                        "CREATE TABLE _syncular_blobs(
5922                           blob_id TEXT PRIMARY KEY,
5923                           bytes BLOB NOT NULL,
5924                           byte_length INTEGER NOT NULL,
5925                           media_type TEXT,
5926                           created_at_ms INTEGER NOT NULL);",
5927                    )
5928                    .map_err(|e| e.to_string())?;
5929            }
5930            self.conn
5931                .execute_batch(
5932                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
5933                       blob_id TEXT PRIMARY KEY,
5934                       media_type TEXT,
5935                       created_at_ms INTEGER NOT NULL);",
5936                )
5937                .map_err(|e| e.to_string())?;
5938        }
5939        Ok(())
5940    }
5941
5942    /// True iff any synced table declares a `blob_ref` column (§5.9).
5943    fn schema_has_blobs(&self) -> bool {
5944        self.schema
5945            .tables
5946            .iter()
5947            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
5948    }
5949
5950    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
5951
5952    fn get_meta(&self, key: &str) -> Option<String> {
5953        meta_get(&self.conn, key)
5954    }
5955
5956    fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
5957        self.conn
5958            .query_row(
5959                "SELECT value FROM _syncular_meta WHERE key = ?1",
5960                rusqlite::params![key],
5961                |row| row.get::<_, String>(0),
5962            )
5963            .optional()
5964            .map_err(|_| {
5965                "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
5966            })
5967    }
5968
5969    fn set_meta(&self, key: &str, value: &str) {
5970        meta_set(&self.conn, key, value);
5971    }
5972
5973    fn delete_meta(&self, key: &str) {
5974        meta_delete(&self.conn, key);
5975    }
5976
5977    fn restore_persisted_state(&mut self) -> Result<(), String> {
5978        self.subs = {
5979            let mut stmt = self
5980                .conn
5981                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
5982                .map_err(|error| error.to_string())?;
5983            let rows = stmt
5984                .query_map([], |row| {
5985                    Ok((
5986                        row.get::<_, String>(0)?,
5987                        row.get::<_, String>(1)?,
5988                        row.get::<_, String>(2)?,
5989                    ))
5990                })
5991                .map_err(|error| error.to_string())?;
5992            let mut subscriptions = Vec::new();
5993            for row in rows {
5994                let (id, table, raw) = row.map_err(|error| error.to_string())?;
5995                let state: Value = serde_json::from_str(&raw)
5996                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
5997                let requested = json_to_scope_map(
5998                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
5999                )?;
6000                let effective = state
6001                    .get("effectiveScopes")
6002                    .filter(|value| !value.is_null())
6003                    .map(json_to_scope_map)
6004                    .transpose()?;
6005                subscriptions.push(Subscription {
6006                    id,
6007                    table,
6008                    requested,
6009                    params: state
6010                        .get("params")
6011                        .and_then(Value::as_str)
6012                        .map(str::to_owned),
6013                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
6014                    bootstrap_state: state
6015                        .get("bootstrapState")
6016                        .and_then(Value::as_str)
6017                        .map(str::to_owned),
6018                    state: SubState::parse(
6019                        state
6020                            .get("status")
6021                            .and_then(Value::as_str)
6022                            .unwrap_or("active"),
6023                    ),
6024                    reason_code: state
6025                        .get("reasonCode")
6026                        .and_then(Value::as_str)
6027                        .map(str::to_owned),
6028                    effective,
6029                    synced_once: state
6030                        .get("syncedOnce")
6031                        .and_then(Value::as_bool)
6032                        .unwrap_or(false),
6033                });
6034            }
6035            subscriptions
6036        };
6037
6038        self.outbox = {
6039            let mut stmt = self
6040                .conn
6041                .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
6042                .map_err(|error| error.to_string())?;
6043            let rows = stmt
6044                .query_map([], |row| {
6045                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6046                })
6047                .map_err(|error| error.to_string())?;
6048            let mut commits = Vec::new();
6049            for row in rows {
6050                let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
6051                let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
6052                    format!("invalid persisted outbox {client_commit_id:?}: {error}")
6053                })?;
6054                let mut ops = Vec::with_capacity(entries.len());
6055                for entry in entries {
6056                    let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
6057                    ops.push(OutboxOp {
6058                        upsert: op == "upsert",
6059                        table: entry
6060                            .get("table")
6061                            .and_then(Value::as_str)
6062                            .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
6063                            .to_owned(),
6064                        row_id: entry
6065                            .get("rowId")
6066                            .and_then(Value::as_str)
6067                            .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
6068                            .to_owned(),
6069                        base_version: entry.get("baseVersion").and_then(Value::as_i64),
6070                        // A legacy entry's `changedFields` key is ignored:
6071                        // the values map is the presence set (§6.1).
6072                        values: entry.get("values").and_then(Value::as_object).cloned(),
6073                    });
6074                }
6075                commits.push(OutboxCommit {
6076                    client_commit_id,
6077                    ops,
6078                });
6079            }
6080            commits
6081        };
6082
6083        self.prune_commit_outcomes()?;
6084        let active = self.commit_outcomes(CommitOutcomeQuery {
6085            active_only: true,
6086            ..CommitOutcomeQuery::default()
6087        })?;
6088        self.conflicts = active
6089            .iter()
6090            .flat_map(|outcome| outcome.results.iter())
6091            .filter_map(|result| match result {
6092                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
6093                _ => None,
6094            })
6095            .collect();
6096        self.rejections = active
6097            .iter()
6098            .flat_map(|outcome| outcome.results.iter())
6099            .filter_map(|result| match result {
6100                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
6101                _ => None,
6102            })
6103            .collect();
6104
6105        self.lease_state = self
6106            .get_meta(LEASE_STATE_KEY)
6107            .map(|raw| serde_json::from_str(&raw))
6108            .transpose()
6109            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
6110        self.schema_floor = self
6111            .get_meta(SCHEMA_FLOOR_KEY)
6112            .map(|raw| serde_json::from_str(&raw))
6113            .transpose()
6114            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
6115        self.stopped = self.schema_floor.is_some();
6116        // A replica left in unactivated preflight reopens gated: the quarantine
6117        // decision persists with the data across handle teardown and restart.
6118        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
6119            self.security_preflight = true;
6120        }
6121        Ok(())
6122    }
6123
6124    /// Remove registrations for tables the running schema no longer knows.
6125    /// Otherwise the server rejects every pull with `sync.unknown_table`.
6126    fn prune_unknown_subscriptions(&mut self) -> Result<(), String> {
6127        let valid_tables: BTreeSet<String> = self
6128            .schema
6129            .tables
6130            .iter()
6131            .map(|table| table.name.clone())
6132            .collect();
6133        let stale_ids: Vec<String> = self
6134            .subs
6135            .iter()
6136            .filter(|sub| !valid_tables.contains(&sub.table))
6137            .map(|sub| sub.id.clone())
6138            .collect();
6139        for id in &stale_ids {
6140            self.conn
6141                .execute(
6142                    "DELETE FROM _syncular_windows WHERE sub_id = ?1",
6143                    rusqlite::params![id],
6144                )
6145                .map_err(|error| error.to_string())?;
6146            self.conn
6147                .execute(
6148                    "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
6149                    rusqlite::params![id],
6150                )
6151                .map_err(|error| error.to_string())?;
6152            self.conn
6153                .execute(
6154                    "DELETE FROM _syncular_subscriptions WHERE id = ?1",
6155                    rusqlite::params![id],
6156                )
6157                .map_err(|error| error.to_string())?;
6158        }
6159        self.subs.retain(|sub| valid_tables.contains(&sub.table));
6160        Ok(())
6161    }
6162
6163    #[must_use]
6164    pub fn local_revision(&self) -> u64 {
6165        self.get_meta(LOCAL_REVISION_KEY)
6166            .and_then(|value| value.parse().ok())
6167            .unwrap_or(0)
6168    }
6169
6170    #[must_use]
6171    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
6172        SyncStatusSnapshot {
6173            current_schema_version: self.schema.version,
6174            outbox: self.outbox.len(),
6175            upgrading: self.upgrading,
6176            lease_state: self.lease_state.clone(),
6177            schema_floor: self.schema_floor.clone(),
6178            sync_needed: self.sync_needed,
6179            previous_version_context: self.previous_version_status(),
6180        }
6181    }
6182
6183    /// RFC 0005 A2: container presence for a host that must act on it (Diego's
6184    /// rollback precondition), read without creating the file.
6185    fn previous_version_status(&self) -> PreviousVersionStatus {
6186        let Some(replica_path) = self.previous_version_replica_path() else {
6187            return PreviousVersionStatus {
6188                present: false,
6189                created_at_ms: None,
6190            };
6191        };
6192        match crate::previous_version::read_previous_version_record(&replica_path) {
6193            Ok(Some(record)) => PreviousVersionStatus {
6194                present: true,
6195                created_at_ms: Some(record.created_at_ms),
6196            },
6197            _ => PreviousVersionStatus {
6198                present: std::path::Path::new(
6199                    &crate::previous_version::previous_version_container_path(&replica_path),
6200                )
6201                .exists(),
6202                created_at_ms: None,
6203            },
6204        }
6205    }
6206
6207    /// RFC 0005 D7: read captured rows from the previous local schema. `state`
6208    /// is always `previousVersion`; the container never makes
6209    /// `query_snapshot()` coverage complete. Lease stop/expiry, scope
6210    /// revocation, coverage completion and the TTL DISCARD the container and
6211    /// close the read with their own reason.
6212    pub fn previous_version_snapshot(
6213        &self,
6214        spec: &PreviousVersionReadSpec,
6215    ) -> Result<PreviousVersionSnapshot, String> {
6216        let now_ms = self.clock_now_ms();
6217        crate::previous_version::previous_version_snapshot(
6218            &self.conn,
6219            self.previous_version_replica_path().as_deref(),
6220            self.previous_version.as_ref(),
6221            PreviousVersionLifecycle {
6222                lease_inactive: self.previous_version_lease_inactive(now_ms),
6223                scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
6224                coverage_complete: self.previous_version_coverage_complete(),
6225            },
6226            now_ms,
6227            self.schema.version,
6228            spec,
6229        )
6230    }
6231
6232    /// RFC 0005 D6: the advisory pre-reset compatibility audit.
6233    #[must_use]
6234    pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
6235        crate::previous_version::stored_previous_version_audit(&self.conn)
6236    }
6237
6238    /// RFC 0005 A2/D9: the executable downgrade step. Drop the container file
6239    /// and both metadata records, and report whether anything was present.
6240    /// Idempotent: absence is a successful no-op.
6241    pub fn previous_version_discard(
6242        &self,
6243    ) -> crate::previous_version::PreviousVersionDiscardOutcome {
6244        self.drop_previous_version()
6245    }
6246
6247    /// The module's discard has no failure path (it removes the file even when
6248    /// the container cannot be opened), so the only error a caller could ever
6249    /// see is a bug; report absence rather than claiming a discard happened.
6250    fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
6251        let absent = crate::previous_version::PreviousVersionDiscardOutcome {
6252            present: false,
6253            discarded: false,
6254        };
6255        let Some(replica_path) = self.previous_version_replica_path() else {
6256            return absent;
6257        };
6258        crate::previous_version::discard_previous_version(&self.conn, &replica_path)
6259            .unwrap_or(absent)
6260    }
6261
6262    /// RFC 0005 D7: `leaseState.errorCode` or an expired lease.
6263    fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
6264        let Some(lease) = &self.lease_state else {
6265            return false;
6266        };
6267        if lease.error_code.is_some() {
6268            return true;
6269        }
6270        lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
6271    }
6272
6273    /// RFC 0005 D7: §7.4.5 replacement coverage — every ACTIVE subscription
6274    /// has `cursor >= 0` and no resume token. An empty active set is not
6275    /// completion (mirrors the TS predicate exactly).
6276    fn previous_version_coverage_complete(&self) -> bool {
6277        let active = self
6278            .subs
6279            .iter()
6280            .filter(|sub| sub.state == SubState::Active)
6281            .collect::<Vec<_>>();
6282        if active.is_empty() {
6283            return false;
6284        }
6285        active
6286            .iter()
6287            .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
6288    }
6289
6290    /// RFC 0005 D7: lifetime triggers evaluated once per sync round, after the
6291    /// apply. A container that no longer has a reason to exist is discarded so
6292    /// the rows do not outlive the window they were kept for.
6293    fn previous_version_lifetime_check(&self) {
6294        let Some(config) = self.previous_version.filter(|config| config.enabled) else {
6295            return;
6296        };
6297        let now_ms = self.clock_now_ms();
6298        if self.previous_version_lease_inactive(now_ms) {
6299            let _ = self.drop_previous_version();
6300            return;
6301        }
6302        let Some(replica_path) = self.previous_version_replica_path() else {
6303            return;
6304        };
6305        let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
6306        else {
6307            return;
6308        };
6309        if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
6310            let _ = self.drop_previous_version();
6311            return;
6312        }
6313        if now_ms - record.created_at_ms > config.max_age_ms {
6314            let _ = self.drop_previous_version();
6315            return;
6316        }
6317        if self.previous_version_coverage_complete() {
6318            let _ = self.drop_previous_version();
6319        }
6320    }
6321
6322    pub fn diagnostics_snapshot(
6323        &self,
6324        request: &ClientDiagnosticsRequest,
6325    ) -> Result<ClientDiagnosticsSnapshot, String> {
6326        if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
6327            return Err(format!(
6328                "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
6329            ));
6330        }
6331        let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
6332        for sub in &self.subs {
6333            let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
6334            let complete =
6335                sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
6336            let state = match sub.state {
6337                SubState::Revoked => "revoked",
6338                SubState::Failed => "failed",
6339                SubState::Active if reset => "reset",
6340                SubState::Active if complete => "complete",
6341                SubState::Active => "bootstrapping",
6342            };
6343            subscriptions.insert(
6344                sub.id.clone(),
6345                DiagnosticSubscription {
6346                    id: sub.id.clone(),
6347                    table: sub.table.clone(),
6348                    state: state.to_owned(),
6349                    complete,
6350                    cursor: Some(sub.cursor),
6351                    reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
6352                },
6353            );
6354        }
6355        for expected in &request.expected_subscriptions {
6356            if expected.id.is_empty() || expected.table.is_empty() {
6357                return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
6358            }
6359            if subscriptions
6360                .get(&expected.id)
6361                .is_some_and(|registered| registered.table != expected.table)
6362            {
6363                subscriptions.insert(
6364                    expected.id.clone(),
6365                    DiagnosticSubscription {
6366                        id: expected.id.clone(),
6367                        table: expected.table.clone(),
6368                        state: "failed".to_owned(),
6369                        complete: false,
6370                        cursor: None,
6371                        reason_code: Some("client.subscription_intent_mismatch".to_owned()),
6372                    },
6373                );
6374            } else {
6375                subscriptions.entry(expected.id.clone()).or_insert_with(|| {
6376                    DiagnosticSubscription {
6377                        id: expected.id.clone(),
6378                        table: expected.table.clone(),
6379                        state: "unregistered".to_owned(),
6380                        complete: false,
6381                        cursor: None,
6382                        reason_code: None,
6383                    }
6384                });
6385            }
6386        }
6387        let mut ordered_subscriptions = Vec::new();
6388        let mut included = BTreeSet::new();
6389        for expected in &request.expected_subscriptions {
6390            if included.insert(expected.id.clone()) {
6391                if let Some(subscription) = subscriptions.get(&expected.id) {
6392                    ordered_subscriptions.push(subscription.clone());
6393                }
6394            }
6395        }
6396        for (id, subscription) in &subscriptions {
6397            if included.insert(id.clone()) {
6398                ordered_subscriptions.push(subscription.clone());
6399            }
6400        }
6401        let subscriptions_truncated =
6402            ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
6403        ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
6404        let captured_at_ms = self.clock_now_ms();
6405        let lease = if let Some(error_code) = self
6406            .lease_state
6407            .as_ref()
6408            .and_then(|state| state.error_code.clone())
6409        {
6410            ClientDiagnosticsLease {
6411                state: "stopped".to_owned(),
6412                expires_at_ms: self
6413                    .lease_state
6414                    .as_ref()
6415                    .and_then(|state| state.expires_at_ms),
6416                error_code: Some(Self::diagnostic_code(&error_code)),
6417            }
6418        } else if let Some(expires_at_ms) = self
6419            .lease_state
6420            .as_ref()
6421            .and_then(|state| state.expires_at_ms)
6422        {
6423            ClientDiagnosticsLease {
6424                state: if expires_at_ms <= captured_at_ms {
6425                    "expired".to_owned()
6426                } else {
6427                    "active".to_owned()
6428                },
6429                expires_at_ms: Some(expires_at_ms),
6430                error_code: None,
6431            }
6432        } else {
6433            ClientDiagnosticsLease {
6434                state: "none".to_owned(),
6435                expires_at_ms: None,
6436                error_code: None,
6437            }
6438        };
6439        let connectivity = match self.last_round.as_ref() {
6440            Some(round) if round.status == "succeeded" => "online",
6441            Some(round)
6442                if round.status == "failed"
6443                    && round
6444                        .error_code
6445                        .as_deref()
6446                        .is_some_and(Self::retryable_transport_code) =>
6447            {
6448                "offline"
6449            }
6450            _ => "unknown",
6451        };
6452        Ok(ClientDiagnosticsSnapshot {
6453            version: CLIENT_DIAGNOSTICS_VERSION,
6454            captured_at_ms,
6455            host: ClientDiagnosticsHost {
6456                kind: "direct".to_owned(),
6457                role: "single".to_owned(),
6458                connectivity: connectivity.to_owned(),
6459                realtime: self.realtime_state,
6460                realtime_policy: self.realtime_policy,
6461                realtime_reason_code: self.realtime_reason_code.clone(),
6462                realtime_retry_delay_ms: self.realtime_retry_delay_ms,
6463            },
6464            security_lifecycle: self.security_lifecycle().to_owned(),
6465            schema: ClientDiagnosticsSchema {
6466                current_version: self.schema.version,
6467                upgrading: self.upgrading,
6468                required_version: self
6469                    .schema_floor
6470                    .as_ref()
6471                    .and_then(|floor| floor.required_schema_version),
6472                latest_version: self
6473                    .schema_floor
6474                    .as_ref()
6475                    .and_then(|floor| floor.latest_schema_version),
6476            },
6477            replica: ClientDiagnosticsReplica {
6478                local_revision: self.local_revision().to_string(),
6479                sync_needed: self.sync_needed,
6480                pending_outbox: self.outbox.len(),
6481            },
6482            lease,
6483            subscriptions: ordered_subscriptions,
6484            subscriptions_truncated,
6485            last_round: self.last_round.clone(),
6486            last_change: self.last_change.clone(),
6487            storage: self.diagnostics_storage(),
6488            query_failures: self.query_failures.clone(),
6489        })
6490    }
6491
6492    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
6493        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
6494            let page_count: i64 = self
6495                .conn
6496                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
6497            let page_size: i64 = self
6498                .conn
6499                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
6500            // Reuse compiled aggregates; every invocation still reads current storage.
6501            let outbox_bytes: i64 = self
6502                .conn
6503                .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
6504                .query_row([], |row| row.get(0))?;
6505            let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
6506                "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
6507            )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
6508            let blob_bytes = if self.schema_has_blobs() {
6509                self.conn
6510                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
6511                    .query_row([], |row| row.get(0))?
6512            } else {
6513                0
6514            };
6515            let pressure = self
6516                .limits
6517                .blob_cache_max_bytes
6518                .is_some_and(|limit| blob_bytes > limit);
6519            Ok(ClientDiagnosticsStorage {
6520                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
6521                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
6522                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
6523                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
6524                retained_outcome_entries: Some(outcome_entries.max(0)),
6525                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
6526                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
6527            })
6528        };
6529        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
6530            status: "unreadable".to_owned(),
6531            database_bytes_approx: None,
6532            pending_outbox_bytes_approx: None,
6533            retained_outcome_bytes_approx: None,
6534            retained_outcome_entries: None,
6535            blob_cache_bytes_approx: None,
6536            pressure_reason_code: None,
6537        })
6538    }
6539
6540    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
6541        self.change_queue.drain(..).collect()
6542    }
6543
6544    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
6545        self.sync_intent_queue.drain(..).collect()
6546    }
6547
6548    fn schedule_background_retry(&mut self) -> u64 {
6549        let delay_ms = self.retry_delay_ms;
6550        self.sync_intent_queue
6551            .push_back(SyncIntent::Background { delay_ms });
6552        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
6553        self.round_retry_delay_ms = Some(delay_ms);
6554        delay_ms
6555    }
6556
6557    fn reset_background_retry(&mut self) {
6558        self.retry_delay_ms = 250;
6559    }
6560
6561    fn retryable_transport_code(code: &str) -> bool {
6562        code == "transport.failed"
6563            || code == "transport.unavailable"
6564            || code == "sync.transport_failed"
6565    }
6566
6567    /// A failed round worth a background retry: a transport failure, or a
6568    /// §10.2 code the catalog marks retryable (clients may hardcode the
6569    /// catalog metadata, §10.1). Matches the TypeScript core, which retries
6570    /// on the server's `retryable` flag.
6571    fn retryable_failure_code(code: &str) -> bool {
6572        Self::retryable_transport_code(code)
6573            || matches!(
6574                code,
6575                "sync.auth_required"
6576                    | "sync.auth_lease_required"
6577                    | "sync.auth_lease_revoked"
6578                    | "sync.internal_error"
6579                    | "sync.idempotency_cache_miss"
6580                    | "sync.schema_not_ready"
6581                    | "sync.segment_expired"
6582                    | "sync.rate_limited"
6583                    | "sync.websocket_connection_limit"
6584            )
6585    }
6586
6587    fn diagnostic_code(code: &str) -> String {
6588        let valid = !code.is_empty()
6589            && code.len() <= 96
6590            && code.contains('.')
6591            && code
6592                .bytes()
6593                .next()
6594                .is_some_and(|byte| byte.is_ascii_lowercase())
6595            && code.bytes().all(|byte| {
6596                byte.is_ascii_lowercase()
6597                    || byte.is_ascii_digit()
6598                    || matches!(byte, b'.' | b'_' | b'-')
6599            });
6600        if valid {
6601            code.to_owned()
6602        } else {
6603            "client.unknown_failure".to_owned()
6604        }
6605    }
6606
6607    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
6608        if self.sync_needed != value {
6609            if self.begin_observation("syncular_status").is_ok() {
6610                self.sync_needed = value;
6611                let batch = ChangeAccumulator {
6612                    status: true,
6613                    ..ChangeAccumulator::default()
6614                };
6615                if self.finish_observation("syncular_status", batch).is_err() {
6616                    self.rollback_observation("syncular_status");
6617                }
6618            } else {
6619                self.sync_needed = value;
6620            }
6621        }
6622        if value && interactive {
6623            self.sync_intent_queue.push_back(SyncIntent::Interactive);
6624        }
6625    }
6626
6627    fn sqlite_failure(
6628        storage_failure: &RefCell<Option<QueryReadFailure>>,
6629        error: rusqlite::Error,
6630    ) -> String {
6631        let message = error.to_string();
6632        let failure = QueryReadFailure::from(error);
6633        let code = failure.code;
6634        if code.is_some() && storage_failure.borrow().is_none() {
6635            *storage_failure.borrow_mut() = Some(failure);
6636        }
6637        match code {
6638            Some(code) => format!("{code}: {message}"),
6639            None => message,
6640        }
6641    }
6642
6643    fn begin_observation(&self, name: &str) -> Result<(), String> {
6644        self.conn
6645            .execute_batch(&format!("SAVEPOINT {name}"))
6646            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
6647    }
6648
6649    fn rollback_observation(&self, name: &str) {
6650        if let Err(error) = self
6651            .conn
6652            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
6653        {
6654            if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
6655                if failure.rollback_failure.is_none() {
6656                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
6657                }
6658            }
6659        }
6660    }
6661
6662    fn finish_observation(
6663        &mut self,
6664        name: &str,
6665        mut batch: ChangeAccumulator,
6666    ) -> Result<(), String> {
6667        #[cfg(feature = "bench-internals")]
6668        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
6669        if self.failed_commits.iter().any(|(commit, _)| {
6670            commit
6671                .ops
6672                .iter()
6673                .any(|operation| batch.tables.contains_key(&operation.table))
6674        }) {
6675            batch.outcomes = true;
6676        }
6677        if !batch.touched() {
6678            self.conn
6679                .execute_batch(&format!("RELEASE {name}"))
6680                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6681            return Ok(());
6682        }
6683        let revision = self
6684            .local_revision()
6685            .checked_add(1)
6686            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
6687        self.conn
6688            .execute(
6689                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
6690                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
6691            )
6692            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6693        let status = batch.status.then(|| self.status_snapshot());
6694        let event = ClientChangeBatch {
6695            revision: revision.to_string(),
6696            tables: batch
6697                .tables
6698                .into_iter()
6699                .map(|(table, scope_keys)| TableChange {
6700                    table,
6701                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
6702                })
6703                .collect(),
6704            windows: batch
6705                .windows
6706                .into_iter()
6707                .map(|((base_key, table), units)| WindowChange {
6708                    base_key,
6709                    table,
6710                    units: units.into_iter().collect(),
6711                })
6712                .collect(),
6713            status,
6714            conflicts_changed: batch.conflicts,
6715            rejections_changed: batch.rejections,
6716            outcomes_changed: batch.outcomes,
6717        };
6718        self.conn
6719            .execute_batch(&format!("RELEASE {name}"))
6720            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6721        let mut diagnostic_tables = event
6722            .tables
6723            .iter()
6724            .map(|entry| entry.table.clone())
6725            .collect::<BTreeSet<_>>()
6726            .into_iter()
6727            .collect::<Vec<_>>();
6728        let mut diagnostic_windows = event
6729            .windows
6730            .iter()
6731            .map(|entry| entry.table.clone())
6732            .collect::<BTreeSet<_>>()
6733            .into_iter()
6734            .collect::<Vec<_>>();
6735        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
6736            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
6737        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
6738        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
6739        self.last_change = Some(DiagnosticLastChange {
6740            revision: event.revision.clone(),
6741            recorded_at_ms: self.clock_now_ms(),
6742            tables: diagnostic_tables,
6743            windows: diagnostic_windows,
6744            domains_truncated,
6745            status_changed: event.status.is_some(),
6746            conflicts_changed: event.conflicts_changed,
6747            rejections_changed: event.rejections_changed,
6748            outcomes_changed: event.outcomes_changed,
6749        });
6750        self.change_queue.push_back(event);
6751        Ok(())
6752    }
6753
6754    fn record_scope_map(
6755        &self,
6756        batch: &mut ChangeAccumulator,
6757        table_name: &str,
6758        scopes: &[(String, Vec<String>)],
6759    ) {
6760        let Some(table) = self.schema.table(table_name) else {
6761            return;
6762        };
6763        for (variable, values) in scopes {
6764            let Some(scope) = table
6765                .scope_variables
6766                .iter()
6767                .find(|scope| &scope.variable == variable)
6768            else {
6769                continue;
6770            };
6771            for value in values {
6772                batch.scope(table_name, format!("{}:{value}", scope.prefix));
6773            }
6774        }
6775    }
6776
6777    /// Record a row's current scope keys from the base or visible table.
6778    fn record_row_scopes(
6779        &self,
6780        batch: &mut ChangeAccumulator,
6781        table_name: &str,
6782        row_id: &str,
6783        base: bool,
6784    ) -> bool {
6785        let Some(table) = self.schema.table(table_name) else {
6786            return false;
6787        };
6788        if table.scope_variables.is_empty() {
6789            return false;
6790        }
6791        let columns = table
6792            .scope_variables
6793            .iter()
6794            .map(|scope| quote_ident(&scope.column))
6795            .collect::<Vec<_>>()
6796            .join(", ");
6797        let full_table = if base {
6798            base_table(table_name)
6799        } else {
6800            visible_table(table_name)
6801        };
6802        let sql = format!(
6803            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
6804            row_id_predicate(table)
6805        );
6806        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
6807            return false;
6808        };
6809        let values = stmt.query_row(rusqlite::params![row_id], |row| {
6810            let mut values = Vec::with_capacity(table.scope_variables.len());
6811            for index in 0..table.scope_variables.len() {
6812                values.push(row.get::<_, Option<String>>(index)?);
6813            }
6814            Ok(values)
6815        });
6816        let Ok(values) = values else {
6817            return false;
6818        };
6819        let mut recorded = false;
6820        for (scope, value) in table.scope_variables.iter().zip(values) {
6821            if let Some(value) = value {
6822                batch.scope(table_name, format!("{}:{value}", scope.prefix));
6823                recorded = true;
6824            }
6825        }
6826        recorded
6827    }
6828
6829    fn record_commit_changes(
6830        &self,
6831        batch: &mut ChangeAccumulator,
6832        tables: &[String],
6833        changes: &[ssp2::model::Change],
6834    ) {
6835        #[cfg(feature = "bench-internals")]
6836        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
6837        for change in changes {
6838            let Some(table_name) = tables.get(change.table_index as usize) else {
6839                continue;
6840            };
6841            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
6842            if let Some(table) = self.schema.table(table_name) {
6843                for (variable, value) in &change.scopes {
6844                    if let Some(scope) = table
6845                        .scope_variables
6846                        .iter()
6847                        .find(|scope| &scope.variable == variable)
6848                    {
6849                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
6850                        precise = true;
6851                    }
6852                }
6853            }
6854            if !precise {
6855                batch.table(table_name);
6856            }
6857        }
6858    }
6859
6860    fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
6861        if effective.is_empty() {
6862            return false;
6863        }
6864        let Some(table) = self.schema.table(table_name) else {
6865            return false;
6866        };
6867        let mut clauses = Vec::new();
6868        let mut params = Vec::new();
6869        for (variable, values) in effective {
6870            let Some(column) = table.scope_column(variable) else {
6871                return false;
6872            };
6873            if values.is_empty() {
6874                return false;
6875            }
6876            let placeholders = values
6877                .iter()
6878                .map(|value| {
6879                    params.push(SqlValue::Text(value.clone()));
6880                    "?"
6881                })
6882                .collect::<Vec<_>>()
6883                .join(", ");
6884            clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
6885        }
6886        let sql = format!(
6887            "SELECT 1 FROM {} WHERE {} LIMIT 1",
6888            base_table(table_name),
6889            clauses.join(" AND ")
6890        );
6891        self.conn
6892            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
6893            .is_ok()
6894    }
6895
6896    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
6897    pub fn upgrading(&self) -> bool {
6898        self.upgrading
6899    }
6900
6901    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
6902    /// keeping this client's local database (identity, outbox, tables). The
6903    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
6904    /// version changed. Mirrors the TS client's boot-time detection —
6905    /// the Rust core has no persistent restart, so recreation IS the boot.
6906    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
6907        let new_schema = parse_schema_json(schema_json)?;
6908        let marker: Option<i32> = self
6909            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6910            .and_then(|v| v.parse().ok());
6911        self.schema = new_schema;
6912        self.create_bookkeeping_tables()?;
6913        if marker != Some(self.schema.version) {
6914            self.run_schema_reset()?;
6915        }
6916        self.prune_unknown_subscriptions()?;
6917        // The conformance recreate is the in-memory equivalent of reopening a
6918        // durable client. Apply the same startup catch-up contract even when
6919        // the schema itself did not change.
6920        self.enqueue_startup_sync_if_needed()?;
6921        Ok(())
6922    }
6923
6924    fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
6925        let startup_work = !self.stopped
6926            && (!self.outbox.is_empty()
6927                || !self.load_pending_evictions()?.is_empty()
6928                || self.subs.iter().any(|sub| sub.state == SubState::Active));
6929        if startup_work {
6930            self.sync_needed = true;
6931            self.sync_intent_queue.push_back(SyncIntent::Interactive);
6932        }
6933        Ok(())
6934    }
6935
6936    /// A native client persists schema-floor stops across process restarts.
6937    /// Once the running app already satisfies that floor, the persisted stop
6938    /// is only stale evidence from an older server round (for example, the
6939    /// server was restarted after catching up to an app that was ahead).
6940    /// Clear it and let the normal startup pull re-negotiate. If the server is
6941    /// still incompatible it will return the floor again in that first round.
6942    fn clear_satisfied_persisted_schema_floor(&mut self) {
6943        let satisfied = self
6944            .schema_floor
6945            .as_ref()
6946            .and_then(|floor| floor.required_schema_version)
6947            .is_some_and(|required| self.schema.version >= required);
6948        if !satisfied {
6949            return;
6950        }
6951        self.schema_floor = None;
6952        self.stopped = false;
6953        self.delete_meta(SCHEMA_FLOOR_KEY);
6954    }
6955
6956    /// RFC 0005 D1: discard an orphan or stale container at boot. The reset
6957    /// path already swept it; this covers a same-version or fresh-install open,
6958    /// which runs no reset and therefore no sweep — plus the D7 TTL, which only
6959    /// an aware binary can apply.
6960    fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
6961        let Some(replica_path) = self.previous_version_replica_path() else {
6962            return Ok(());
6963        };
6964        let max_age_ms = self
6965            .previous_version
6966            .filter(|config| config.enabled)
6967            .map(|config| config.max_age_ms);
6968        reconcile_previous_version_at_boot(
6969            &self.conn,
6970            &replica_path,
6971            self.schema.version,
6972            max_age_ms,
6973            self.clock_now_ms(),
6974        )
6975    }
6976
6977    /// RFC 0005 D3: the sibling container path, or `None` when the replica has
6978    /// no file — an in-memory replica returns an empty path, and a `file:` URI
6979    /// has no plain sibling path — so the feature resolves as off there.
6980    fn previous_version_replica_path(&self) -> Option<String> {
6981        let path = self.conn.path()?;
6982        if path.is_empty() || path.starts_with("file:") {
6983            return None;
6984        }
6985        Some(path.to_owned())
6986    }
6987
6988    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
6989    /// and leaseState. Drops/recreates every synced table from the new
6990    /// schema, resets subscription sync-state (keeping registrations), clears
6991    /// the schema-floor stop state, rewrites the marker, drops outbox commits
6992    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
6993    fn run_schema_reset(&mut self) -> Result<(), String> {
6994        self.begin_observation("syncular_schema_reset")?;
6995        let mut batch = ChangeAccumulator::default();
6996        let result = self.run_schema_reset_observed(&mut batch, true, true);
6997        if let Err(error) = result {
6998            self.rollback_observation("syncular_schema_reset");
6999            return Err(error);
7000        }
7001        if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
7002            self.rollback_observation("syncular_schema_reset");
7003            return Err(error);
7004        }
7005        Ok(())
7006    }
7007
7008    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
7009        let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
7010        self.begin_observation("syncular_log_epoch_reset")?;
7011        let mut batch = ChangeAccumulator::default();
7012        let result = self.run_schema_reset_observed(&mut batch, false, false);
7013        if let Err(error) = result {
7014            self.rollback_observation("syncular_log_epoch_reset");
7015            return Err(error);
7016        }
7017        self.set_meta(LOG_EPOCH_KEY, log_epoch);
7018        self.sync_needed = true;
7019        batch.status = true;
7020        if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
7021            self.rollback_observation("syncular_log_epoch_reset");
7022            return Err(error);
7023        }
7024        self.sync_intent_queue.push_back(SyncIntent::Interactive);
7025        Ok(resets)
7026    }
7027
7028    fn run_schema_reset_observed(
7029        &mut self,
7030        batch: &mut ChangeAccumulator,
7031        drop_incompatible: bool,
7032        capture: bool,
7033    ) -> Result<(), String> {
7034        self.upgrading = true;
7035        batch.status = true;
7036        // RFC 0005 D6: classify the pending outbox against the NEW compiled
7037        // schema BEFORE the wipe, and record the advisory audit. It drops
7038        // nothing — the §7.4.4 send-time drop stays the only path that removes
7039        // a commit. The classification is shared with that drop so the two can
7040        // never disagree about what is incompatible.
7041        if self.previous_version.is_some_and(|config| config.enabled) {
7042            let previous_version = self
7043                .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7044                .and_then(|value| value.parse::<i32>().ok())
7045                .unwrap_or(self.schema.version);
7046            let pending = self
7047                .outbox
7048                .iter()
7049                .map(|commit| PendingCommitAudit {
7050                    commit_id: &commit.client_commit_id,
7051                    operations: commit_audit_operations(commit),
7052                })
7053                .collect::<Vec<_>>();
7054            crate::previous_version::write_previous_version_audit(
7055                &self.conn,
7056                &crate::previous_version::build_previous_version_audit(
7057                    &self.schema,
7058                    &pending,
7059                    previous_version,
7060                    self.schema.version,
7061                    self.clock_now_ms(),
7062                ),
7063            );
7064        }
7065        // RFC 0005 D5: the container lives in its own FILE, so the reset can
7066        // never reach it. Sweep first (unconditional — also on the log-epoch
7067        // reset, which captures nothing), then capture BEFORE the wipe drops
7068        // any row. A refused capture records its reason and leaves no file.
7069        if let Some(replica_path) = self.previous_version_replica_path() {
7070            sweep_previous_version_container(&self.conn, &replica_path)?;
7071            if capture {
7072                if let Some(config) = self.previous_version.filter(|config| config.enabled) {
7073                    let previous_version = self
7074                        .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7075                        .and_then(|value| value.parse::<i32>().ok())
7076                        .unwrap_or(self.schema.version);
7077                    capture_previous_version_from_replica(
7078                        &self.conn,
7079                        &replica_path,
7080                        previous_version,
7081                        self.schema.version,
7082                        &config,
7083                        self.clock_now_ms(),
7084                    )?;
7085                }
7086            }
7087        }
7088        for table in &self.schema.tables {
7089            batch.table(&table.name);
7090        }
7091        for (base_key, unit, table) in self.load_registered_window_units() {
7092            batch.window(&base_key, &table, &unit);
7093        }
7094        // The per-table insert SQL is derived from the OLD column lists.
7095        self.insert_sql.borrow_mut().clear();
7096        self.overlay_dirty.set(true);
7097        // Drop every synced table (base + visible) that currently exists —
7098        // discovered from sqlite_master so a bump that adds/removes tables is
7099        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
7100        // and the blob cache) are preserved; base tables are `_syncular_base_*`
7101        // so they are matched explicitly, not by the bookkeeping filter.
7102        // Drop virtual tables first so SQLite removes every FTS shadow table
7103        // atomically instead of the generic discovery tearing it apart.
7104        let virtual_tables: Vec<String> = {
7105            let mut stmt = self
7106                .conn
7107                .prepare(
7108                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
7109                )
7110                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7111            let rows = stmt
7112                .query_map([], |row| row.get::<_, String>(0))
7113                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7114            rows.filter_map(Result::ok)
7115                .filter(|name| is_synced_table_name(name))
7116                .collect()
7117        };
7118        for name in virtual_tables {
7119            self.conn
7120                .execute_batch(&format!(
7121                    "DROP TABLE IF EXISTS {}",
7122                    quote_ident(&format!("_syncular_fts_{name}"))
7123                ))
7124                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7125            self.conn
7126                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
7127                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7128        }
7129        let existing: Vec<String> = {
7130            let mut stmt = self
7131                .conn
7132                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
7133                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7134            let rows = stmt
7135                .query_map([], |row| row.get::<_, String>(0))
7136                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7137            rows.filter_map(Result::ok)
7138                .filter(|name| is_synced_table_name(name))
7139                .collect()
7140        };
7141        for name in &existing {
7142            if let Some(table) = name.strip_prefix("_syncular_base_") {
7143                batch.table(table);
7144            } else if !name.starts_with("_syncular_") {
7145                batch.table(name);
7146            }
7147        }
7148        for name in existing {
7149            self.conn
7150                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
7151                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7152        }
7153        // Recreate the synced tables from the NEW schema.
7154        self.create_synced_tables()?;
7155        // Reset every subscription's sync-state, keeping the registration.
7156        for sub in &mut self.subs {
7157            sub.cursor = -1;
7158            sub.bootstrap_state = None;
7159            sub.effective = None;
7160            sub.state = SubState::Active;
7161            sub.reason_code = None;
7162            sub.synced_once = false;
7163        }
7164        let subs = self.subs.clone();
7165        for sub in &subs {
7166            self.persist_sub(sub)?;
7167        }
7168        // The stop state is over: this client now ships a servable schema.
7169        self.stopped = false;
7170        self.schema_floor = None;
7171        self.delete_meta(SCHEMA_FLOOR_KEY);
7172        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
7173        self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
7174        // D1: the descriptor follows every marker write. A crash in between
7175        // self-heals on the next same-version open, which backfills it.
7176        set_local_schema_descriptor(&self.conn, &self.schema);
7177        // §7.4.4: drop outbox commits that cannot re-encode under the new
7178        // schema (a referenced column/table the bump removed), surfacing each
7179        // as a `sync.outbox_incompatible` rejection.
7180        if drop_incompatible && self.drop_incompatible_outbox()? {
7181            batch.rejections = true;
7182            batch.status = true;
7183            batch.outcomes = true;
7184        }
7185        // Re-apply the surviving outbox optimistically over the empty tables.
7186        self.rebuild_overlay();
7187        Ok(())
7188    }
7189
7190    /// §7.4.4: a persisted upsert whose values reference a column the current
7191    /// schema lacks (or a removed table) cannot be encoded. Drop the commit
7192    /// and raise a client-local `sync.outbox_incompatible` rejection.
7193    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
7194        let incompatible = self
7195            .outbox
7196            .iter()
7197            .filter(|commit| {
7198                first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
7199            })
7200            .cloned()
7201            .collect::<Vec<_>>();
7202        if incompatible.is_empty() {
7203            return Ok(false);
7204        }
7205        let mut rejections = Vec::new();
7206        for commit in &incompatible {
7207            let results = commit
7208                .ops
7209                .iter()
7210                .enumerate()
7211                .map(|(op_index, operation)| {
7212                    let rejection = RejectionRecord {
7213                        client_commit_id: commit.client_commit_id.clone(),
7214                        op_index: op_index as i32,
7215                        code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
7216                        message: "the persisted commit cannot encode under the current schema"
7217                            .to_owned(),
7218                        retryable: false,
7219                        details: None,
7220                        operation: Some(CommitOperation::from(operation)),
7221                    };
7222                    rejections.push(rejection.clone());
7223                    CommitOperationOutcome::Error { rejection }
7224                })
7225                .collect::<Vec<_>>();
7226            self.persist_commit_outcome(
7227                &commit.client_commit_id,
7228                CommitOutcomeStatus::Rejected,
7229                &results,
7230                Some(&commit.ops),
7231            )?;
7232            self.delete_outbox_persisted(&commit.client_commit_id)?;
7233        }
7234        self.prune_commit_outcomes()?;
7235        let incompatible_ids = incompatible
7236            .iter()
7237            .map(|commit| commit.client_commit_id.as_str())
7238            .collect::<BTreeSet<_>>();
7239        self.outbox
7240            .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
7241        self.rejections.extend(rejections);
7242        Ok(true)
7243    }
7244
7245    /// §7.4.3: (re)create the base + visible table pair for every synced
7246    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
7247    fn create_synced_tables(&self) -> Result<(), String> {
7248        for table in &self.schema.tables {
7249            // The base half + the visible half form the synced-table pair. An
7250            // index name is global in SQLite, so the base half's indexes are
7251            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
7252            for (full, index_prefix) in [
7253                (base_table(&table.name), "_syncular_base_"),
7254                (visible_table(&table.name), ""),
7255            ] {
7256                let mut cols: Vec<String> =
7257                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
7258                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
7259                let sql = format!(
7260                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
7261                    cols.join(", "),
7262                    quote_ident(&table.primary_key)
7263                );
7264                self.conn
7265                    .execute(&sql, [])
7266                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7267                // Local secondary indexes (CREATE INDEX subset). Created on
7268                // both halves so mirror reads hit an index on either. Runs on
7269                // both the initial create and the §7.4.3 reset recreate path.
7270                for index in &table.indexes {
7271                    let unique = if index.unique { "UNIQUE " } else { "" };
7272                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
7273                    let cols_sql = index
7274                        .columns
7275                        .iter()
7276                        .map(|c| quote_ident(c))
7277                        .collect::<Vec<_>>()
7278                        .join(", ");
7279                    let index_sql = format!(
7280                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
7281                    );
7282                    self.conn
7283                        .execute(&index_sql, [])
7284                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7285                }
7286            }
7287        }
7288        // FTS exists only on the visible half. It is a local search
7289        // projection, never a synced/base table or a wire-schema member.
7290        for table in &self.schema.tables {
7291            for index in &table.fts_indexes {
7292                self.create_fts_projection(table, index)?;
7293            }
7294        }
7295        Ok(())
7296    }
7297
7298    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
7299        let count: i64 = self
7300            .conn
7301            .query_row(
7302                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
7303                rusqlite::params![index.name],
7304                |row| row.get(0),
7305            )
7306            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7307        Ok(count > 0)
7308    }
7309
7310    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
7311        for suffix in ["ai", "ad", "au"] {
7312            self.conn
7313                .execute(
7314                    &format!(
7315                        "DROP TRIGGER IF EXISTS {}",
7316                        quote_ident(&format!("{}_{suffix}", index.name))
7317                    ),
7318                    [],
7319                )
7320                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7321        }
7322        Ok(())
7323    }
7324
7325    fn create_fts_triggers(
7326        &self,
7327        table: &TableSchema,
7328        index: &FtsIndexSchema,
7329    ) -> Result<(), String> {
7330        let fts = quote_ident(&index.name);
7331        let source = visible_table(&table.name);
7332        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7333        let pk = quote_ident(&table.primary_key);
7334        let projection_columns = std::iter::once(source_id.clone())
7335            .chain(index.columns.iter().map(|column| quote_ident(column)))
7336            .collect::<Vec<_>>()
7337            .join(", ");
7338        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
7339            .chain(
7340                index
7341                    .columns
7342                    .iter()
7343                    .map(|column| format!("new.{}", quote_ident(column))),
7344            )
7345            .collect::<Vec<_>>()
7346            .join(", ");
7347        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7348        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)");
7349        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)");
7350        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})");
7351        let bi = quote_ident(&format!("{}_bi", index.name));
7352        let ai = quote_ident(&format!("{}_ai", index.name));
7353        let ad = quote_ident(&format!("{}_ad", index.name));
7354        let au = quote_ident(&format!("{}_au", index.name));
7355        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
7356        let sql = format!(
7357            "DROP TRIGGER IF EXISTS {bi};
7358             DROP TRIGGER IF EXISTS {ai};
7359             DROP TRIGGER IF EXISTS {ad};
7360             DROP TRIGGER IF EXISTS {au};
7361             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
7362             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
7363             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
7364             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
7365        );
7366        self.conn
7367            .execute_batch(&sql)
7368            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7369    }
7370
7371    fn rebuild_fts_projection(
7372        &self,
7373        table: &TableSchema,
7374        index: &FtsIndexSchema,
7375    ) -> Result<(), String> {
7376        let fts = quote_ident(&index.name);
7377        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7378        let pk = quote_ident(&table.primary_key);
7379        let indexed_columns = index
7380            .columns
7381            .iter()
7382            .map(|column| quote_ident(column))
7383            .collect::<Vec<_>>();
7384        let projection_columns = std::iter::once(source_id.clone())
7385            .chain(indexed_columns.iter().cloned())
7386            .collect::<Vec<_>>()
7387            .join(", ");
7388        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7389        let sql = format!(
7390            "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};",
7391            columns = indexed_columns.join(", "),
7392            source = visible_table(&table.name),
7393        );
7394        self.conn
7395            .execute_batch(&sql)
7396            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7397    }
7398
7399    fn create_fts_projection(
7400        &self,
7401        table: &TableSchema,
7402        index: &FtsIndexSchema,
7403    ) -> Result<(), String> {
7404        self.begin_observation("syncular_fts_schema")?;
7405        let result = (|| {
7406            let existed = self.fts_projection_exists(index)?;
7407            let tokenizer = index.tokenize.replace('\'', "''");
7408            let columns = index
7409                .columns
7410                .iter()
7411                .map(|column| quote_ident(column))
7412                .collect::<Vec<_>>()
7413                .join(", ");
7414            let sql = format!(
7415            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
7416            fts = quote_ident(&index.name),
7417            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
7418        );
7419            self.conn.execute(&sql, []).map_err(|error| {
7420                format!(
7421                    "cannot create local FTS5 projection {:?}: {error}",
7422                    index.name
7423                )
7424            })?;
7425            let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7426            let mapped: bool = self
7427                .conn
7428                .query_row(
7429                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
7430                    [format!("_syncular_fts_{}", index.name)],
7431                    |row| row.get(0),
7432                )
7433                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7434            self.conn.execute_batch(&format!("CREATE TABLE IF NOT EXISTS {mapping}(id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE)")).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7435            if !mapped && existed {
7436                self.conn
7437                    .execute_batch(&format!(
7438                        "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
7439                        quote_ident(FTS_SOURCE_ID_COLUMN),
7440                        quote_ident(&index.name)
7441                    ))
7442                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7443            }
7444            self.create_fts_triggers(table, index)?;
7445            if !existed {
7446                self.rebuild_fts_projection(table, index)?;
7447            }
7448            Ok(())
7449        })();
7450        if let Err(error) = result {
7451            self.rollback_observation("syncular_fts_schema");
7452            return Err(error);
7453        }
7454        if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
7455            self.rollback_observation("syncular_fts_schema");
7456            return Err(error.to_string());
7457        }
7458        Ok(())
7459    }
7460
7461    // -- persistence write-through --------------------------------------------
7462
7463    fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
7464        let state = serde_json::json!({
7465            "requested": scope_map_to_json(&sub.requested),
7466            "params": sub.params,
7467            "cursor": sub.cursor,
7468            "bootstrapState": sub.bootstrap_state,
7469            "status": sub.state.name(),
7470            "reasonCode": sub.reason_code,
7471            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
7472            "syncedOnce": sub.synced_once,
7473        });
7474        self.conn.execute(
7475            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
7476            rusqlite::params![sub.id, sub.table, state.to_string()],
7477        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7478    }
7479
7480    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
7481        let ops: Vec<Value> = commit
7482            .ops
7483            .iter()
7484            .map(|op| {
7485                serde_json::json!({
7486                    "op": if op.upsert { "upsert" } else { "delete" },
7487                    "table": op.table,
7488                    "rowId": op.row_id,
7489                    "baseVersion": op.base_version,
7490                    "values": op.values.clone().map(Value::Object),
7491                })
7492            })
7493            .collect();
7494        self.conn
7495            .execute(
7496                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
7497                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
7498            )
7499            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7500        for operation in &commit.ops {
7501            let Some(values) = &operation.values else {
7502                continue;
7503            };
7504            let Some(table) = self.schema.table(&operation.table) else {
7505                continue;
7506            };
7507            for column in table
7508                .columns
7509                .iter()
7510                .filter(|column| column.ty == ColumnType::BlobRef)
7511            {
7512                let Some(Value::String(raw)) = values.get(&column.name) else {
7513                    continue;
7514                };
7515                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
7516                    continue;
7517                };
7518                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
7519                    continue;
7520                };
7521                self.conn
7522                    .execute(
7523                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
7524                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
7525                        rusqlite::params![commit.client_commit_id, blob_id],
7526                    )
7527                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7528            }
7529        }
7530        Ok(())
7531    }
7532
7533    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
7534        self.conn
7535            .execute(
7536                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
7537                rusqlite::params![client_commit_id],
7538            )
7539            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7540        self.conn
7541            .execute(
7542                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
7543                rusqlite::params![client_commit_id],
7544            )
7545            .map(|_| ())
7546            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7547    }
7548
7549    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
7550        match status {
7551            CommitOutcomeStatus::Applied => "applied",
7552            CommitOutcomeStatus::Cached => "cached",
7553            CommitOutcomeStatus::Conflict => "conflict",
7554            CommitOutcomeStatus::Rejected => "rejected",
7555        }
7556    }
7557
7558    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
7559        match resolution {
7560            CommitOutcomeResolution::Active => "active",
7561            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
7562            CommitOutcomeResolution::Superseded => "superseded",
7563            CommitOutcomeResolution::Dismissed => "dismissed",
7564        }
7565    }
7566
7567    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
7568        match value {
7569            "applied" => Ok(CommitOutcomeStatus::Applied),
7570            "cached" => Ok(CommitOutcomeStatus::Cached),
7571            "conflict" => Ok(CommitOutcomeStatus::Conflict),
7572            "rejected" => Ok(CommitOutcomeStatus::Rejected),
7573            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
7574        }
7575    }
7576
7577    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
7578        match value {
7579            "active" => Ok(CommitOutcomeResolution::Active),
7580            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
7581            "superseded" => Ok(CommitOutcomeResolution::Superseded),
7582            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
7583            _ => Err(format!(
7584                "invalid persisted commit outcome resolution {value:?}"
7585            )),
7586        }
7587    }
7588
7589    fn persist_commit_outcome(
7590        &self,
7591        client_commit_id: &str,
7592        status: CommitOutcomeStatus,
7593        results: &[CommitOperationOutcome],
7594        operations: Option<&[OutboxOp]>,
7595    ) -> Result<(), String> {
7596        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
7597        let operations_json = operations
7598            .map(|items| {
7599                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
7600            })
7601            .transpose()
7602            .map_err(|error| error.to_string())?;
7603        self.conn
7604            .execute(
7605                "INSERT INTO _syncular_commit_outcomes (
7606                   client_commit_id, status, recorded_at_ms, results_json,
7607                   operations_json, resolution
7608                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
7609                rusqlite::params![
7610                    client_commit_id,
7611                    Self::outcome_status_name(status),
7612                    self.clock_now_ms(),
7613                    results_json,
7614                    operations_json
7615                ],
7616            )
7617            .map(|_| ())
7618            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7619    }
7620
7621    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
7622        let StoredCommitOutcomeRow {
7623            sequence,
7624            client_commit_id,
7625            status,
7626            recorded_at_ms,
7627            results_json,
7628            operations_json,
7629            resolution,
7630            resolved_at_ms,
7631            replacement_client_commit_id,
7632        } = row;
7633        Ok(CommitOutcome {
7634            sequence,
7635            client_commit_id,
7636            status: Self::parse_outcome_status(&status)?,
7637            recorded_at_ms,
7638            results: serde_json::from_str(&results_json)
7639                .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
7640            operations: operations_json
7641                .map(|value| {
7642                    serde_json::from_str(&value).map_err(|error| {
7643                        format!("invalid persisted commit outcome operations: {error}")
7644                    })
7645                })
7646                .transpose()?,
7647            retained_rows: None,
7648            resolution: Self::parse_outcome_resolution(&resolution)?,
7649            resolved_at_ms,
7650            replacement_client_commit_id,
7651        })
7652    }
7653
7654    fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
7655        if let Some((commit, initial)) = self
7656            .failed_commits
7657            .iter()
7658            .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
7659        {
7660            let mut rows = Vec::new();
7661            for operation in &commit.ops {
7662                let table = self
7663                    .schema
7664                    .table(&operation.table)
7665                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
7666                let key =
7667                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
7668                        .map_err(|_| "sync.local_corrupt".to_owned())?;
7669                let base = self.stored_row(table, &operation.row_id, true);
7670                rows.push(RetainedCommitRow {
7671                    table: operation.table.clone(),
7672                    row_id: operation.row_id.clone(),
7673                    local_row: if operation.upsert {
7674                        initial
7675                            .get(&key)
7676                            .and_then(Value::as_object)
7677                            .cloned()
7678                            .or_else(|| operation.values.clone())
7679                    } else {
7680                        None
7681                    },
7682                    server_row: base.as_ref().map(|(values, _)| {
7683                        table
7684                            .columns
7685                            .iter()
7686                            .zip(values)
7687                            .map(|(column, value)| {
7688                                (column.name.clone(), column_value_to_json(value))
7689                            })
7690                            .collect()
7691                    }),
7692                    server_version: base.map(|(_, version)| version),
7693                });
7694            }
7695            outcome.retained_rows = Some(rows);
7696        }
7697        Ok(outcome)
7698    }
7699
7700    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
7701        let row = self
7702            .conn
7703            .query_row(
7704                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7705                        resolution, resolved_at_ms, replacement_client_commit_id
7706                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
7707                rusqlite::params![client_commit_id],
7708                |row| {
7709                    Ok(StoredCommitOutcomeRow {
7710                        sequence: row.get(0)?,
7711                        client_commit_id: row.get(1)?,
7712                        status: row.get(2)?,
7713                        recorded_at_ms: row.get(3)?,
7714                        results_json: row.get(4)?,
7715                        operations_json: row.get(5)?,
7716                        resolution: row.get(6)?,
7717                        resolved_at_ms: row.get(7)?,
7718                        replacement_client_commit_id: row.get(8)?,
7719                    })
7720                },
7721            )
7722            .optional()
7723            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7724        row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
7725            .transpose()
7726    }
7727
7728    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
7729        if query.limit == Some(0) {
7730            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
7731        }
7732        let mut sql = String::from(
7733            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7734                    resolution, resolved_at_ms, replacement_client_commit_id
7735               FROM _syncular_commit_outcomes",
7736        );
7737        if query.active_only {
7738            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
7739        }
7740        sql.push_str(" ORDER BY seq DESC");
7741        if let Some(limit) = query.limit {
7742            sql.push_str(&format!(" LIMIT {limit}"));
7743        }
7744        let mut stmt = self
7745            .conn
7746            .prepare(&sql)
7747            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7748        let rows = stmt
7749            .query_map([], |row| {
7750                Ok(StoredCommitOutcomeRow {
7751                    sequence: row.get(0)?,
7752                    client_commit_id: row.get(1)?,
7753                    status: row.get(2)?,
7754                    recorded_at_ms: row.get(3)?,
7755                    results_json: row.get(4)?,
7756                    operations_json: row.get(5)?,
7757                    resolution: row.get(6)?,
7758                    resolved_at_ms: row.get(7)?,
7759                    replacement_client_commit_id: row.get(8)?,
7760                })
7761            })
7762            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7763        let mut outcomes = Vec::new();
7764        for row in rows {
7765            outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
7766                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
7767            )?)?);
7768        }
7769        Ok(outcomes)
7770    }
7771
7772    fn prune_commit_outcomes(&self) -> Result<(), String> {
7773        #[cfg(test)]
7774        self.outcome_prune_count
7775            .set(self.outcome_prune_count.get() + 1);
7776        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
7777        let count = self
7778            .conn
7779            .query_row(
7780                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
7781                [],
7782                |row| row.get::<_, i64>(0),
7783            )
7784            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
7785            as usize;
7786        let excess = count.saturating_sub(max_entries);
7787        if excess == 0 {
7788            return Ok(());
7789        }
7790        let mut stmt = self
7791            .conn
7792            .prepare(
7793                "SELECT seq FROM _syncular_commit_outcomes
7794                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
7795                  ORDER BY seq ASC LIMIT ?1",
7796            )
7797            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7798        let rows = stmt
7799            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
7800            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7801        let sequences = rows
7802            .collect::<Result<Vec<_>, _>>()
7803            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7804        drop(stmt);
7805        for sequence in sequences {
7806            self.conn
7807                .execute(
7808                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
7809                    rusqlite::params![sequence],
7810                )
7811                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7812        }
7813        Ok(())
7814    }
7815
7816    // -- driver surface ---------------------------------------------------------
7817
7818    pub fn subscribe(
7819        &mut self,
7820        id: String,
7821        table: String,
7822        scopes: Vec<(String, Vec<String>)>,
7823        params: Option<String>,
7824    ) -> Result<(), String> {
7825        if self.schema.table(&table).is_none() {
7826            return Err(format!("unknown table {table:?}"));
7827        }
7828        if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
7829            let same_intent = existing.table == table
7830                && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
7831                && existing.params == params;
7832            if same_intent {
7833                return Ok(());
7834            }
7835            return Err(
7836                "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
7837                    .to_owned(),
7838            );
7839        }
7840        let sub = Subscription {
7841            id: id.clone(),
7842            table,
7843            requested: scopes,
7844            params,
7845            cursor: -1,
7846            bootstrap_state: None,
7847            state: SubState::Active,
7848            reason_code: None,
7849            effective: None,
7850            synced_once: false,
7851        };
7852        self.persist_sub(&sub)?;
7853        self.subs.push(sub);
7854        Ok(())
7855    }
7856
7857    pub fn unsubscribe(&mut self, id: &str) {
7858        self.subs.retain(|s| s.id != id);
7859        let _ = self.conn.execute(
7860            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7861            rusqlite::params![id],
7862        );
7863    }
7864
7865    // -- windowed subscriptions (§4.8) ------------------------------------------
7866
7867    /// §4.8: set the live window units for a base — a value-sharded family
7868    /// of subscriptions, one per unit. Added units get fresh subscriptions
7869    /// (image-lane bootstrap on the next sync); removed units are
7870    /// unsubscribed with their first eviction chunk in one transaction (E1–E4).
7871    /// Idempotent; re-entry cancels any deferred eviction.
7872    pub fn set_window(
7873        &mut self,
7874        base: &WindowBase,
7875        units: &[String],
7876    ) -> Result<CommandEffects, String> {
7877        let table = self
7878            .schema
7879            .table(&base.table)
7880            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
7881        if table.scope_column(&base.variable).is_none() {
7882            return Err(format!(
7883                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
7884                base.table, base.variable
7885            ));
7886        }
7887        let base_key = window_base_key(base);
7888        let wanted: std::collections::HashSet<&String> = units.iter().collect();
7889        let live = self.load_window_units(&base_key);
7890        self.begin_observation("syncular_window")?;
7891        let mut batch = ChangeAccumulator::default();
7892        let mut changed = false;
7893
7894        let prior_subs = self.subs.clone();
7895        let widened = (|| {
7896            // Widen: units wanted but not live → fresh subscription + registry row.
7897            for unit in units {
7898                if live.iter().any(|(u, _)| u == unit) {
7899                    continue;
7900                }
7901                let sub_id = derive_sub_id(base, unit);
7902                self.delete_pending_evict(&sub_id)?;
7903                self.insert_window_unit(&base_key, unit, &sub_id)?;
7904                self.subscribe(
7905                    sub_id,
7906                    base.table.clone(),
7907                    unit_scopes(base, unit),
7908                    base.params.clone(),
7909                )?;
7910                batch.window(&base_key, &base.table, unit);
7911                changed = true;
7912            }
7913
7914            self.finish_observation("syncular_window", batch)
7915        })();
7916        if let Err(error) = widened {
7917            self.rollback_observation("syncular_window");
7918            self.subs = prior_subs;
7919            return Err(error);
7920        }
7921        for (unit, sub_id) in live {
7922            if wanted.contains(&unit) {
7923                continue;
7924            }
7925            self.rebuild_overlay_if_dirty();
7926            let prior_subs = self.subs.clone();
7927            self.begin_observation("syncular_window")?;
7928            let effective = self
7929                .subs
7930                .iter()
7931                .find(|sub| sub.id == sub_id)
7932                .and_then(|sub| sub.effective.clone())
7933                .unwrap_or_else(|| unit_scopes(base, &unit));
7934            let mut batch = ChangeAccumulator::default();
7935            self.record_scope_map(&mut batch, &base.table, &effective);
7936            batch.window(&base_key, &base.table, &unit);
7937            let result = self.evict_unit(&base_key, base, &unit, &sub_id);
7938            let remaining = match result {
7939                Ok(remaining) => remaining,
7940                Err(error) => {
7941                    self.rollback_observation("syncular_window");
7942                    self.subs = prior_subs;
7943                    return Err(error);
7944                }
7945            };
7946            if let Err(error) = self.finish_observation("syncular_window", batch) {
7947                self.rollback_observation("syncular_window");
7948                self.subs = prior_subs;
7949                return Err(error);
7950            }
7951            if remaining {
7952                self.drain_pending_evictions()?;
7953            }
7954            changed = true;
7955        }
7956        Ok(if changed {
7957            CommandEffects::interactive()
7958        } else {
7959            CommandEffects::none()
7960        })
7961    }
7962
7963    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
7964    /// the subset still bootstrap-pending. Registration alone is not
7965    /// completeness — a unit is pending until its subscription completes a
7966    /// bootstrap round (cursor advances past -1 with no resume token held).
7967    pub fn window_state(&self, base: &WindowBase) -> WindowState {
7968        let mut units = Vec::new();
7969        let mut pending = Vec::new();
7970        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
7971            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
7972                Some(sub) => {
7973                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
7974                }
7975                None => true,
7976            };
7977            if is_pending {
7978                pending.push(unit.clone());
7979            }
7980            units.push(unit);
7981        }
7982        WindowState { units, pending }
7983    }
7984
7985    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
7986        let mut stmt = match self
7987            .conn
7988            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
7989        {
7990            Ok(stmt) => stmt,
7991            Err(_) => return Vec::new(),
7992        };
7993        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
7994            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7995        });
7996        match rows {
7997            Ok(rows) => rows.filter_map(Result::ok).collect(),
7998            Err(_) => Vec::new(),
7999        }
8000    }
8001
8002    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
8003        let mut stmt = match self.conn.prepare(
8004            "SELECT windows.base, windows.unit, subscriptions.tbl
8005               FROM _syncular_windows AS windows
8006               JOIN _syncular_subscriptions AS subscriptions
8007                 ON subscriptions.id = windows.sub_id
8008               ORDER BY windows.base, windows.unit",
8009        ) {
8010            Ok(stmt) => stmt,
8011            Err(_) => return Vec::new(),
8012        };
8013        let rows = stmt.query_map([], |row| {
8014            Ok((
8015                row.get::<_, String>(0)?,
8016                row.get::<_, String>(1)?,
8017                row.get::<_, String>(2)?,
8018            ))
8019        });
8020        match rows {
8021            Ok(rows) => rows.filter_map(Result::ok).collect(),
8022            Err(_) => Vec::new(),
8023        }
8024    }
8025
8026    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
8027        self.conn
8028            .query_row(
8029                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
8030                rusqlite::params![sub_id],
8031                |row| Ok((row.get(0)?, row.get(1)?)),
8032            )
8033            .ok()
8034    }
8035
8036    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
8037        self.conn
8038            .execute(
8039                "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
8040                rusqlite::params![base_key, unit, sub_id],
8041            )
8042            .map(|_| ())
8043            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8044    }
8045
8046    fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
8047        self.conn
8048            .execute(
8049                "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
8050                rusqlite::params![base_key, unit],
8051            )
8052            .map(|_| ())
8053            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8054    }
8055
8056    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
8057    /// Deletes the unit's rows except those pinned by a pending outbox
8058    /// commit (E1); records a deferred eviction if any pin remains; discards
8059    /// the subscription's cursor/resume/effective-echo (E3) and version
8060    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
8061    /// nothing.
8062    fn evict_unit(
8063        &mut self,
8064        base_key: &str,
8065        base: &WindowBase,
8066        unit: &str,
8067        sub_id: &str,
8068    ) -> Result<bool, String> {
8069        let effective = self
8070            .subs
8071            .iter()
8072            .find(|s| s.id == sub_id)
8073            .and_then(|s| s.effective.clone())
8074            .unwrap_or_else(|| unit_scopes(base, unit));
8075        let pinned = self.pinned_row_ids(&base.table);
8076        let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
8077        self.delete_window_unit(base_key, unit)?;
8078        self.conn
8079            .execute(
8080                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
8081                [sub_id],
8082            )
8083            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8084        self.subs.retain(|sub| sub.id != sub_id);
8085        if deferred {
8086            self.save_pending_evict(sub_id, &base.table, &effective)?;
8087        } else {
8088            self.delete_pending_evict(sub_id)?;
8089        }
8090        Ok(remaining)
8091    }
8092
8093    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
8094    /// primary keys. Returns (more unpinned work, any scoped rows remain).
8095    /// A missing scope-column mapping fails without deleting rows.
8096    fn evict_scope_rows(
8097        &self,
8098        table_name: &str,
8099        effective: &[(String, Vec<String>)],
8100        pinned: &std::collections::HashSet<String>,
8101    ) -> Result<(bool, bool), String> {
8102        if effective.is_empty() {
8103            return Ok((false, false));
8104        }
8105        let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
8106        let mut clauses = Vec::new();
8107        let mut params: Vec<SqlValue> = Vec::new();
8108        for (variable, values) in effective {
8109            let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
8110            if values.is_empty() {
8111                return Ok((false, false));
8112            }
8113            let holes = values
8114                .iter()
8115                .map(|v| {
8116                    params.push(SqlValue::Text(v.clone()));
8117                    "?"
8118                })
8119                .collect::<Vec<_>>()
8120                .join(", ");
8121            clauses.push(format!("{} IN ({holes})", quote_ident(column)));
8122        }
8123        let scope = clauses.join(" AND ");
8124        let scope_params = params.len();
8125        let pk = quote_ident(&table.primary_key);
8126        if !pinned.is_empty() {
8127            let holes = pinned
8128                .iter()
8129                .map(|id| {
8130                    params.push(SqlValue::Text(id.clone()));
8131                    "?"
8132                })
8133                .collect::<Vec<_>>()
8134                .join(", ");
8135            clauses.push(format!("{pk} NOT IN ({holes})"));
8136        }
8137        let predicate = clauses.join(" AND ");
8138        let base = base_table(table_name);
8139        let visible = visible_table(table_name);
8140        let mut ids: Vec<SqlValue> = Vec::new();
8141        for source in [&base, &visible] {
8142            let mut stmt = self
8143                .conn
8144                .prepare_cached(&format!(
8145                    "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
8146                ))
8147                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8148            ids = stmt
8149                .query_map(rusqlite::params_from_iter(&params), |row| row.get(0))
8150                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
8151                .collect::<Result<_, _>>()
8152                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8153            if !ids.is_empty() {
8154                break;
8155            }
8156        }
8157        if !ids.is_empty() {
8158            let holes = vec!["?"; ids.len()].join(", ");
8159            for source in [&base, &visible] {
8160                self.conn
8161                    .execute(
8162                        &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
8163                        rusqlite::params_from_iter(&ids),
8164                    )
8165                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8166            }
8167        }
8168        let mut remaining = false;
8169        let mut deferred = false;
8170        for source in [&base, &visible] {
8171            remaining |= self
8172                .conn
8173                .query_row(
8174                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
8175                    rusqlite::params_from_iter(&params),
8176                    |row| row.get::<_, bool>(0),
8177                )
8178                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8179            deferred |= self
8180                .conn
8181                .query_row(
8182                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
8183                    rusqlite::params_from_iter(&params[..scope_params]),
8184                    |row| row.get::<_, bool>(0),
8185                )
8186                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8187        }
8188        Ok((remaining, deferred))
8189    }
8190
8191    /// Resume committed eviction chunks, retaining outbox pins and their durable marker.
8192    fn drain_pending_evictions(&mut self) -> Result<(), String> {
8193        let pending = self.load_pending_evictions()?;
8194        if pending.is_empty() {
8195            return Ok(());
8196        }
8197        self.rebuild_overlay_if_dirty();
8198        for (sub_id, table_name, effective) in pending {
8199            if self.schema.table(&table_name).is_none() {
8200                self.delete_pending_evict(&sub_id)?;
8201                continue;
8202            }
8203            loop {
8204                std::thread::yield_now();
8205                self.begin_observation("syncular_evict")?;
8206                let pinned = self.pinned_row_ids(&table_name);
8207                let result = self.evict_scope_rows(&table_name, &effective, &pinned);
8208                let (remaining, deferred) = match result {
8209                    Ok(result) => result,
8210                    Err(error) => {
8211                        self.rollback_observation("syncular_evict");
8212                        return Err(error);
8213                    }
8214                };
8215                if !deferred {
8216                    if let Err(error) = self.delete_pending_evict(&sub_id) {
8217                        self.rollback_observation("syncular_evict");
8218                        return Err(error);
8219                    }
8220                }
8221                let mut batch = ChangeAccumulator::default();
8222                self.record_scope_map(&mut batch, &table_name, &effective);
8223                if let Err(error) = self.finish_observation("syncular_evict", batch) {
8224                    self.rollback_observation("syncular_evict");
8225                    return Err(error);
8226                }
8227                if !remaining {
8228                    break;
8229                }
8230            }
8231        }
8232        Ok(())
8233    }
8234
8235    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
8236    /// commit — rows that MUST NOT be evicted until the commit drains.
8237    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
8238        let mut pinned = std::collections::HashSet::new();
8239        for commit in self
8240            .outbox
8241            .iter()
8242            .chain(self.failed_commits.iter().map(|(commit, _)| commit))
8243        {
8244            for op in &commit.ops {
8245                if op.table == table {
8246                    pinned.insert(op.row_id.clone());
8247                }
8248            }
8249        }
8250        pinned
8251    }
8252
8253    fn save_pending_evict(
8254        &self,
8255        sub_id: &str,
8256        table: &str,
8257        effective: &[(String, Vec<String>)],
8258    ) -> Result<(), String> {
8259        self.conn.execute(
8260            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
8261            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
8262        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8263    }
8264
8265    fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
8266        self.conn
8267            .execute(
8268                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
8269                rusqlite::params![sub_id],
8270            )
8271            .map(|_| ())
8272            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8273    }
8274
8275    fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
8276        let mut stmt = self
8277            .conn
8278            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
8279            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8280        let rows = stmt
8281            .query_map([], |row| {
8282                Ok((
8283                    row.get::<_, String>(0)?,
8284                    row.get::<_, String>(1)?,
8285                    row.get::<_, String>(2)?,
8286                ))
8287            })
8288            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8289        rows.map(|row| {
8290            let (sub_id, table, json) =
8291                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8292            let value: Value = serde_json::from_str(&json)
8293                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
8294            let effective = json_to_scope_map(&value)
8295                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
8296            Ok((sub_id, table, effective))
8297        })
8298        .collect()
8299    }
8300
8301    /// Select failed-intent retention; reload durable retained aggregates.
8302    pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
8303        self.retain_failed_commits = enabled;
8304        self.failed_commits = self.load_failed_commits()?;
8305        self.overlay_dirty.set(true);
8306        Ok(())
8307    }
8308
8309    fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
8310        let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
8311            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8312        let rows = statement
8313            .query_map([], |row| {
8314                Ok((
8315                    row.get::<_, String>(0)?,
8316                    row.get::<_, String>(1)?,
8317                    row.get::<_, String>(2)?,
8318                ))
8319            })
8320            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8321        rows.map(|row| {
8322            let (id, operations, initial) =
8323                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8324            let operations: Vec<CommitOperation> =
8325                serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
8326            let initial =
8327                serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
8328            Ok((
8329                OutboxCommit {
8330                    client_commit_id: id,
8331                    ops: operations
8332                        .into_iter()
8333                        .map(|op| OutboxOp {
8334                            upsert: op.op == "upsert",
8335                            table: op.table,
8336                            row_id: op.row_id,
8337                            base_version: op.base_version,
8338                            values: op.values,
8339                        })
8340                        .collect(),
8341                },
8342                initial,
8343            ))
8344        })
8345        .collect()
8346    }
8347
8348    fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
8349        let mut initial = Map::new();
8350        for operation in operations {
8351            let table = self
8352                .schema
8353                .table(&operation.table)
8354                .ok_or_else(|| "sync.unknown_table".to_owned())?;
8355            if let Some((values, _)) = self
8356                .stored_row(table, &operation.row_id, true)
8357                .or_else(|| self.visible_row(table, &operation.row_id))
8358            {
8359                let mut intended: Map<String, Value> = table
8360                    .columns
8361                    .iter()
8362                    .zip(values.iter())
8363                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
8364                    .collect();
8365                if let Some(patch) = &operation.values {
8366                    intended.extend(patch.clone());
8367                }
8368                initial.insert(
8369                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
8370                        .map_err(|_| "sync.local_corrupt".to_owned())?,
8371                    Value::Object(intended),
8372                );
8373            }
8374        }
8375        self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
8376            serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
8377            serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
8378            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8379        self.failed_commits.push((
8380            OutboxCommit {
8381                client_commit_id: id.to_owned(),
8382                ops: operations.to_vec(),
8383            },
8384            initial,
8385        ));
8386        Ok(())
8387    }
8388
8389    fn drop_failed_commits(
8390        &mut self,
8391        matches: impl Fn(&str, &Map<String, Value>) -> bool,
8392    ) -> Result<Vec<OutboxCommit>, String> {
8393        let mut doomed = Vec::new();
8394        for (commit, initial) in &self.failed_commits {
8395            for operation in &commit.ops {
8396                let key =
8397                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
8398                        .map_err(|_| "sync.local_corrupt".to_owned())?;
8399                if initial
8400                    .get(&key)
8401                    .and_then(Value::as_object)
8402                    .is_some_and(|values| matches(&operation.table, values))
8403                {
8404                    doomed.push(commit.clone());
8405                    break;
8406                }
8407            }
8408        }
8409        for commit in &doomed {
8410            self.conn
8411                .execute(
8412                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
8413                    rusqlite::params![commit.client_commit_id],
8414                )
8415                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8416        }
8417        self.failed_commits.retain(|(commit, _)| {
8418            !doomed
8419                .iter()
8420                .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
8421        });
8422        if !doomed.is_empty() {
8423            self.overlay_dirty.set(true);
8424        }
8425        Ok(doomed)
8426    }
8427
8428    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
8429        if mutations.is_empty() {
8430            return Err("a commit must contain at least one operation (§6.1)".to_owned());
8431        }
8432        let mut ops = Vec::with_capacity(mutations.len());
8433        for mutation in mutations {
8434            match mutation {
8435                Mutation::Patch {
8436                    table,
8437                    values,
8438                    base_version,
8439                } => {
8440                    let schema_table = self
8441                        .schema
8442                        .table(&table)
8443                        .ok_or_else(|| "sync.unknown_table".to_owned())?;
8444                    let values = normalize_values_casing(schema_table, values)?;
8445                    let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
8446                    ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
8447                }
8448                Mutation::Upsert {
8449                    table,
8450                    values,
8451                    base_version,
8452                } => {
8453                    let schema_table = self
8454                        .schema
8455                        .table(&table)
8456                        .ok_or_else(|| format!("unknown table {table:?}"))?;
8457                    // §5: value keys are accepted in snake_case AND the
8458                    // generated row types' camelCase; normalize to SQL truth
8459                    // before the pk lookup / codec see them.
8460                    let values = normalize_values_casing(schema_table, values)?;
8461                    let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
8462                    // §6.7 `mutate` marks every column present: fill missing
8463                    // nullable columns with NULL before the payload is built.
8464                    let values = full_row_values(schema_table, values)?;
8465                    // §5.11: validate the payload encodes with the current
8466                    // codec. A key-selection or unknown-key failure is a
8467                    // durable rejection at the push seam (§10.3), never an
8468                    // author-time error. Full-row `mutate` presents every
8469                    // column, so no stored fallback is needed.
8470                    self.validate_author_encode(schema_table, &row_id, &values)?;
8471                    ops.push(OutboxOp {
8472                        upsert: true,
8473                        table,
8474                        row_id,
8475                        base_version,
8476                        values: Some(values),
8477                    });
8478                }
8479                Mutation::Delete {
8480                    table,
8481                    row_id,
8482                    base_version,
8483                } => {
8484                    if self.schema.table(&table).is_none() {
8485                        return Err(format!("unknown table {table:?}"));
8486                    }
8487                    ops.push(OutboxOp {
8488                        upsert: false,
8489                        table,
8490                        row_id,
8491                        base_version,
8492                        values: None,
8493                    });
8494                }
8495            }
8496        }
8497        self.record_outbox_commit(ops)
8498    }
8499
8500    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
8501        let commit = OutboxCommit {
8502            client_commit_id: uuid::Uuid::new_v4().to_string(),
8503            ops,
8504        };
8505        self.begin_observation("syncular_mutation")?;
8506        let mut batch = ChangeAccumulator::default();
8507        for op in &commit.ops {
8508            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
8509            if let Some(values) = &op.values {
8510                if let Some(table) = self.schema.table(&op.table) {
8511                    for scope in &table.scope_variables {
8512                        if let Some(Value::String(value)) = values.get(&scope.column) {
8513                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
8514                            precise = true;
8515                        }
8516                    }
8517                }
8518            }
8519            if !precise {
8520                batch.table(&op.table);
8521            }
8522        }
8523        if let Err(error) = self.persist_outbox_insert(&commit) {
8524            self.rollback_observation("syncular_mutation");
8525            return Err(error);
8526        }
8527        let was_dirty = self.overlay_dirty.get();
8528        // A clean overlay contains the FIFO fold of every previous commit, so
8529        // the new commit applies over it. §7.1: an operation that does not
8530        // apply (a secondary unique collision) fails the whole commit; the
8531        // savepoint rollback removes the outbox entry and every visible write.
8532        if was_dirty {
8533            self.rebuild_overlay();
8534        }
8535        if let Some(error) = commit
8536            .ops
8537            .iter()
8538            .find_map(|op| self.apply_outbox_op(op).err())
8539        {
8540            self.rollback_observation("syncular_mutation");
8541            self.overlay_dirty.set(was_dirty);
8542            return Err(error);
8543        }
8544        let id = commit.client_commit_id.clone();
8545        self.outbox.push(commit);
8546        batch.status = true;
8547        if let Err(error) = self.finish_observation("syncular_mutation", batch) {
8548            self.rollback_observation("syncular_mutation");
8549            self.outbox.pop();
8550            self.overlay_dirty.set(was_dirty);
8551            return Err(error);
8552        }
8553        Ok(id)
8554    }
8555
8556    /// Record one §6.1 sparse upsert: the primary key plus the supplied
8557    /// non-scope columns are present; every other column is absent and stays
8558    /// untouched on the server and in the local overlay. An absent local base
8559    /// refuses the whole batch with sync.row_missing before enqueueing (§7.1).
8560    pub fn patch(
8561        &mut self,
8562        table: &str,
8563        row_id: &str,
8564        partial: Map<String, Value>,
8565        base_version: Option<i64>,
8566    ) -> Result<String, String> {
8567        let operation = self.prepare_patch(table, row_id, partial, base_version)?;
8568        self.record_outbox_commit(vec![operation])
8569    }
8570
8571    fn prepare_patch(
8572        &self,
8573        table: &str,
8574        row_id: &str,
8575        partial: Map<String, Value>,
8576        base_version: Option<i64>,
8577    ) -> Result<OutboxOp, String> {
8578        let schema_table = self
8579            .schema
8580            .table(table)
8581            .ok_or_else(|| format!("unknown table {table:?}"))?;
8582        let partial = normalize_values_casing(schema_table, partial)?;
8583        if let Some(pk) = partial.get(&schema_table.primary_key) {
8584            if pk.as_str() != Some(row_id) {
8585                return Err(format!(
8586                    "sync.invalid_request: table {table:?}: patch cannot change the primary key"
8587                ));
8588            }
8589        }
8590        // Existence reads only the key. Locked encrypted values stay opaque;
8591        // decoding the complete row here would reject a valid plain patch.
8592        let exists: bool = self
8593            .conn
8594            .query_row(
8595                &format!(
8596                    "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
8597                    visible_table(table),
8598                    row_id_predicate(schema_table)
8599                ),
8600                rusqlite::params![row_id],
8601                |row| row.get(0),
8602            )
8603            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8604        if !exists {
8605            return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
8606        }
8607        // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
8608        // server accepts a present scope column whose value equals the
8609        // stored row and applies it as a no-op, so a decoded envelope
8610        // round-trips. The local row is the only value the client can prove
8611        // equality against; when the row is absent locally the client cannot
8612        // prove equality against the server's stored row and fails closed.
8613        // A primary key that is also a scope column needs no stored-row
8614        // read: the primary key in a sparse payload is by construction the
8615        // row id being patched (§6.1), so its value is proven equal already.
8616        for scope in &schema_table.scope_variables {
8617            if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
8618                continue;
8619            }
8620            let Some(column) = schema_table
8621                .columns
8622                .iter()
8623                .find(|column| column.name == scope.column)
8624            else {
8625                continue;
8626            };
8627            let stored = self
8628                .conn
8629                .query_row(
8630                    &format!(
8631                        "SELECT {} FROM {} WHERE {}",
8632                        quote_ident(&scope.column),
8633                        visible_table(&schema_table.name),
8634                        row_id_predicate(schema_table)
8635                    ),
8636                    rusqlite::params![row_id],
8637                    |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
8638                )
8639                .ok();
8640            // Decode the supplied value through the same column-type path the
8641            // rest of the patch encoding uses, so a value that only equals
8642            // the stored one in another number representation still compares
8643            // equal.
8644            let incoming = json_to_column_value(column, partial.get(&scope.column))
8645                .ok()
8646                .map(|value| column_value_to_json(&value));
8647            // A stored blob never equals a patch value, matching the server's
8648            // scope-column comparison (§3.4): bytes are unstorable as scope
8649            // values.
8650            let equal = stored.as_ref().is_some_and(|stored| {
8651                stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
8652            });
8653            if !equal {
8654                return Err(format!(
8655                    "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
8656                    scope.column
8657                ));
8658            }
8659        }
8660        let mut values = partial;
8661        // Presence-set semantics: a proven-equal scope column is dropped, so
8662        // the patch leaves the stored value untouched (§6.2).
8663        for scope in &schema_table.scope_variables {
8664            values.remove(&scope.column);
8665        }
8666        values.insert(
8667            schema_table.primary_key.clone(),
8668            Value::from(row_id.to_owned()),
8669        );
8670        // §5.11: structural validation only; an absent key-id selector is
8671        // resolved at the push seam from the stored local row, and a
8672        // key-selection or unknown-key failure becomes a durable
8673        // `client.encrypt_failed` rejection there (§10.3), never an
8674        // author-time error.
8675        self.validate_author_encode(schema_table, row_id, &values)?;
8676        Ok(OutboxOp {
8677            upsert: true,
8678            table: table.to_owned(),
8679            row_id: row_id.to_owned(),
8680            base_version,
8681            values: Some(values),
8682        })
8683    }
8684
8685    /// §5.11 key-id fallback: the stored local row's selector value as a
8686    /// positional row (only the selector slot filled). Returns None when no
8687    /// selector is configured, the row is locally absent, or the stored
8688    /// value is not a usable key id.
8689    fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
8690        let selector = self.encryption.key_id_columns.get(&table.name)?;
8691        let index = table
8692            .columns
8693            .iter()
8694            .position(|column| &column.name == selector)?;
8695        let sql = format!(
8696            "SELECT {} FROM {} WHERE {}",
8697            quote_ident(selector),
8698            visible_table(&table.name),
8699            row_id_predicate(table)
8700        );
8701        let key: Option<String> = self
8702            .conn
8703            .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
8704            .ok()?;
8705        let key = key.filter(|key| !key.is_empty())?;
8706        let mut fallback: Row = vec![None; table.columns.len()];
8707        fallback[index] = Some(ColumnValue::String(key));
8708        Some(fallback)
8709    }
8710
8711    /// §5.11/§10.3 author-time encode check: a structural failure (a
8712    /// non-nullable NULL, a value the codec rejects) is an author-time
8713    /// error; a key-selection or unknown-key failure is deferred to the push
8714    /// seam, where it becomes a durable `client.encrypt_failed` rejection.
8715    fn validate_author_encode(
8716        &self,
8717        table: &TableSchema,
8718        row_id: &str,
8719        values: &Map<String, Value>,
8720    ) -> Result<(), String> {
8721        match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
8722            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
8723            result => result.map(|_| ()),
8724        }
8725    }
8726
8727    /// §5.11/§10.3: drop every pending commit whose push encode fails with
8728    /// `client.encrypt_failed` (an unresolvable key id or an unknown key) and
8729    /// raise one durable rejection per commit. `sync()` never aborts for an
8730    /// encode failure. Mirrors §7.4.4's `drop_incompatible_outbox`, including
8731    /// undoing the dropped commit's purely-optimistic rows.
8732    fn drop_unencodable_outbox(&mut self) -> Result<bool, String> {
8733        if !self
8734            .schema
8735            .tables
8736            .iter()
8737            .any(|table| table.has_encrypted_columns())
8738        {
8739            return Ok(false);
8740        }
8741        let failures = self
8742            .outbox
8743            .iter()
8744            .filter_map(|commit| {
8745                commit
8746                    .ops
8747                    .iter()
8748                    .find_map(|op| {
8749                        let values = op.values.as_ref().filter(|_| op.upsert)?;
8750                        let table = self.schema.table(&op.table)?;
8751                        let fallback = self.stored_key_fallback(table, &op.row_id);
8752                        match encode_sparse_row_json(
8753                            table,
8754                            &op.row_id,
8755                            values,
8756                            &self.encryption,
8757                            fallback.as_ref(),
8758                        ) {
8759                            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Some(error),
8760                            _ => None,
8761                        }
8762                    })
8763                    .map(|error| (commit.clone(), error))
8764            })
8765            .collect::<Vec<_>>();
8766        if failures.is_empty() {
8767            return Ok(false);
8768        }
8769        for (commit, message) in failures {
8770            if self.retain_failed_commits {
8771                self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
8772            }
8773            let rejection = RejectionRecord {
8774                client_commit_id: commit.client_commit_id.clone(),
8775                op_index: 0,
8776                code: ENCRYPT_FAILED_CODE.to_owned(),
8777                message,
8778                retryable: false,
8779                details: None,
8780                operation: commit.ops.first().map(CommitOperation::from),
8781            };
8782            self.persist_commit_outcome(
8783                &commit.client_commit_id,
8784                CommitOutcomeStatus::Rejected,
8785                &[CommitOperationOutcome::Error {
8786                    rejection: rejection.clone(),
8787                }],
8788                Some(&commit.ops),
8789            )?;
8790            self.delete_outbox_persisted(&commit.client_commit_id)?;
8791            self.outbox
8792                .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
8793            self.rejections.push(rejection);
8794        }
8795        self.prune_commit_outcomes()?;
8796        self.overlay_dirty.set(true);
8797        self.rebuild_overlay();
8798        Ok(true)
8799    }
8800
8801    pub fn pending_commit_ids(&self) -> Vec<String> {
8802        self.outbox
8803            .iter()
8804            .map(|c| c.client_commit_id.clone())
8805            .collect()
8806    }
8807
8808    /// §6.1: the sparse push payload bytes of every pending upsert operation,
8809    /// FIFO — exactly what the next push round would put on the wire. Used by
8810    /// conformance B.21(h) to pin cross-core byte identity.
8811    pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
8812        let mut payloads = Vec::new();
8813        for commit in &self.outbox {
8814            for op in &commit.ops {
8815                let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
8816                    continue;
8817                };
8818                let Some(table) = self.schema.table(&op.table) else {
8819                    continue;
8820                };
8821                let fallback = self.stored_key_fallback(table, &op.row_id);
8822                if let Ok(payload) = encode_sparse_row_json(
8823                    table,
8824                    &op.row_id,
8825                    values,
8826                    &self.encryption,
8827                    fallback.as_ref(),
8828                ) {
8829                    payloads.push(payload);
8830                }
8831            }
8832        }
8833        payloads
8834    }
8835
8836    pub fn conflicts(&self) -> &[ConflictRecord] {
8837        &self.conflicts
8838    }
8839
8840    pub fn rejections(&self) -> &[RejectionRecord] {
8841        &self.rejections
8842    }
8843
8844    pub fn resolve_commit_outcome(
8845        &mut self,
8846        input: ResolveCommitOutcomeInput,
8847    ) -> Result<CommitOutcome, String> {
8848        let current = self
8849            .commit_outcome(&input.client_commit_id)?
8850            .ok_or_else(|| {
8851                format!(
8852                    "sync.outcome_not_found: no durable outcome exists for {:?}",
8853                    input.client_commit_id
8854                )
8855            })?;
8856        if current.resolution != CommitOutcomeResolution::Active {
8857            return Ok(current);
8858        }
8859        if input.resolution == CommitOutcomeResolution::Active {
8860            return Err("sync.invalid_request: resolution must leave active state".to_owned());
8861        }
8862        match input.resolution {
8863            CommitOutcomeResolution::Superseded => {
8864                let replacement = input
8865                    .replacement_client_commit_id
8866                    .as_deref()
8867                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
8868                    .ok_or_else(|| {
8869                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
8870                            .to_owned()
8871                    })?;
8872                let _ = replacement;
8873            }
8874            _ if input.replacement_client_commit_id.is_some() => {
8875                return Err(
8876                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
8877                        .to_owned(),
8878                );
8879            }
8880            _ => {}
8881        }
8882        let allowed = match current.status {
8883            CommitOutcomeStatus::Conflict => matches!(
8884                input.resolution,
8885                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
8886            ),
8887            CommitOutcomeStatus::Rejected => {
8888                matches!(
8889                    input.resolution,
8890                    CommitOutcomeResolution::Superseded
8891                        | CommitOutcomeResolution::ResolvedKeepServer
8892                )
8893            }
8894            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
8895                input.resolution == CommitOutcomeResolution::Dismissed
8896            }
8897        };
8898        if !allowed {
8899            return Err(format!(
8900                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
8901                input.resolution, current.status
8902            ));
8903        }
8904
8905        self.begin_observation("syncular_outcome_resolution")?;
8906        let previous_failed = self.failed_commits.clone();
8907        let previous_conflicts = self.conflicts.clone();
8908        let previous_rejections = self.rejections.clone();
8909        let result = (|| {
8910            self.conn
8911                .execute(
8912                    "UPDATE _syncular_commit_outcomes
8913                        SET resolution = ?1, resolved_at_ms = ?2,
8914                            replacement_client_commit_id = ?3
8915                      WHERE client_commit_id = ?4 AND resolution = 'active'",
8916                    rusqlite::params![
8917                        Self::outcome_resolution_name(input.resolution),
8918                        self.clock_now_ms(),
8919                        input.replacement_client_commit_id,
8920                        input.client_commit_id
8921                    ],
8922                )
8923                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8924            let resolved = self
8925                .commit_outcome(&current.client_commit_id)?
8926                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
8927            self.conn
8928                .execute(
8929                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
8930                    rusqlite::params![input.client_commit_id],
8931                )
8932                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8933            self.failed_commits
8934                .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
8935            self.overlay_dirty.set(true);
8936            self.prune_commit_outcomes()?;
8937            Ok(resolved)
8938        })();
8939        let resolved = match result {
8940            Ok(outcome) => outcome,
8941            Err(error) => {
8942                self.rollback_observation("syncular_outcome_resolution");
8943                self.failed_commits = previous_failed;
8944                self.conflicts = previous_conflicts;
8945                self.rejections = previous_rejections;
8946                return Err(error);
8947            }
8948        };
8949        self.conflicts
8950            .retain(|record| record.client_commit_id != current.client_commit_id);
8951        self.rejections
8952            .retain(|record| record.client_commit_id != current.client_commit_id);
8953        let mut batch = ChangeAccumulator {
8954            conflicts: current.status == CommitOutcomeStatus::Conflict,
8955            rejections: current.status == CommitOutcomeStatus::Rejected,
8956            outcomes: true,
8957            ..ChangeAccumulator::default()
8958        };
8959        if let Some(operations) = &current.operations {
8960            for operation in operations {
8961                batch.table(&operation.table);
8962            }
8963        }
8964        self.rebuild_overlay_if_dirty();
8965        if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
8966            self.rollback_observation("syncular_outcome_resolution");
8967            self.failed_commits = previous_failed;
8968            self.conflicts = previous_conflicts;
8969            self.rejections = previous_rejections;
8970            return Err(error);
8971        }
8972        Ok(resolved)
8973    }
8974
8975    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
8976        self.schema_floor.as_ref()
8977    }
8978
8979    /// §7.3.5: the client's opaque auth-lease state, if any.
8980    pub fn lease_state(&self) -> Option<&LeaseState> {
8981        self.lease_state.as_ref()
8982    }
8983
8984    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
8985    /// the two lease codes set it; other errors leave leaseState untouched.
8986    fn record_lease_error(&mut self, code: &str) {
8987        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
8988            return;
8989        }
8990        let mut next = self.lease_state.clone().unwrap_or_default();
8991        next.error_code = Some(code.to_owned());
8992        self.set_lease_state(Some(next));
8993    }
8994
8995    fn set_lease_state(&mut self, next: Option<LeaseState>) {
8996        if self.lease_state == next {
8997            return;
8998        }
8999        if self.begin_observation("syncular_lease").is_err() {
9000            return;
9001        }
9002        self.lease_state = next;
9003        if let Some(lease) = &self.lease_state {
9004            if let Ok(json) = serde_json::to_string(lease) {
9005                self.set_meta(LEASE_STATE_KEY, &json);
9006            }
9007        } else {
9008            self.delete_meta(LEASE_STATE_KEY);
9009        }
9010        let batch = ChangeAccumulator {
9011            status: true,
9012            ..ChangeAccumulator::default()
9013        };
9014        if self.finish_observation("syncular_lease", batch).is_err() {
9015            self.rollback_observation("syncular_lease");
9016        }
9017    }
9018
9019    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
9020        if self.schema_floor == next {
9021            return;
9022        }
9023        if self.begin_observation("syncular_schema_floor").is_err() {
9024            return;
9025        }
9026        self.schema_floor = next;
9027        self.stopped = self.schema_floor.is_some();
9028        if let Some(floor) = &self.schema_floor {
9029            if let Ok(json) = serde_json::to_string(floor) {
9030                self.set_meta(SCHEMA_FLOOR_KEY, &json);
9031            }
9032        } else {
9033            self.delete_meta(SCHEMA_FLOOR_KEY);
9034        }
9035        let batch = ChangeAccumulator {
9036            status: true,
9037            ..ChangeAccumulator::default()
9038        };
9039        if self
9040            .finish_observation("syncular_schema_floor", batch)
9041            .is_err()
9042        {
9043            self.rollback_observation("syncular_schema_floor");
9044        }
9045    }
9046
9047    fn set_upgrading(&mut self, value: bool) {
9048        if self.upgrading == value {
9049            return;
9050        }
9051        if self.begin_observation("syncular_upgrading").is_err() {
9052            return;
9053        }
9054        self.upgrading = value;
9055        let batch = ChangeAccumulator {
9056            status: true,
9057            ..ChangeAccumulator::default()
9058        };
9059        if self
9060            .finish_observation("syncular_upgrading", batch)
9061            .is_err()
9062        {
9063            self.rollback_observation("syncular_upgrading");
9064        }
9065    }
9066
9067    pub fn sync_needed(&self) -> bool {
9068        self.sync_needed
9069    }
9070
9071    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
9072        let sub = self.subs.iter().find(|s| s.id == id)?;
9073        Some(SubscriptionStateView {
9074            id: sub.id.clone(),
9075            table: sub.table.clone(),
9076            status: sub.state.name().to_owned(),
9077            cursor: sub.cursor,
9078            has_resume_token: sub.bootstrap_state.is_some(),
9079            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
9080            reason_code: sub.reason_code.clone(),
9081        })
9082    }
9083
9084    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
9085        let schema_table = self
9086            .schema
9087            .table(table)
9088            .ok_or_else(|| format!("unknown table {table:?}"))?;
9089        let sql = format!(
9090            "SELECT * FROM {} ORDER BY {} ASC",
9091            visible_table(table),
9092            quote_ident(&schema_table.primary_key)
9093        );
9094        let mut stmt = self
9095            .conn
9096            .prepare(&sql)
9097            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9098        let mut rows = stmt
9099            .query([])
9100            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9101        let mut out = Vec::new();
9102        while let Some(row) = rows
9103            .next()
9104            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
9105        {
9106            let mut values = Map::new();
9107            for (i, column) in schema_table.columns.iter().enumerate() {
9108                let value = row
9109                    .get_ref(i)
9110                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9111                values.insert(column.name.clone(), sql_ref_to_json(column, value));
9112            }
9113            let version: i64 = row
9114                .get(schema_table.columns.len())
9115                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9116            let row_id = match values.get(&schema_table.primary_key) {
9117                Some(Value::String(s)) => s.clone(),
9118                Some(Value::Number(n)) => n.to_string(),
9119                Some(Value::Bool(b)) => b.to_string(),
9120                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
9121            };
9122            out.push(RowState {
9123                row_id,
9124                version,
9125                values,
9126            });
9127        }
9128        Ok(out)
9129    }
9130
9131    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
9132    //
9133    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
9134    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
9135    // the op with `yrs`, re-encodes the whole doc state, and records a
9136    // baseVersion-less sparse upsert carrying only the primary key and the
9137    // crdt column (§5.10.3, §6.6). No local merge — merging is server-side;
9138    // the overlay re-materializes the edit immediately (optimistic apply,
9139    // §7.1) and the server-merged bytes arrive on the next pull, idempotently.
9140    // Byte-compatible with `@syncular/crdt-yjs`.
9141
9142    /// The current stored value of a `crdt` column for one row — the visible
9143    /// (optimistic) bytes, or `None` when the row is absent or the column is
9144    /// NULL (the empty document, §5.10.1). Errors if the column is not a
9145    /// `crdt` column (guards the app against a typo'd column name).
9146    #[cfg(feature = "crdt-yjs")]
9147    fn crdt_column_bytes(
9148        &self,
9149        table: &str,
9150        row_id: &str,
9151        column: &str,
9152    ) -> Result<Option<Vec<u8>>, String> {
9153        let schema_table = self
9154            .schema
9155            .table(table)
9156            .ok_or_else(|| format!("unknown table {table:?}"))?;
9157        let col = schema_table
9158            .columns
9159            .iter()
9160            .find(|c| c.name == column)
9161            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
9162        if col.ty != ColumnType::Crdt {
9163            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
9164        }
9165        let sql = format!(
9166            "SELECT {} FROM {} WHERE {}",
9167            quote_ident(column),
9168            visible_table(table),
9169            row_id_predicate(schema_table)
9170        );
9171        let bytes: Option<Vec<u8>> = self
9172            .conn
9173            .prepare_cached(&sql)
9174            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
9175            .query_row(rusqlite::params![row_id], |row| {
9176                row.get::<_, Option<Vec<u8>>>(0)
9177            })
9178            .map_err(|e| match e {
9179                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
9180                other => other.to_string(),
9181            })?;
9182        Ok(bytes)
9183    }
9184
9185    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
9186    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
9187    /// An absent row / NULL column is the empty document (empty string).
9188    #[cfg(feature = "crdt-yjs")]
9189    pub fn crdt_text(
9190        &self,
9191        table: &str,
9192        row_id: &str,
9193        column: &str,
9194        name: &str,
9195    ) -> Result<String, String> {
9196        let bytes = self
9197            .crdt_column_bytes(table, row_id, column)?
9198            .unwrap_or_default();
9199        crate::crdt::text(&bytes, name)
9200    }
9201
9202    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and
9203    /// record the resulting crdt-only sparse operation. Returns the enqueued
9204    /// `clientCommitId`.
9205    #[cfg(feature = "crdt-yjs")]
9206    pub fn crdt_insert_text(
9207        &mut self,
9208        table: &str,
9209        row_id: &str,
9210        column: &str,
9211        name: &str,
9212        index: u32,
9213        value: &str,
9214    ) -> Result<String, String> {
9215        let current = self
9216            .crdt_column_bytes(table, row_id, column)?
9217            .unwrap_or_default();
9218        let update = crate::crdt::insert_text(&current, name, index, value)?;
9219        self.crdt_push_update(table, row_id, column, &update)
9220    }
9221
9222    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and
9223    /// record the resulting crdt-only sparse operation. Returns the enqueued
9224    /// `clientCommitId`.
9225    #[cfg(feature = "crdt-yjs")]
9226    pub fn crdt_delete_text(
9227        &mut self,
9228        table: &str,
9229        row_id: &str,
9230        column: &str,
9231        name: &str,
9232        index: u32,
9233        len: u32,
9234    ) -> Result<String, String> {
9235        let current = self
9236            .crdt_column_bytes(table, row_id, column)?
9237            .unwrap_or_default();
9238        let update = crate::crdt::delete_text(&current, name, index, len)?;
9239        self.crdt_push_update(table, row_id, column, &update)
9240    }
9241
9242    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
9243    /// `crdt` column's current state and record the resulting crdt-only sparse
9244    /// operation. The app authored the update with its own `yrs` model.
9245    /// Returns the enqueued `clientCommitId`.
9246    #[cfg(feature = "crdt-yjs")]
9247    pub fn crdt_apply_update(
9248        &mut self,
9249        table: &str,
9250        row_id: &str,
9251        column: &str,
9252        update: &[u8],
9253    ) -> Result<String, String> {
9254        let current = self
9255            .crdt_column_bytes(table, row_id, column)?
9256            .unwrap_or_default();
9257        let next = crate::crdt::apply_update(&current, update)?;
9258        self.crdt_push_update(table, row_id, column, &next)
9259    }
9260
9261    /// Shared tail of the crdt edit methods: record a §6.1 sparse crdt-only
9262    /// upsert — the primary key plus the crdt column, no `baseVersion` — and
9263    /// enqueue it. The server merges the crdt column and leaves every other
9264    /// column untouched (§5.10.3), so a baseVersion-less crdt edit cannot
9265    /// clobber a concurrent edit to another column. A locally absent row
9266    /// records the same partial operation; the server answers per §6.2.
9267    #[cfg(feature = "crdt-yjs")]
9268    fn crdt_push_update(
9269        &mut self,
9270        table: &str,
9271        row_id: &str,
9272        column: &str,
9273        crdt_bytes: &[u8],
9274    ) -> Result<String, String> {
9275        let mut bytes_obj = Map::new();
9276        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
9277        let mut partial = Map::new();
9278        partial.insert(column.to_owned(), Value::Object(bytes_obj));
9279        self.patch(table, row_id, partial, None)
9280    }
9281
9282    /// Run an arbitrary read-only SQL query against the local database and
9283    /// return each row as a `column-name → JSON value` map. This is the seam
9284    /// the React `useSyncQuery` live-query API needs (it takes app-authored
9285    /// SQL over the visible tables/views, not a fixed table read like
9286    /// [`read_rows`]).
9287    ///
9288    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
9289    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
9290    /// same envelope the command surface uses everywhere else. Output BLOB
9291    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
9292    ///
9293    /// The result column typing is dynamic (SQLite's stored affinity), because
9294    /// arbitrary SQL can alias, join, and compute — there is no schema column
9295    /// to consult per output cell, unlike [`read_rows`].
9296    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
9297        query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
9298    }
9299
9300    /// Repository benchmark and conformance access, present only with `bench-internals`.
9301    #[cfg(feature = "bench-internals")]
9302    #[doc(hidden)]
9303    pub fn benchmark_connection(&mut self) -> &mut Connection {
9304        &mut self.conn
9305    }
9306
9307    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
9308    #[cfg(feature = "bench-internals")]
9309    #[doc(hidden)]
9310    pub fn benchmark_phases(
9311        &self,
9312        enabled: Option<bool>,
9313        reset: bool,
9314    ) -> Result<Option<Value>, String> {
9315        self.benchmark_phases.configure(enabled, reset)?;
9316        Ok(self.benchmark_phases.snapshot())
9317    }
9318
9319    /// Rows, coverage, and local revision from one SQLite read snapshot.
9320    /// §7.5 atomic snapshot read. An `owner` records a failure in
9321    /// `diagnostics_snapshot().query_failures` until its next successful read.
9322    pub fn query_snapshot(
9323        &mut self,
9324        sql: &str,
9325        params: &[QueryValue],
9326        coverage: &[WindowCoverage],
9327        owner: Option<QueryOwner<'_>>,
9328    ) -> Result<QuerySnapshot, String> {
9329        let result = snapshot_connection(&self.conn, sql, params, coverage);
9330        if let Some(owner) = owner {
9331            self.record_query_read(owner, result.as_ref().err());
9332        }
9333        result.map_err(|failure| failure.to_string())
9334    }
9335
9336    /// §7.6: record the outcome of one owned snapshot read, including reads a
9337    /// host ran on a separate connection. A success removes the owner's entry;
9338    /// an identical failure leaves it unchanged; a changed failure moves last.
9339    pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
9340        let position = self
9341            .query_failures
9342            .iter()
9343            .position(|entry| entry.id == owner.id);
9344        let Some(failure) = failure else {
9345            if let Some(index) = position {
9346                self.query_failures.remove(index);
9347            }
9348            return;
9349        };
9350        let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
9351            .into_iter()
9352            .map(str::to_owned)
9353            .collect();
9354        let code = failure.code.unwrap_or("client.query_failed").to_owned();
9355        if position.is_some_and(|index| {
9356            let entry = &self.query_failures[index];
9357            entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
9358        }) {
9359            return;
9360        }
9361        if let Some(index) = position {
9362            self.query_failures.remove(index);
9363        }
9364        self.query_failures.push(DiagnosticQueryFailure {
9365            id: owner.id.to_owned(),
9366            tables,
9367            code,
9368            sqlite_code: failure.sqlite_code,
9369            at_ms: self.clock_now_ms(),
9370        });
9371        if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
9372            self.query_failures.remove(0);
9373        }
9374    }
9375
9376    // -- request building ---------------------------------------------------------
9377
9378    fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
9379        #[cfg(feature = "bench-internals")]
9380        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
9381        let log_epoch = self.get_meta(LOG_EPOCH_KEY);
9382        let mut frames = vec![Frame::ReqHeader {
9383            client_id: self.client_id.clone(),
9384            schema_version: self.schema.version,
9385            log_epoch: log_epoch.clone(),
9386        }];
9387        let mut pushed_ids = Vec::new();
9388        let mut ops_in_request = 0usize;
9389        let mut deferred_commits = 0usize;
9390        #[cfg(feature = "bench-internals")]
9391        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
9392        for (index, commit) in self
9393            .outbox
9394            .iter()
9395            .take(if log_epoch.is_some() {
9396                self.outbox.len()
9397            } else {
9398                0
9399            })
9400            .enumerate()
9401        {
9402            // §6.1 splitBatch: stop at the operation cap — commits apply in
9403            // order, so everything from the first non-fitting commit on is
9404            // deferred to the next round. A single over-cap commit still goes
9405            // alone (commits are atomic and cannot be split).
9406            if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
9407                deferred_commits = self.outbox.len() - index;
9408                break;
9409            }
9410            ops_in_request += commit.ops.len();
9411            let operations = commit
9412                .ops
9413                .iter()
9414                .map(|op| {
9415                    let payload = op.values.as_ref().and_then(|values| {
9416                        let table = self.schema.table(&op.table)?;
9417                        // §0: outbox entries encode at send time with the
9418                        // current codec (validated at mutate()). §6.1: the
9419                        // values map's keys are the sparse presence set.
9420                        // §5.11: encrypted columns are encrypted here, with
9421                        // an absent key-id selector read from the stored row.
9422                        // `sync_inner` runs `drop_unencodable_outbox` first,
9423                        // so an encrypt failure cannot reach this encode; the
9424                        // author seam validated every other failure.
9425                        let fallback = self.stored_key_fallback(table, &op.row_id);
9426                        encode_sparse_row_json(
9427                            table,
9428                            &op.row_id,
9429                            values,
9430                            &self.encryption,
9431                            fallback.as_ref(),
9432                        )
9433                        .ok()
9434                    });
9435                    ssp2::model::Operation {
9436                        table: op.table.clone(),
9437                        row_id: op.row_id.clone(),
9438                        op: if op.upsert { Op::Upsert } else { Op::Delete },
9439                        base_version: op.base_version,
9440                        payload,
9441                    }
9442                })
9443                .collect();
9444            frames.push(Frame::PushCommit {
9445                client_commit_id: commit.client_commit_id.clone(),
9446                operations,
9447            });
9448            pushed_ids.push(commit.client_commit_id.clone());
9449        }
9450        #[cfg(feature = "bench-internals")]
9451        drop(outbox_phase);
9452        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
9453        // bare URL — capability negotiation, decided per transport.
9454        let accept = self.limits.accept.unwrap_or(if url_capable {
9455            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
9456        } else {
9457            DEFAULT_ACCEPT
9458        });
9459        frames.push(Frame::PullHeader {
9460            limit_commits: self.limits.limit_commits.unwrap_or(0),
9461            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
9462            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
9463            accept,
9464        });
9465        let mut fresh = Vec::new();
9466        for sub in &self.subs {
9467            if sub.state != SubState::Active {
9468                continue;
9469            }
9470            let mut scopes = sub.requested.clone();
9471            sort_scope_map(&mut scopes);
9472            frames.push(Frame::Subscription {
9473                id: sub.id.clone(),
9474                table: sub.table.clone(),
9475                scopes,
9476                params: sub.params.clone().map(RawJson),
9477                cursor: sub.cursor,
9478                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
9479            });
9480            fresh.push((
9481                sub.id.clone(),
9482                sub.cursor < 0 && sub.bootstrap_state.is_none(),
9483            ));
9484        }
9485        let message = Message {
9486            wire_version: WIRE_VERSION,
9487            msg_kind: MsgKind::Request,
9488            frames,
9489        };
9490        (
9491            message,
9492            RequestMeta {
9493                pushed_ids,
9494                fresh,
9495                accept,
9496                deferred_commits,
9497            },
9498        )
9499    }
9500
9501    // -- sync -------------------------------------------------------------------
9502
9503    /// Cloneable observation handle; no client lock is needed to read it.
9504    pub fn progress(&self) -> ProgressObserver {
9505        self.progress.clone()
9506    }
9507
9508    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
9509        self.storage_failure.borrow_mut().take();
9510        self.progress.start();
9511        self.round_retry_delay_ms = None;
9512        let started_at_ms = self.clock_now_ms();
9513        let mut outcome = self.sync_inner(transport);
9514        if let SyncOutcome::Failed {
9515            error_code,
9516            message,
9517            details,
9518        } = &mut outcome
9519        {
9520            if let Some(failure) = self.storage_failure.borrow().as_ref() {
9521                let code = failure
9522                    .code
9523                    .expect("retained storage failures are classified");
9524                *error_code = code.to_owned();
9525                *message = match code {
9526                    "client.storage_full" => "local SQLite storage is full",
9527                    "client.storage_io" => "local SQLite storage I/O failed",
9528                    "client.storage_corrupt" => "local SQLite storage is corrupt",
9529                    _ => unreachable!("unrecognized classified storage failure"),
9530                }
9531                .to_owned();
9532                *details = failure.details();
9533            }
9534        }
9535        let retry_delay_ms = self.round_retry_delay_ms;
9536        self.progress.update(|p| match &outcome {
9537            SyncOutcome::Ok(report) if report.failed.is_empty() => {
9538                p.state = ProgressState::Complete
9539            }
9540            SyncOutcome::Ok(_) => {
9541                p.state = ProgressState::Failed;
9542                p.error_code = Some("sync.scope_revoked".into());
9543            }
9544            SyncOutcome::Failed { error_code, .. } => {
9545                p.state = ProgressState::Failed;
9546                p.error_code = Some(Self::diagnostic_code(error_code));
9547                p.retry_delay_ms = retry_delay_ms;
9548            }
9549            SyncOutcome::RealtimeUnavailable { .. } => {
9550                p.state = ProgressState::Failed;
9551                p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
9552                p.retry_delay_ms = retry_delay_ms;
9553            }
9554        });
9555        let completed_at_ms = self.clock_now_ms();
9556        self.last_round = Some(match &outcome {
9557            SyncOutcome::Ok(report) => DiagnosticLastRound {
9558                status: "succeeded".to_owned(),
9559                started_at_ms,
9560                completed_at_ms,
9561                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
9562                counters: Some(DiagnosticRoundCounters {
9563                    pushed: report.pushed,
9564                    applied: report.applied.len(),
9565                    rejected: report.rejected.len(),
9566                    retryable: report.retryable.len(),
9567                    conflicts: report.conflicts,
9568                    commits_applied: report.commits_applied,
9569                    segment_rows_applied: report.segment_rows_applied,
9570                    bootstrapping: report.bootstrapping.len(),
9571                    resets: report.resets.len(),
9572                    revoked: report.revoked.len(),
9573                    failed: report.failed.len(),
9574                    deferred_commits: report.deferred_commits,
9575                }),
9576                error_code: None,
9577            },
9578            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
9579                status: "failed".to_owned(),
9580                started_at_ms,
9581                completed_at_ms,
9582                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
9583                counters: None,
9584                error_code: Some(Self::diagnostic_code(error_code)),
9585            },
9586            SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
9587                status: "failed".to_owned(),
9588                started_at_ms,
9589                completed_at_ms,
9590                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
9591                counters: None,
9592                error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
9593            },
9594        });
9595        outcome
9596    }
9597
9598    fn sync_inner(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
9599        if self.stopped {
9600            // §1.6: the client stopped at the schema floor; syncing is inert
9601            // until an upgrade. The outbox is preserved for replay.
9602            return SyncOutcome::Ok(SyncReport {
9603                schema_floor: self.schema_floor.clone(),
9604                ..SyncReport::default()
9605            });
9606        }
9607        // §8.4: the coalesced sync-needed signal clears when a pull round
9608        // BEGINS, so a wake-up landing mid-round survives it.
9609        self.set_sync_needed(false, false);
9610        // §5.9.7 B4: upload pending blobs before pushing the referencing
9611        // rows, so the server-side existence check (§6.6) passes.
9612        if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
9613            if let Err(TransportError { code, message, .. }) = self.flush_blob_uploads(transport) {
9614                if Self::retryable_failure_code(&code) {
9615                    self.schedule_background_retry();
9616                }
9617                return SyncOutcome::Failed {
9618                    details: None,
9619                    error_code: code,
9620                    message,
9621                };
9622            }
9623        }
9624        if let Err(message) = self.drain_pending_evictions() {
9625            return SyncOutcome::Failed {
9626                details: None,
9627                error_code: "storage.failed".into(),
9628                message,
9629            };
9630        }
9631        // §5.11/§10.3: an unresolvable key id or an unknown key is a durable
9632        // per-commit rejection, never a reason to abort the round. Drop those
9633        // commits before the request is built.
9634        if let Err(message) = self.drop_unencodable_outbox() {
9635            return SyncOutcome::Failed {
9636                details: None,
9637                error_code: "storage.failed".into(),
9638                message,
9639            };
9640        }
9641        let (message, meta) = self.build_request(transport.supports_url_fetch());
9642        let request_bytes = {
9643            #[cfg(feature = "bench-internals")]
9644            let _phase = self.benchmark_phases.start(Phase::RequestEncode);
9645            encode_message(&message)
9646        };
9647        // §8.7: rounds ride the socket whenever it is connected (one
9648        // loop, no fallback pair); the transport seam stays bytes-in /
9649        // bytes-out either way. Registration-at-round-end is server-side.
9650        // §8.8: under `required`, a round entered while the socket is not
9651        // connected is refused as a typed state and NEVER falls back to the
9652        // request/response binding.
9653        let round = if self.realtime_state == RealtimeState::Connected {
9654            transport.realtime_sync(&request_bytes)
9655        } else if self.realtime_policy == RealtimePolicy::Required {
9656            let retry_delay_ms = self.schedule_background_retry();
9657            self.realtime_retry_delay_ms = Some(retry_delay_ms);
9658            return SyncOutcome::RealtimeUnavailable {
9659                state: self.realtime_state,
9660                reason_code: self.realtime_reason_code.clone(),
9661                retry_delay_ms,
9662            };
9663        } else {
9664            transport.sync(&request_bytes)
9665        };
9666        let realtime_round = self.realtime_state == RealtimeState::Connected;
9667        let response_bytes = match round {
9668            Ok(bytes) => bytes,
9669            Err(TransportError { code, message, .. }) => {
9670                // §8.8: the socket could not carry the round. Record the loss
9671                // with the transport's code so a later round refuses under
9672                // `required` and reports the explicit state.
9673                if realtime_round {
9674                    self.set_realtime_state(RealtimeState::Lost, Some(&code));
9675                }
9676                // §7.3.5: a request-level lease code stops-and-surfaces —
9677                // record it in leaseState (no local-data purge, §7.3.4).
9678                self.record_lease_error(&code);
9679                if Self::retryable_failure_code(&code) {
9680                    let retry_delay_ms = self.schedule_background_retry();
9681                    if realtime_round {
9682                        self.realtime_retry_delay_ms = Some(retry_delay_ms);
9683                    }
9684                }
9685                return SyncOutcome::Failed {
9686                    details: None,
9687                    error_code: code,
9688                    message,
9689                };
9690            }
9691        };
9692        #[cfg(feature = "bench-internals")]
9693        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
9694        let response = match decode_message(&response_bytes) {
9695            Ok(message) => message,
9696            Err(error) => {
9697                // §1.2 rule 1 / §1.4 rule 5: truncated or malformed
9698                // responses abort without persisting anything.
9699                return SyncOutcome::Failed {
9700                    details: None,
9701                    error_code: error.code.as_str().to_owned(),
9702                    message: error.detail,
9703                };
9704            }
9705        };
9706        #[cfg(feature = "bench-internals")]
9707        drop(decode_phase);
9708        if response.msg_kind != MsgKind::Response {
9709            return SyncOutcome::Failed {
9710                details: None,
9711                error_code: "sync.invalid_request".to_owned(),
9712                message: "expected a response message".to_owned(),
9713            };
9714        }
9715        let mut outcome = self.process_response(transport, response, &meta);
9716        if let SyncOutcome::Ok(report) = &mut outcome {
9717            report.deferred_commits = meta.deferred_commits;
9718        }
9719        match &outcome {
9720            SyncOutcome::Ok(_) => self.reset_background_retry(),
9721            SyncOutcome::Failed { error_code, .. } if Self::retryable_failure_code(error_code) => {
9722                self.schedule_background_retry();
9723            }
9724            SyncOutcome::Failed { .. } => {}
9725            SyncOutcome::RealtimeUnavailable { .. } => {}
9726        }
9727        if meta.deferred_commits > 0 {
9728            // §6.1 splitBatch: commits past the operation cap wait for the
9729            // next round — keep the host's sync signal raised until then.
9730            self.set_sync_needed(true, true);
9731        }
9732        // RFC 0005 D7: coverage completion, lease end, scope revocation and the
9733        // TTL are evaluated once per round, after the apply.
9734        self.previous_version_lifetime_check();
9735        outcome
9736    }
9737
9738    pub fn sync_until_idle(
9739        &mut self,
9740        transport: &mut dyn Transport,
9741        max_rounds: Option<u32>,
9742    ) -> SyncOutcome {
9743        let rounds = max_rounds.unwrap_or(20).max(1);
9744        let mut aggregate = SyncReport::default();
9745        let mut spent = 0;
9746        while spent < rounds {
9747            spent += 1;
9748            let before = self
9749                .subs
9750                .iter()
9751                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
9752                .collect::<Vec<_>>();
9753            let outcome = self.sync(transport);
9754            match outcome {
9755                SyncOutcome::Failed {
9756                    error_code,
9757                    message,
9758                    details,
9759                } => {
9760                    return SyncOutcome::Failed {
9761                        details,
9762                        error_code,
9763                        message,
9764                    };
9765                }
9766                SyncOutcome::RealtimeUnavailable { .. } => return outcome,
9767                SyncOutcome::Ok(report) => {
9768                    if max_rounds.is_none()
9769                        && report.segment_rows_applied > 0
9770                        && !report.bootstrapping.is_empty()
9771                        && before
9772                            != self
9773                                .subs
9774                                .iter()
9775                                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
9776                                .collect::<Vec<_>>()
9777                    {
9778                        spent = 0;
9779                    }
9780                    aggregate.pushed += report.pushed;
9781                    aggregate.applied.extend(report.applied.iter().cloned());
9782                    aggregate.rejected.extend(report.rejected.iter().cloned());
9783                    aggregate.retryable.extend(report.retryable.iter().cloned());
9784                    aggregate.conflicts += report.conflicts;
9785                    aggregate.commits_applied += report.commits_applied;
9786                    aggregate.segment_rows_applied += report.segment_rows_applied;
9787                    aggregate.bootstrapping = report.bootstrapping.clone();
9788                    aggregate.resets.extend(report.resets.iter().cloned());
9789                    aggregate.revoked.extend(report.revoked.iter().cloned());
9790                    aggregate.failed.extend(report.failed.iter().cloned());
9791                    aggregate.deferred_commits = report.deferred_commits;
9792                    if report.schema_floor.is_some() {
9793                        aggregate.schema_floor = report.schema_floor.clone();
9794                    }
9795                    // §4.5: pull again whenever the response contained
9796                    // commits or segments; resets re-bootstrap; a pending
9797                    // resume token continues paging (§4.7); a raised
9798                    // sync-needed signal covers §6.1 splitBatch remainders
9799                    // (deferred outbox commits push on the next round).
9800                    let more = !report.bootstrapping.is_empty()
9801                        || report.commits_applied > 0
9802                        || report.segment_rows_applied > 0
9803                        || !report.resets.is_empty()
9804                        || self.sync_needed;
9805                    if !more {
9806                        return SyncOutcome::Ok(aggregate);
9807                    }
9808                }
9809            }
9810        }
9811        SyncOutcome::Failed {
9812            details: None,
9813            error_code: "sync.invalid_request".into(),
9814            message: "sync did not reach idle within the round budget".into(),
9815        }
9816    }
9817
9818    fn process_response(
9819        &mut self,
9820        transport: &mut dyn Transport,
9821        response: Message,
9822        meta: &RequestMeta,
9823    ) -> SyncOutcome {
9824        #[cfg(feature = "bench-internals")]
9825        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
9826        let mut report = SyncReport {
9827            pushed: meta.pushed_ids.len() as u32,
9828            ..SyncReport::default()
9829        };
9830        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
9831            HashMap::new();
9832        let pushed_ids = meta
9833            .pushed_ids
9834            .iter()
9835            .map(String::as_str)
9836            .collect::<HashSet<_>>();
9837        let mut last_final_push_result_id: Option<String> = None;
9838        for frame in &response.frames {
9839            match frame {
9840                Frame::PushResultDetails {
9841                    client_commit_id,
9842                    entries,
9843                } => {
9844                    let details = rejection_details_by_commit
9845                        .entry(client_commit_id.clone())
9846                        .or_default();
9847                    for entry in entries {
9848                        let parsed = match RejectionDetails::parse(&entry.details.0) {
9849                            Ok(value) => value,
9850                            Err(message) => {
9851                                return SyncOutcome::Failed {
9852                                    details: None,
9853                                    error_code: "sync.invalid_request".to_owned(),
9854                                    message,
9855                                };
9856                            }
9857                        };
9858                        details.insert(entry.op_index, parsed);
9859                    }
9860                }
9861                Frame::PushResult {
9862                    client_commit_id,
9863                    status,
9864                    results,
9865                    ..
9866                } if pushed_ids.contains(client_commit_id.as_str())
9867                    && Self::push_result_is_final(*status, results) =>
9868                {
9869                    last_final_push_result_id = Some(client_commit_id.clone());
9870                }
9871                _ => {}
9872            }
9873        }
9874        let mut frames = response.frames.into_iter().peekable();
9875        if response.wire_version < 2 {
9876            return SyncOutcome::Failed {
9877                details: None,
9878                error_code: "client.invalid_host_response".to_owned(),
9879                message: "server response does not carry wire version 2 log-epoch state".to_owned(),
9880            };
9881        }
9882        match frames.next() {
9883            Some(Frame::RespHeader {
9884                required_schema_version,
9885                latest_schema_version,
9886                log_epoch,
9887                reset_required,
9888            }) => {
9889                let Some(log_epoch) = log_epoch else {
9890                    return SyncOutcome::Failed {
9891                        details: None,
9892                        error_code: "client.invalid_host_response".to_owned(),
9893                        message: "response header omits logEpoch".to_owned(),
9894                    };
9895                };
9896                let Some(reset_required) = reset_required else {
9897                    return SyncOutcome::Failed {
9898                        details: None,
9899                        error_code: "client.invalid_host_response".to_owned(),
9900                        message: "response header omits resetRequired".to_owned(),
9901                    };
9902                };
9903                if let Some(required) = required_schema_version {
9904                    // §1.6 schema-floor response: nothing else is processed;
9905                    // stop syncing and surface the upgrade requirement.
9906                    let floor = SchemaFloor {
9907                        required_schema_version: Some(required),
9908                        latest_schema_version,
9909                    };
9910                    self.set_schema_floor(Some(floor.clone()));
9911                    report.schema_floor = Some(floor);
9912                    return SyncOutcome::Ok(report);
9913                }
9914                if reset_required {
9915                    if frames.next().is_some() {
9916                        return SyncOutcome::Failed {
9917                            details: None,
9918                            error_code: "client.invalid_host_response".to_owned(),
9919                            message: "log-epoch reset response contains body frames".to_owned(),
9920                        };
9921                    }
9922                    match self.run_log_epoch_reset(&log_epoch) {
9923                        Ok(resets) => {
9924                            report.resets = resets;
9925                            return SyncOutcome::Ok(report);
9926                        }
9927                        Err(message) => {
9928                            return SyncOutcome::Failed {
9929                                details: None,
9930                                error_code: "sync.local_corrupt".to_owned(),
9931                                message,
9932                            };
9933                        }
9934                    }
9935                }
9936                if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
9937                    return SyncOutcome::Failed {
9938                        details: None,
9939                        error_code: "client.invalid_host_response".to_owned(),
9940                        message: "server changed logEpoch without requiring a reset".to_owned(),
9941                    };
9942                }
9943            }
9944            _ => {
9945                return SyncOutcome::Failed {
9946                    details: None,
9947                    error_code: "sync.invalid_request".to_owned(),
9948                    message: "response does not start with RESP_HEADER".to_owned(),
9949                };
9950            }
9951        }
9952
9953        let mut failure: Option<(String, String)> = None;
9954        while let Some(frame) = frames.next() {
9955            match frame {
9956                frame @ Frame::PushResult { .. } => {
9957                    let successful = matches!(
9958                        &frame,
9959                        Frame::PushResult {
9960                            status: PushStatus::Applied | PushStatus::Cached,
9961                            ..
9962                        }
9963                    );
9964                    let mut results = vec![frame];
9965                    if successful {
9966                        while matches!(
9967                            frames.peek(),
9968                            Some(Frame::PushResult {
9969                                status: PushStatus::Applied | PushStatus::Cached,
9970                                ..
9971                            })
9972                        ) {
9973                            if let Some(next) = frames.next() {
9974                                results.push(next);
9975                            }
9976                        }
9977                    }
9978                    if self
9979                        .handle_push_results(
9980                            &results,
9981                            &pushed_ids,
9982                            &rejection_details_by_commit,
9983                            &mut report,
9984                            last_final_push_result_id.as_deref(),
9985                        )
9986                        .is_err()
9987                    {
9988                        failure = Some((
9989                            "client.outcome_persistence_failed".to_owned(),
9990                            "local commit outcome could not be persisted".to_owned(),
9991                        ));
9992                        break;
9993                    }
9994                }
9995                Frame::PushResultDetails { .. } => {}
9996                Frame::SubStart {
9997                    id,
9998                    status,
9999                    reason_code,
10000                    effective_scopes,
10001                    bootstrap: _,
10002                } => {
10003                    let mut body = Vec::new();
10004                    let mut sub_end: Option<(i64, Option<String>)> = None;
10005                    for inner in frames.by_ref() {
10006                        match inner {
10007                            Frame::SubEnd {
10008                                next_cursor,
10009                                bootstrap_state,
10010                            } => {
10011                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
10012                                break;
10013                            }
10014                            Frame::Unknown { .. } => {}
10015                            other => body.push(other),
10016                        }
10017                    }
10018                    if let Err(SectionError::Abort(code, message)) = self.process_section(
10019                        transport,
10020                        &id,
10021                        status,
10022                        &reason_code,
10023                        effective_scopes,
10024                        body,
10025                        sub_end,
10026                        meta,
10027                        &mut report,
10028                    ) {
10029                        failure = Some((code, message));
10030                        break;
10031                    }
10032                }
10033                Frame::Lease {
10034                    lease_id,
10035                    expires_at_ms,
10036                } => {
10037                    // §7.3.5: persist the opaque lease; a fresh lease clears
10038                    // any prior lease error (the outage/revocation is over).
10039                    self.set_lease_state(Some(LeaseState {
10040                        lease_id: Some(lease_id),
10041                        expires_at_ms: Some(expires_at_ms),
10042                        error_code: None,
10043                    }));
10044                }
10045                Frame::Error { code, message, .. } => {
10046                    // §1.6: the whole request failed; already-completed
10047                    // subscriptions keep their applied data and cursors.
10048                    failure = Some((code, message));
10049                    break;
10050                }
10051                Frame::Unknown { .. } => {}
10052                _ => {
10053                    failure = Some((
10054                        "sync.invalid_request".to_owned(),
10055                        "unexpected frame in response".to_owned(),
10056                    ));
10057                    break;
10058                }
10059            }
10060        }
10061
10062        // §7.1: reconcile the visible overlay once at the response boundary
10063        // after all outbox acknowledgements and server data applied so a
10064        // batch of PUSH_RESULT frames cannot trigger repeated full-table
10065        // rebuilds. This also covers a round that aborted mid-way.
10066        if self.overlay_dirty.get() {
10067            self.rebuild_overlay();
10068        }
10069        if let Some((error_code, message)) = failure {
10070            return SyncOutcome::Failed {
10071                details: None,
10072                error_code,
10073                message,
10074            };
10075        }
10076        // §4.8 E1: the push half may have drained commits that pinned rows of
10077        // a shrunk window unit — retry any deferred evictions now.
10078        if let Err(message) = self.drain_pending_evictions() {
10079            return SyncOutcome::Failed {
10080                details: None,
10081                error_code: "storage.failed".into(),
10082                message,
10083            };
10084        }
10085        self.ack_after_pull(transport);
10086        // §7.4.5: the reset is over once the first post-reset pull round
10087        // leaves no subscription mid-bootstrap — the tables are rebuilt.
10088        if self.upgrading && report.bootstrapping.is_empty() {
10089            self.set_upgrading(false);
10090        }
10091        SyncOutcome::Ok(report)
10092    }
10093
10094    // -- push results (§6.3, §7.2) ------------------------------------------------
10095
10096    fn handle_push_results(
10097        &mut self,
10098        frames: &[Frame],
10099        pushed_ids: &HashSet<&str>,
10100        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
10101        report: &mut SyncReport,
10102        last_final_push_result_id: Option<&str>,
10103    ) -> Result<(), String> {
10104        self.begin_observation("syncular_push_results")?;
10105        let previous_failed = self.failed_commits.clone();
10106        let conflict_count = self.conflicts.len();
10107        let rejection_count = self.rejections.len();
10108        let applied_count = report.applied.len();
10109        let rejected_count = report.rejected.len();
10110        let retryable_count = report.retryable.len();
10111        let was_dirty = self.overlay_dirty.get();
10112        let mut removed = Vec::new();
10113        let mut batch = ChangeAccumulator::default();
10114        let persisted = (|| {
10115            for frame in frames {
10116                let Frame::PushResult {
10117                    client_commit_id,
10118                    status,
10119                    results,
10120                    ..
10121                } = frame
10122                else {
10123                    return Err("unexpected acknowledgement frame".to_owned());
10124                };
10125                if !pushed_ids.contains(client_commit_id.as_str()) {
10126                    continue;
10127                }
10128                let Some(index) = self
10129                    .outbox
10130                    .iter()
10131                    .position(|commit| commit.client_commit_id == *client_commit_id)
10132                else {
10133                    continue;
10134                };
10135                let status = *status;
10136                let rejection_details = rejection_details_by_commit.get(client_commit_id);
10137                let operations = self.outbox[index].ops.clone();
10138                match status {
10139                    PushStatus::Applied | PushStatus::Cached => {
10140                        // §7.2: a lost ack replays as `cached` — proceed as if the
10141                        // ack had arrived.
10142                        let journal_results = results
10143                            .iter()
10144                            .map(|result| {
10145                                let op_index = match result {
10146                                    OpResult::Applied { op_index }
10147                                    | OpResult::Conflict { op_index, .. }
10148                                    | OpResult::Error { op_index, .. } => *op_index,
10149                                };
10150                                CommitOperationOutcome::Applied { op_index }
10151                            })
10152                            .collect::<Vec<_>>();
10153                        let outcome_status = if status == PushStatus::Applied {
10154                            CommitOutcomeStatus::Applied
10155                        } else {
10156                            CommitOutcomeStatus::Cached
10157                        };
10158                        let persisted = self
10159                            .persist_commit_outcome(
10160                                client_commit_id,
10161                                outcome_status,
10162                                &journal_results,
10163                                None,
10164                            )
10165                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
10166                        persisted?;
10167                        self.overlay_dirty.set(true);
10168                        batch.status = true;
10169                        batch.outcomes = true;
10170                    }
10171                    PushStatus::Rejected => {
10172                        if results.iter().any(|result| {
10173                            matches!(
10174                                result,
10175                                OpResult::Error {
10176                                    code,
10177                                    retryable: true,
10178                                    ..
10179                                } if code == "sync.idempotency_cache_miss"
10180                            )
10181                        }) {
10182                            // §6.3/§7.2: a serving failure, not an outcome — keep the
10183                            // exact commit queued for an identical retry.
10184                            report.retryable.push(client_commit_id.to_owned());
10185                            continue;
10186                        }
10187
10188                        let mut journal_results = Vec::with_capacity(results.len());
10189                        let mut conflicts = Vec::new();
10190                        let mut rejections = Vec::new();
10191                        for result in results {
10192                            match result {
10193                                OpResult::Applied { op_index } => {
10194                                    journal_results.push(CommitOperationOutcome::Applied {
10195                                        op_index: *op_index,
10196                                    });
10197                                }
10198                                OpResult::Conflict {
10199                                    op_index,
10200                                    code,
10201                                    message,
10202                                    server_version,
10203                                    server_row,
10204                                    conflict_columns: conflict_columns_bitmap,
10205                                } => {
10206                                    let operation = operations
10207                                        .get(*op_index as usize)
10208                                        .map(CommitOperation::from);
10209                                    let (table, row_id) = operation
10210                                        .as_ref()
10211                                        .map(|op| (op.table.clone(), op.row_id.clone()))
10212                                        .unwrap_or_default();
10213                                    let server_row_json = self
10214                                        .schema
10215                                        .table(&table)
10216                                        .and_then(|t| {
10217                                            decode_row_bytes(t, server_row, &self.encryption)
10218                                                .ok()
10219                                                .map(|row| (t, row))
10220                                        })
10221                                        .map(|(t, row)| {
10222                                            let mut map = Map::new();
10223                                            for (i, column) in t.columns.iter().enumerate() {
10224                                                map.insert(
10225                                                    column.name.clone(),
10226                                                    column_value_to_json(
10227                                                        row.get(i).unwrap_or(&None),
10228                                                    ),
10229                                                );
10230                                            }
10231                                            map
10232                                        })
10233                                        .unwrap_or_default();
10234                                    // §6.3: decode the presence-layout bitmap
10235                                    // into the marked column names.
10236                                    let conflict_columns = self
10237                                        .schema
10238                                        .table(&table)
10239                                        .map(|t| {
10240                                            t.columns
10241                                                .iter()
10242                                                .enumerate()
10243                                                .filter(|(index, _)| {
10244                                                    conflict_columns_bitmap
10245                                                        .get(index / 8)
10246                                                        .is_some_and(|byte| {
10247                                                            byte & (1 << (index % 8)) != 0
10248                                                        })
10249                                                })
10250                                                .map(|(_, column)| column.name.clone())
10251                                                .collect::<Vec<_>>()
10252                                        })
10253                                        .unwrap_or_default();
10254                                    let conflict = ConflictRecord {
10255                                        client_commit_id: client_commit_id.to_owned(),
10256                                        op_index: *op_index,
10257                                        table,
10258                                        row_id,
10259                                        code: code.clone(),
10260                                        message: message.clone(),
10261                                        server_version: *server_version,
10262                                        server_row: server_row_json,
10263                                        conflict_columns,
10264                                        operation,
10265                                    };
10266                                    journal_results.push(CommitOperationOutcome::Conflict {
10267                                        conflict: conflict.clone(),
10268                                    });
10269                                    conflicts.push(conflict);
10270                                }
10271                                OpResult::Error {
10272                                    op_index,
10273                                    code,
10274                                    message,
10275                                    retryable,
10276                                } => {
10277                                    let rejection = RejectionRecord {
10278                                        client_commit_id: client_commit_id.to_owned(),
10279                                        op_index: *op_index,
10280                                        code: code.clone(),
10281                                        message: message.clone(),
10282                                        retryable: *retryable,
10283                                        details: rejection_details
10284                                            .and_then(|details| details.get(op_index))
10285                                            .cloned(),
10286                                        operation: operations
10287                                            .get(*op_index as usize)
10288                                            .map(CommitOperation::from),
10289                                    };
10290                                    journal_results.push(CommitOperationOutcome::Error {
10291                                        rejection: rejection.clone(),
10292                                    });
10293                                    rejections.push(rejection);
10294                                }
10295                            }
10296                        }
10297                        if self.retain_failed_commits {
10298                            self.retain_failed_commit(client_commit_id, &operations)?;
10299                        }
10300                        let outcome_status = if conflicts.is_empty() {
10301                            CommitOutcomeStatus::Rejected
10302                        } else {
10303                            CommitOutcomeStatus::Conflict
10304                        };
10305                        let persisted = self
10306                            .persist_commit_outcome(
10307                                client_commit_id,
10308                                outcome_status,
10309                                &journal_results,
10310                                Some(&operations),
10311                            )
10312                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
10313                        persisted?;
10314                        batch.conflicts = !conflicts.is_empty();
10315                        batch.rejections = !rejections.is_empty();
10316                        batch.status = true;
10317                        batch.outcomes = true;
10318                        self.conflicts.extend(conflicts);
10319                        self.rejections.extend(rejections);
10320                        self.overlay_dirty.set(true);
10321                    }
10322                }
10323                removed.push((index, self.outbox.remove(index)));
10324                if batch.status {
10325                    for operation in &operations {
10326                        if !self.record_row_scopes(
10327                            &mut batch,
10328                            &operation.table,
10329                            &operation.row_id,
10330                            false,
10331                        ) {
10332                            batch.table(&operation.table);
10333                        }
10334                    }
10335                }
10336                match status {
10337                    PushStatus::Applied | PushStatus::Cached => {
10338                        report.applied.push(client_commit_id.to_owned())
10339                    }
10340                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
10341                }
10342            }
10343            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)) {
10344                self.prune_commit_outcomes()?;
10345            }
10346            self.finish_observation("syncular_push_results", batch)
10347        })();
10348        if let Err(error) = persisted {
10349            self.rollback_observation("syncular_push_results");
10350            self.failed_commits = previous_failed;
10351            for (index, commit) in removed.into_iter().rev() {
10352                self.outbox.insert(index, commit);
10353            }
10354            self.conflicts.truncate(conflict_count);
10355            self.rejections.truncate(rejection_count);
10356            self.overlay_dirty.set(was_dirty);
10357            report.applied.truncate(applied_count);
10358            report.rejected.truncate(rejected_count);
10359            report.retryable.truncate(retryable_count);
10360            return Err(error);
10361        }
10362        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
10363        Ok(())
10364    }
10365
10366    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
10367        status != PushStatus::Rejected
10368            || !results.iter().any(|result| {
10369                matches!(
10370                    result,
10371                    OpResult::Error {
10372                        code,
10373                        retryable: true,
10374                        ..
10375                    } if code == "sync.idempotency_cache_miss"
10376                )
10377            })
10378    }
10379
10380    // -- subscription sections ------------------------------------------------------
10381
10382    #[allow(clippy::too_many_arguments)]
10383    fn process_section(
10384        &mut self,
10385        transport: &mut dyn Transport,
10386        id: &str,
10387        status: SubStatus,
10388        reason_code: &str,
10389        effective_scopes: Vec<(String, Vec<String>)>,
10390        body: Vec<Frame>,
10391        sub_end: Option<(i64, Option<String>)>,
10392        meta: &RequestMeta,
10393        report: &mut SyncReport,
10394    ) -> Result<(), SectionError> {
10395        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
10396            return Ok(()); // unknown echo: ignore
10397        };
10398        if status != SubStatus::Active && sub_end.is_none() {
10399            return Err(SectionError::Abort(
10400                "sync.invalid_request".into(),
10401                "subscription section without SUB_END".into(),
10402            ));
10403        }
10404        match status {
10405            SubStatus::Revoked => {
10406                self.begin_observation("syncular_revocation")
10407                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
10408                let previous = self.subs[sub_index].clone();
10409                let was_dirty = self.overlay_dirty.get();
10410                let previous_outbox = self.outbox.clone();
10411                let previous_failed = self.failed_commits.clone();
10412                let rejection_count = self.rejections.len();
10413                let revoked_count = report.revoked.len();
10414                let failed_count = report.failed.len();
10415                let result =
10416                    (|| {
10417                        let mut batch = ChangeAccumulator::default();
10418                        let registered = self.window_unit_by_sub_id(id);
10419                        // §3.3: stop pulling, purge exactly the last effective grant.
10420                        let (table, effective) = {
10421                            let sub = &self.subs[sub_index];
10422                            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10423                        };
10424                        let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
10425                            SectionError::Abort(
10426                                "sync.unknown_table".to_owned(),
10427                                "revoked scope references an unknown table".to_owned(),
10428                            )
10429                        })?;
10430                        let dropped = self
10431                            .drop_failed_commits(|name, values| {
10432                                name == table
10433                                    && !effective.is_empty()
10434                                    && effective.iter().all(|(variable, allowed)| {
10435                                        scoped_table
10436                                            .scope_column(variable)
10437                                            .and_then(|column| values.get(column))
10438                                            .and_then(Value::as_str)
10439                                            .is_some_and(|value| {
10440                                                allowed.iter().any(|allowed| allowed == value)
10441                                            })
10442                                    })
10443                            })
10444                            .map_err(|message| {
10445                                SectionError::Abort("storage.failed".to_owned(), message)
10446                            })?;
10447                        for commit in dropped {
10448                            batch.outcomes = true;
10449                            for operation in commit.ops {
10450                                batch.table(&operation.table);
10451                            }
10452                        }
10453                        let purged = self.purge_scope_rows(&table, &effective);
10454                        match purged {
10455                            Ok(()) => {
10456                                self.record_scope_map(&mut batch, &table, &effective);
10457                                let sub = &mut self.subs[sub_index];
10458                                sub.state = SubState::Revoked;
10459                                sub.reason_code = Some(if reason_code.is_empty() {
10460                                    "sync.scope_revoked".to_owned()
10461                                } else {
10462                                    reason_code.to_owned()
10463                                });
10464                                report.revoked.push(id.to_owned());
10465                                let doomed_effective = effective;
10466                                let sub_table = table;
10467                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
10468                                    |message| SectionError::Abort("storage.failed".into(), message),
10469                                )?;
10470                                let dropped = self
10471                                    .drop_doomed_outbox(&sub_table, &doomed_effective)
10472                                    .map_err(|message| {
10473                                        SectionError::Abort("storage.failed".to_owned(), message)
10474                                    })?;
10475                                if dropped {
10476                                    batch.status = true;
10477                                    batch.rejections = true;
10478                                    batch.outcomes = true;
10479                                }
10480                                // §5.9.7 B2: revocation deletes now-unauthorized blob
10481                                // bodies (evicted ≠ revoked).
10482                                self.delete_unreferenced_cached_blobs();
10483                            }
10484                            Err(()) => {
10485                                // §3.3 fail closed: no local mapping — never clear by
10486                                // approximation; fatal configuration error.
10487                                let sub = &mut self.subs[sub_index];
10488                                sub.state = SubState::Failed;
10489                                sub.reason_code = Some("sync.scope_revoked".to_owned());
10490                                report.failed.push(id.to_owned());
10491                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
10492                                    |message| SectionError::Abort("storage.failed".into(), message),
10493                                )?;
10494                            }
10495                        }
10496                        if let Some((base_key, unit)) = registered {
10497                            batch.window(&base_key, &self.subs[sub_index].table, &unit);
10498                        }
10499                        self.rebuild_overlay_if_dirty();
10500                        self.finish_observation("syncular_revocation", batch)
10501                            .map_err(|message| {
10502                                SectionError::Abort("storage.failed".to_owned(), message)
10503                            })?;
10504                        Ok(())
10505                    })();
10506                if result.is_err() {
10507                    self.rollback_observation("syncular_revocation");
10508                    self.subs[sub_index] = previous;
10509                    self.overlay_dirty.set(was_dirty);
10510                    self.outbox = previous_outbox;
10511                    self.failed_commits = previous_failed;
10512                    self.rejections.truncate(rejection_count);
10513                    report.revoked.truncate(revoked_count);
10514                    report.failed.truncate(failed_count);
10515                }
10516                result
10517            }
10518            SubStatus::Reset => {
10519                self.begin_observation("syncular_reset")
10520                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
10521                let previous = self.subs[sub_index].clone();
10522                let was_dirty = self.overlay_dirty.get();
10523                let reset_count = report.resets.len();
10524                let result = (|| {
10525                    let mut batch = ChangeAccumulator::default();
10526                    let registered = self.window_unit_by_sub_id(id);
10527                    // §4.6: discard cursor + bootstrap state, keep local rows —
10528                    // reset is a staleness signal, not a purge signal.
10529                    let sub = &mut self.subs[sub_index];
10530                    sub.cursor = -1;
10531                    sub.bootstrap_state = None;
10532                    report.resets.push(id.to_owned());
10533                    self.persist_sub(&self.subs[sub_index].clone())
10534                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10535                    if let Some((base_key, unit)) = registered {
10536                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
10537                    }
10538                    self.finish_observation("syncular_reset", batch)
10539                        .map_err(|message| {
10540                            SectionError::Abort("storage.failed".to_owned(), message)
10541                        })?;
10542                    Ok(())
10543                })();
10544                if result.is_err() {
10545                    self.rollback_observation("syncular_reset");
10546                    self.subs[sub_index] = previous;
10547                    self.overlay_dirty.set(was_dirty);
10548                    report.resets.truncate(reset_count);
10549                }
10550                result
10551            }
10552            SubStatus::Active => {
10553                let fresh = meta
10554                    .fresh
10555                    .iter()
10556                    .find(|(fid, _)| fid == id)
10557                    .map(|(_, fresh)| *fresh)
10558                    .unwrap_or(false);
10559                let previous = self.subs[sub_index].clone();
10560                self.subs[sub_index].effective = Some(effective_scopes);
10561                let outcome = self
10562                    .apply_section_body(transport, sub_index, body, fresh, meta, report)
10563                    .and_then(|()| {
10564                        let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
10565                            SectionError::Abort(
10566                                "sync.invalid_request".into(),
10567                                "subscription section without SUB_END".into(),
10568                            )
10569                        })?;
10570                        self.apply_sub_end(sub_index, cursor, bootstrap_state)
10571                            .map_err(|message| {
10572                                SectionError::Abort("storage.failed".into(), message)
10573                            })
10574                    });
10575                match outcome {
10576                    Ok(()) => {
10577                        if self.subs[sub_index].bootstrap_state.is_some() {
10578                            report.bootstrapping.push(id.to_owned());
10579                        }
10580                        Ok(())
10581                    }
10582                    Err(SectionError::FailClosed) => {
10583                        self.subs[sub_index] = previous.clone();
10584                        self.begin_observation("syncular_section_failure")
10585                            .map_err(|message| {
10586                                SectionError::Abort("storage.failed".into(), message)
10587                            })?;
10588                        let persisted = (|| {
10589                            let mut batch = ChangeAccumulator::default();
10590                            if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
10591                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
10592                            }
10593                            self.subs[sub_index].state = SubState::Failed;
10594                            self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
10595                            self.persist_sub(&self.subs[sub_index])?;
10596                            self.finish_observation("syncular_section_failure", batch)
10597                        })();
10598                        if let Err(message) = persisted {
10599                            self.rollback_observation("syncular_section_failure");
10600                            self.subs[sub_index] = previous;
10601                            return Err(SectionError::Abort("storage.failed".into(), message));
10602                        }
10603                        report.failed.push(id.to_owned());
10604                        Ok(())
10605                    }
10606                    Err(error) => {
10607                        // Frame/block transactions already completed. Preserve their
10608                        // rows and revisions, but leave this subscription cursor unchanged.
10609                        self.subs[sub_index] = previous;
10610                        Err(error)
10611                    }
10612                }
10613            }
10614        }
10615    }
10616
10617    fn apply_sub_end(
10618        &mut self,
10619        sub_index: usize,
10620        cursor: i64,
10621        bootstrap_state: Option<String>,
10622    ) -> Result<(), String> {
10623        #[cfg(feature = "bench-internals")]
10624        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
10625        let previous = self.subs[sub_index].clone();
10626        self.begin_observation("syncular_sub_end")?;
10627        let persisted = (|| {
10628            let mut batch = ChangeAccumulator::default();
10629            let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
10630                && cursor >= 0
10631                && bootstrap_state.is_none();
10632            if completed {
10633                if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
10634                    batch.window(&base_key, &previous.table, &unit);
10635                }
10636            }
10637            let sub = &mut self.subs[sub_index];
10638            sub.cursor = cursor;
10639            sub.bootstrap_state = bootstrap_state;
10640            sub.synced_once = true;
10641            self.persist_sub(&self.subs[sub_index])?;
10642            self.finish_observation("syncular_sub_end", batch)
10643        })();
10644        if persisted.is_err() {
10645            self.rollback_observation("syncular_sub_end");
10646            self.subs[sub_index] = previous;
10647        }
10648        persisted
10649    }
10650
10651    fn apply_section_body(
10652        &mut self,
10653        transport: &mut dyn Transport,
10654        sub_index: usize,
10655        body: Vec<Frame>,
10656        fresh: bool,
10657        meta: &RequestMeta,
10658        report: &mut SyncReport,
10659    ) -> Result<(), SectionError> {
10660        let mut saw_segment = false;
10661        for frame in body {
10662            match frame {
10663                Frame::Commit {
10664                    tables, changes, ..
10665                } => {
10666                    self.apply_commit_frame(&tables, &changes)
10667                        .map_err(|(c, m)| SectionError::Abort(c, m))?;
10668                    report.commits_applied += 1;
10669                }
10670                Frame::SegmentInline { payload } => {
10671                    let segment = decode_rows_segment(&payload)
10672                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
10673                    self.progress.update(|p| {
10674                        p.phase = ProgressPhase::Import;
10675                        p.subscription_id = Some(self.subs[sub_index].id.clone());
10676                        p.table = Some(self.subs[sub_index].table.clone());
10677                        p.segment_id = None;
10678                        p.bytes_received = payload.len() as u64;
10679                        p.bytes_total = Some(payload.len() as u64);
10680                        p.rows_processed = 0;
10681                        p.rows_total =
10682                            Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
10683                    });
10684                    let first = !saw_segment;
10685                    saw_segment = true;
10686                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
10687                    report.segment_rows_applied += applied;
10688                }
10689                Frame::SegmentRef {
10690                    segment_id,
10691                    byte_length,
10692                    media_type,
10693                    table,
10694                    row_count,
10695                    as_of_commit_seq,
10696                    scope_digest,
10697                    row_cursor,
10698                    next_row_cursor,
10699                    url,
10700                    url_expires_at_ms,
10701                    ..
10702                } => {
10703                    // §4.2: reject a descriptor whose mediaType was not
10704                    // advertised — never skip or guess.
10705                    let advertised = match media_type {
10706                        MediaType::Rows => {
10707                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
10708                                || meta.accept & ACCEPT_INLINE_ROWS != 0
10709                        }
10710                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
10711                    };
10712                    if !advertised {
10713                        return Err(SectionError::Abort(
10714                            "sync.invalid_request".to_owned(),
10715                            format!(
10716                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
10717                                media_type.name()
10718                            ),
10719                        ));
10720                    }
10721                    self.progress.update(|p| {
10722                        p.phase = ProgressPhase::Download;
10723                        p.subscription_id = Some(self.subs[sub_index].id.clone());
10724                        p.table = Some(table.clone());
10725                        p.segment_id = Some(segment_id.clone());
10726                        p.bytes_received = 0;
10727                        p.bytes_total = u64::try_from(byte_length).ok();
10728                        p.rows_processed = 0;
10729                        p.rows_total = u64::try_from(row_count).ok();
10730                    });
10731                    let observer = self.progress.clone();
10732                    let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
10733                    let bytes = if let Some(url) = url {
10734                        // §5.4: a url-carrying descriptor MUST be fetched
10735                        // from that URL; failure invalidates the whole
10736                        // descriptor (no fall-through to §5.5 — re-pull
10737                        // recovers, §1.4 rule 5).
10738                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
10739                            return Err(SectionError::Abort(
10740                                "sync.invalid_request".to_owned(),
10741                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
10742                                    .to_owned(),
10743                            ));
10744                        }
10745                        // §5.4: MUST NOT start a fetch at/past expiry.
10746                        if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
10747                            return Err(SectionError::Abort(
10748                                "sync.segment_expired".to_owned(),
10749                                format!(
10750                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
10751                                ),
10752                            ));
10753                        }
10754                        transport
10755                            .fetch_url(&url, &mut on_progress)
10756                            .map_err(|e| SectionError::Abort(e.code, e.message))?
10757                    } else {
10758                        let requested_scopes_json =
10759                            canonical_scope_json(&self.subs[sub_index].requested);
10760                        transport
10761                            .download_segment(
10762                                &SegmentRequest {
10763                                    segment_id: segment_id.clone(),
10764                                    table,
10765                                    requested_scopes_json,
10766                                },
10767                                &mut on_progress,
10768                            )
10769                            .map_err(|e| SectionError::Abort(e.code, e.message))?
10770                    };
10771                    // §5.1: verify the content address before applying.
10772                    on_progress(bytes.len() as u64);
10773                    let digest = Sha256::digest(&bytes);
10774                    let expected = segment_id
10775                        .strip_prefix("sha256:")
10776                        .unwrap_or(segment_id.as_str());
10777                    if bytes_to_hex(&digest) != expected {
10778                        // §5.1: the client discards the segment and re-pulls.
10779                        self.schedule_background_retry();
10780                        return Err(SectionError::Abort(
10781                            "sync.invalid_request".to_owned(),
10782                            "segment bytes do not match the content address (§5.1)".to_owned(),
10783                        ));
10784                    }
10785                    self.progress.update(|p| p.phase = ProgressPhase::Import);
10786                    if media_type == MediaType::Sqlite {
10787                        // §5.3: images are whole-table — a paged sqlite
10788                        // descriptor is invalid.
10789                        if row_cursor.is_some() || next_row_cursor.is_some() {
10790                            return Err(SectionError::Abort(
10791                                "sync.invalid_request".to_owned(),
10792                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
10793                                    .to_owned(),
10794                            ));
10795                        }
10796                        let first = !saw_segment;
10797                        saw_segment = true;
10798                        let applied = self.apply_sqlite_segment(
10799                            sub_index,
10800                            &bytes,
10801                            fresh && first,
10802                            row_count,
10803                            as_of_commit_seq,
10804                            &scope_digest,
10805                        )?;
10806                        report.segment_rows_applied += applied;
10807                    } else {
10808                        let segment = decode_rows_segment(&bytes).map_err(|e| {
10809                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
10810                        })?;
10811                        let first = row_cursor.is_none();
10812                        saw_segment = true;
10813                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
10814                        report.segment_rows_applied += applied;
10815                    }
10816                }
10817                Frame::Unknown { .. } => {}
10818                Frame::Error { code, message, .. } => {
10819                    return Err(SectionError::Abort(code, message))
10820                }
10821                _ => {
10822                    return Err(SectionError::Abort(
10823                        "sync.invalid_request".to_owned(),
10824                        "unexpected frame inside a subscription section".to_owned(),
10825                    ));
10826                }
10827            }
10828        }
10829        Ok(())
10830    }
10831
10832    // Both pull and realtime frames use the same durable observation boundary.
10833    fn apply_commit_frame(
10834        &mut self,
10835        tables: &[String],
10836        changes: &[ssp2::model::Change],
10837    ) -> Result<(), (String, String)> {
10838        #[cfg(feature = "bench-internals")]
10839        let _phase = self.benchmark_phases.start(Phase::CommitApply);
10840        let was_dirty = self.overlay_dirty.get();
10841        self.begin_observation("syncular_commit")
10842            .map_err(|message| ("storage.failed".into(), message))?;
10843        let applied = (|| {
10844            let mut batch = ChangeAccumulator::default();
10845            self.record_commit_changes(&mut batch, tables, changes);
10846            self.apply_commit_changes(tables, changes)?;
10847            self.rebuild_overlay_if_dirty();
10848            self.finish_observation("syncular_commit", batch)
10849                .map_err(|message| ("storage.failed".into(), message))
10850        })();
10851        if applied.is_err() {
10852            self.rollback_observation("syncular_commit");
10853            self.overlay_dirty.set(was_dirty);
10854        }
10855        applied
10856    }
10857
10858    fn apply_commit_changes(
10859        &mut self,
10860        tables: &[String],
10861        changes: &[ssp2::model::Change],
10862    ) -> Result<(), (String, String)> {
10863        // A clean overlay can reconcile changed primary keys independently
10864        // when their tables have no secondary unique constraints. Such a
10865        // constraint can change whether another row's pending write succeeds,
10866        // so those frames still require the complete FIFO replay below.
10867        let mirror_visible = !self.overlay_dirty.get()
10868            && self.failed_commits.is_empty()
10869            && (self.outbox.is_empty()
10870                || changes.iter().all(|change| {
10871                    tables
10872                        .get(change.table_index as usize)
10873                        .and_then(|name| self.schema.table(name))
10874                        .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
10875                }));
10876        for change in changes {
10877            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
10878                (
10879                    "sync.invalid_request".to_owned(),
10880                    "change tableIndex out of range".to_owned(),
10881                )
10882            })?;
10883            let table = self.schema.table(table_name).ok_or_else(|| {
10884                (
10885                    "sync.schema_mismatch".to_owned(),
10886                    format!("change targets unknown table {table_name:?}"),
10887                )
10888            })?;
10889            match change.op {
10890                Op::Upsert => {
10891                    let payload = change.row.as_ref().ok_or_else(|| {
10892                        (
10893                            "sync.invalid_request".to_owned(),
10894                            "upsert change without row payload".to_owned(),
10895                        )
10896                    })?;
10897                    // §5.11: decrypt encrypted columns on apply.
10898                    let row = {
10899                        #[cfg(feature = "bench-internals")]
10900                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
10901                        decode_row_bytes(table, payload, &self.encryption)
10902                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?
10903                    };
10904                    let version = change.row_version.unwrap_or(0);
10905                    let table_name = table.name.clone();
10906                    self.write_base_row(&table_name, &row, version)
10907                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10908                    if mirror_visible {
10909                        self.write_row(&visible_table(&table_name), &table_name, &row, version)
10910                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10911                    }
10912                }
10913                Op::Delete => {
10914                    self.delete_base_row(table_name, &change.row_id)
10915                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
10916                    if mirror_visible {
10917                        let sql = format!(
10918                            "DELETE FROM {} WHERE {}",
10919                            visible_table(table_name),
10920                            row_id_predicate(table)
10921                        );
10922                        self.conn
10923                            .prepare_cached(&sql)
10924                            .and_then(|mut statement| {
10925                                statement.execute(rusqlite::params![change.row_id])
10926                            })
10927                            .map_err(|error| {
10928                                ("sync.invalid_request".to_owned(), error.to_string())
10929                            })?;
10930                    }
10931                }
10932            }
10933        }
10934        if mirror_visible {
10935            if !self.outbox.is_empty() {
10936                let changed_rows: HashSet<(&str, &str)> = changes
10937                    .iter()
10938                    .map(|change| {
10939                        (
10940                            tables[change.table_index as usize].as_str(),
10941                            change.row_id.as_str(),
10942                        )
10943                    })
10944                    .collect();
10945                self.apply_outbox_ops(
10946                    self.outbox
10947                        .iter()
10948                        .flat_map(|commit| &commit.ops)
10949                        .filter(|op| {
10950                            changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
10951                        }),
10952                );
10953            }
10954            self.overlay_dirty.set(false);
10955        }
10956        Ok(())
10957    }
10958
10959    /// §5.6 segment application: validate against the generated schema,
10960    /// clear the grant on a fresh bootstrap's first page (fail closed
10961    /// without a mapping), then replace-or-upsert each row with its
10962    /// segment-carried server version (§5.2).
10963    fn apply_segment(
10964        &mut self,
10965        sub_index: usize,
10966        segment: &RowsSegment,
10967        first_fresh_page: bool,
10968    ) -> Result<u32, SectionError> {
10969        let (sub_table, effective) = {
10970            let sub = &self.subs[sub_index];
10971            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10972        };
10973        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
10974            SectionError::Abort(
10975                "sync.schema_mismatch".to_owned(),
10976                format!("subscription table {sub_table:?} is not in the client schema"),
10977            )
10978        })?;
10979        // §5.2: the column table validates against the generated schema —
10980        // order, names, types, nullability; mismatch is fatal. §5.11: the
10981        // server sends the WIRE types (bytes for an encrypted column), so
10982        // validate against wire_columns.
10983        let matches = segment.table == table.name
10984            && segment.schema_version == self.schema.version
10985            && segment.columns.len() == table.wire_columns.len()
10986            && segment
10987                .columns
10988                .iter()
10989                .zip(table.wire_columns.iter())
10990                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
10991        if !matches {
10992            return Err(SectionError::Abort(
10993                "sync.schema_mismatch".to_owned(),
10994                "segment column table does not match the generated schema (§5.2)".to_owned(),
10995            ));
10996        }
10997        let mut applied = 0u32;
10998        let empty = Vec::new();
10999        let blocks = if segment.blocks.is_empty() {
11000            std::slice::from_ref(&empty)
11001        } else {
11002            &segment.blocks
11003        };
11004        for (index, block) in blocks.iter().enumerate() {
11005            let clear = first_fresh_page && index == 0;
11006            if !clear && block.is_empty() {
11007                continue;
11008            }
11009            let was_dirty = self.overlay_dirty.get();
11010            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
11011                && self
11012                    .outbox
11013                    .iter()
11014                    .flat_map(|commit| &commit.ops)
11015                    .any(|op| op.table == table.name);
11016            let mirror_visible = !reconcile_pending && self.failed_commits.is_empty();
11017            self.begin_observation("syncular_segment_block")
11018                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11019            let outcome = (|| {
11020                self.rebuild_overlay_if_dirty();
11021                let mut changed_keys = Vec::new();
11022                let mut batch = ChangeAccumulator::default();
11023                if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
11024                    batch.table(&table.name);
11025                }
11026                if clear {
11027                    self.purge_scope_rows(&table.name, &effective)
11028                        .map_err(|()| SectionError::FailClosed)?;
11029                }
11030                for (row_index, row) in block.iter().enumerate() {
11031                    let decrypted;
11032                    let values = if table.has_encrypted_columns() {
11033                        let mut values = row.values.clone();
11034                        crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
11035                            .map_err(|message| {
11036                            SectionError::Abort("client.decrypt_failed".into(), message)
11037                        })?;
11038                        decrypted = values;
11039                        &decrypted
11040                    } else {
11041                        &row.values
11042                    };
11043                    self.write_base_row(&table.name, values, row.server_version)
11044                        .map_err(|message| {
11045                            SectionError::Abort("sync.invalid_request".into(), message)
11046                        })?;
11047                    if reconcile_pending {
11048                        changed_keys.push(
11049                            owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
11050                                |message| SectionError::Abort("storage.failed".into(), message),
11051                            )?,
11052                        );
11053                    }
11054                    if mirror_visible {
11055                        self.write_row(
11056                            &visible_table(&table.name),
11057                            &table.name,
11058                            values,
11059                            row.server_version,
11060                        )
11061                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11062                    }
11063                    let processed = u64::from(applied) + row_index as u64 + 1;
11064                    if processed.is_multiple_of(1024) {
11065                        self.progress.update(|p| p.rows_processed = processed);
11066                    }
11067                }
11068                if reconcile_pending {
11069                    self.reconcile_imported_rows(&table, &mut changed_keys)
11070                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11071                }
11072                if self.failed_commits.is_empty() {
11073                    self.apply_outbox_ops(
11074                        self.outbox
11075                            .iter()
11076                            .flat_map(|commit| &commit.ops)
11077                            .filter(|op| op.table == table.name),
11078                    );
11079                    self.overlay_dirty.set(false);
11080                } else {
11081                    self.rebuild_overlay();
11082                }
11083                self.finish_observation("syncular_segment_block", batch)
11084                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
11085            })();
11086            if let Err(error) = outcome {
11087                self.rollback_observation("syncular_segment_block");
11088                self.overlay_dirty.set(was_dirty);
11089                return Err(error);
11090            }
11091            applied += block.len() as u32;
11092            self.progress
11093                .update(|p| p.rows_processed = u64::from(applied));
11094            std::thread::yield_now();
11095        }
11096        self.progress
11097            .update(|p| p.rows_processed = u64::from(applied));
11098        Ok(applied)
11099    }
11100
11101    /// §5.3 sqlite-image application: validate the in-file metadata
11102    /// against the descriptor, validate column names/order against the
11103    /// generated schema, run the §5.6 first-page clear when fresh, then
11104    /// replace-or-upsert every image row with its `_syncular_version`.
11105    /// Mechanics: the image lands in a temp file read through a second
11106    /// rusqlite connection, equivalent to ATTACH + INSERT…SELECT.
11107    /// Each image chunk commits its writes and revision before readers continue.
11108    fn apply_sqlite_segment(
11109        &mut self,
11110        sub_index: usize,
11111        bytes: &[u8],
11112        first_fresh_page: bool,
11113        row_count: i64,
11114        as_of_commit_seq: i64,
11115        scope_digest: &str,
11116    ) -> Result<u32, SectionError> {
11117        let invalid = |detail: &str| {
11118            SectionError::Abort(
11119                "sync.invalid_request".to_owned(),
11120                format!("sqlite segment rejected: {detail} (§5.3)"),
11121            )
11122        };
11123        let (sub_table, effective) = {
11124            let sub = &self.subs[sub_index];
11125            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
11126        };
11127        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
11128            SectionError::Abort(
11129                "sync.schema_mismatch".to_owned(),
11130                format!("subscription table {sub_table:?} is not in the client schema"),
11131            )
11132        })?;
11133
11134        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
11135        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
11136        let img = match rusqlite::Connection::open_with_flags(
11137            &path,
11138            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
11139        ) {
11140            Ok(conn) => conn,
11141            Err(_) => {
11142                let _ = std::fs::remove_file(&path);
11143                return Err(invalid("bytes do not open as a SQLite database"));
11144            }
11145        };
11146        let outcome = self.apply_sqlite_image(
11147            &img,
11148            &table,
11149            first_fresh_page,
11150            &effective,
11151            row_count,
11152            as_of_commit_seq,
11153            scope_digest,
11154        );
11155        drop(img);
11156        let _ = std::fs::remove_file(&path);
11157        outcome
11158    }
11159
11160    #[allow(clippy::too_many_arguments)]
11161    fn apply_sqlite_image(
11162        &mut self,
11163        img: &rusqlite::Connection,
11164        table: &crate::schema::TableSchema,
11165        first_fresh_page: bool,
11166        effective: &[(String, Vec<String>)],
11167        row_count: i64,
11168        as_of_commit_seq: i64,
11169        scope_digest: &str,
11170    ) -> Result<u32, SectionError> {
11171        let invalid = |detail: String| {
11172            SectionError::Abort(
11173                "sync.invalid_request".to_owned(),
11174                format!("sqlite segment rejected: {detail} (§5.3)"),
11175            )
11176        };
11177
11178        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
11179        //    one row).
11180        type MetaRow = (i64, String, i64, i64, String, i64, i64);
11181        let meta: MetaRow = img
11182            .query_row(
11183                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
11184                        \"scopeDigest\", \"rowCount\",
11185                        (SELECT count(*) FROM _syncular_segment)
11186                 FROM _syncular_segment",
11187                [],
11188                |row| {
11189                    Ok((
11190                        row.get(0)?,
11191                        row.get(1)?,
11192                        row.get(2)?,
11193                        row.get(3)?,
11194                        row.get(4)?,
11195                        row.get(5)?,
11196                        row.get(6)?,
11197                    ))
11198                },
11199            )
11200            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
11201        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
11202        if meta_count != 1 {
11203            return Err(invalid(format!(
11204                "_syncular_segment must contain exactly one row, found {meta_count}"
11205            )));
11206        }
11207        if format != 1 {
11208            return Err(invalid(format!("format {format}")));
11209        }
11210        if meta_table != table.name {
11211            return Err(invalid(format!("image table {meta_table:?}")));
11212        }
11213        if schema_version != i64::from(self.schema.version) {
11214            return Err(invalid(format!("schemaVersion {schema_version}")));
11215        }
11216        if pin != as_of_commit_seq {
11217            return Err(invalid(format!("asOfCommitSeq {pin}")));
11218        }
11219        if digest != scope_digest {
11220            return Err(invalid("scopeDigest mismatch".to_owned()));
11221        }
11222        if meta_rows != row_count {
11223            return Err(invalid(format!("rowCount {meta_rows}")));
11224        }
11225
11226        // 2. Column names and order vs the generated schema (§5.3 rule 3).
11227        let mut names: Vec<String> = Vec::new();
11228        let mut primary_keys: Vec<String> = Vec::new();
11229        {
11230            let mut stmt = img
11231                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
11232                .map_err(|_| invalid("image data table missing".to_owned()))?;
11233            let mut rows = stmt
11234                .query([])
11235                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
11236            while let Some(row) = rows
11237                .next()
11238                .map_err(|_| invalid("image data table unreadable".to_owned()))?
11239            {
11240                if row
11241                    .get::<_, i64>(5)
11242                    .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
11243                    > 0
11244                {
11245                    primary_keys.push(row.get(1).map_err(|error| {
11246                        invalid(Self::sqlite_failure(&self.storage_failure, error))
11247                    })?);
11248                }
11249                names.push(
11250                    row.get::<_, String>(1)
11251                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
11252                );
11253            }
11254        }
11255        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
11256        expected.push("_syncular_version");
11257        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
11258            return Err(SectionError::Abort(
11259                "sync.schema_mismatch".to_owned(),
11260                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
11261            ));
11262        }
11263
11264        if primary_keys != vec![table.primary_key.clone()] {
11265            return Err(invalid(
11266                "image primary key does not match generated schema".into(),
11267            ));
11268        }
11269        let actual_count: i64 = img
11270            .query_row(
11271                &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
11272                [],
11273                |row| row.get(0),
11274            )
11275            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
11276        if actual_count != row_count {
11277            return Err(invalid("image row count does not match descriptor".into()));
11278        }
11279        let insert = self.insert_row_sql(&base_table(&table.name), table);
11280        let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
11281        let column_list = names
11282            .iter()
11283            .map(|n| quote_ident(n))
11284            .collect::<Vec<_>>()
11285            .join(", ");
11286        let mut stmt = img
11287            .prepare(&format!(
11288                "SELECT {column_list} FROM {} ORDER BY {}",
11289                quote_ident(&table.name),
11290                quote_ident(&table.primary_key)
11291            ))
11292            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
11293        let mut rows = stmt
11294            .query([])
11295            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
11296        let mut applied = 0u32;
11297        loop {
11298            let clear = applied == 0 && first_fresh_page;
11299            let was_dirty = self.overlay_dirty.get();
11300            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
11301                && self
11302                    .outbox
11303                    .iter()
11304                    .flat_map(|commit| &commit.ops)
11305                    .any(|op| op.table == table.name);
11306            let mirror_visible = !reconcile_pending && self.failed_commits.is_empty();
11307            self.begin_observation("syncular_image_chunk")
11308                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11309            let result = (|| {
11310                self.rebuild_overlay_if_dirty();
11311                let mut changed_keys = Vec::new();
11312                let mut batch = ChangeAccumulator::default();
11313                if clear {
11314                    if self.scoped_rows_exist(&table.name, effective) {
11315                        batch.table(&table.name);
11316                    }
11317                    self.purge_scope_rows(&table.name, effective)
11318                        .map_err(|()| SectionError::FailClosed)?;
11319                }
11320                let mut processed = 0u32;
11321                {
11322                    let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
11323                        invalid(Self::sqlite_failure(&self.storage_failure, error))
11324                    })?;
11325                    let mut mirror = if mirror_visible {
11326                        Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
11327                            invalid(Self::sqlite_failure(&self.storage_failure, error))
11328                        })?)
11329                    } else {
11330                        None
11331                    };
11332                    for _ in 0..1024 {
11333                        let Some(row) = rows.next().map_err(|error| {
11334                            invalid(Self::sqlite_failure(&self.storage_failure, error))
11335                        })?
11336                        else {
11337                            break;
11338                        };
11339                        for (i, column) in table.columns.iter().enumerate() {
11340                            let cell = row.get_ref(i).map_err(|error| {
11341                                invalid(Self::sqlite_failure(&self.storage_failure, error))
11342                            })?;
11343                            let param = image_cell_param(column, cell).map_err(invalid)?;
11344                            ins.raw_bind_parameter(i + 1, &param).map_err(|error| {
11345                                invalid(Self::sqlite_failure(&self.storage_failure, error))
11346                            })?;
11347                            if let Some(mirror) = &mut mirror {
11348                                mirror.raw_bind_parameter(i + 1, &param).map_err(|error| {
11349                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
11350                                })?;
11351                            }
11352                        }
11353                        let version_index = table.columns.len();
11354                        let version: i64 = row.get(version_index).map_err(|error| {
11355                            invalid(Self::sqlite_failure(&self.storage_failure, error))
11356                        })?;
11357                        if version < 1 {
11358                            return Err(invalid("image row version must be positive".into()));
11359                        }
11360                        ins.raw_bind_parameter(version_index + 1, version)
11361                            .map_err(|error| {
11362                                invalid(Self::sqlite_failure(&self.storage_failure, error))
11363                            })?;
11364                        ins.raw_execute().map_err(|error| {
11365                            invalid(Self::sqlite_failure(&self.storage_failure, error))
11366                        })?;
11367                        if let Some(mirror) = &mut mirror {
11368                            mirror
11369                                .raw_bind_parameter(version_index + 1, version)
11370                                .map_err(|error| {
11371                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
11372                                })?;
11373                            mirror.raw_execute().map_err(|error| {
11374                                invalid(Self::sqlite_failure(&self.storage_failure, error))
11375                            })?;
11376                        }
11377                        if reconcile_pending {
11378                            changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
11379                                |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
11380                            )?);
11381                        }
11382                        processed += 1;
11383                    }
11384                }
11385                if processed > 0 {
11386                    batch.table(&table.name);
11387                    self.overlay_dirty.set(true);
11388                }
11389                if reconcile_pending {
11390                    self.reconcile_imported_rows(table, &mut changed_keys)
11391                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11392                }
11393                if self.failed_commits.is_empty() {
11394                    self.apply_outbox_ops(
11395                        self.outbox
11396                            .iter()
11397                            .flat_map(|commit| &commit.ops)
11398                            .filter(|op| op.table == table.name),
11399                    );
11400                    self.overlay_dirty.set(false);
11401                } else {
11402                    self.rebuild_overlay();
11403                }
11404                self.finish_observation("syncular_image_chunk", batch)
11405                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11406                Ok(processed)
11407            })();
11408            let processed = match result {
11409                Ok(processed) => processed,
11410                Err(error) => {
11411                    self.rollback_observation("syncular_image_chunk");
11412                    self.overlay_dirty.set(was_dirty);
11413                    return Err(error);
11414                }
11415            };
11416            applied += processed;
11417            self.progress
11418                .update(|p| p.rows_processed = u64::from(applied));
11419            std::thread::yield_now();
11420            if i64::from(applied) == actual_count {
11421                break;
11422            }
11423        }
11424        Ok(applied)
11425    }
11426
11427    /// Restore only imported and pending row identities before FIFO replay. All pending
11428    /// identities participate: a server row can free a unique value for a different edit.
11429    /// The caller owns the chunk transaction and publishes its revision after replay.
11430    fn reconcile_imported_rows(
11431        &self,
11432        table: &TableSchema,
11433        keys: &mut Vec<SqlValue>,
11434    ) -> Result<(), String> {
11435        let visible = visible_table(&table.name);
11436        let base = base_table(&table.name);
11437        let pk = quote_ident(&table.primary_key);
11438        let predicate = row_id_predicate(table);
11439        let mut lookup = self.conn.prepare_cached(&format!(
11440            "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
11441        )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11442        let mut deleted = HashSet::new();
11443        for op in self
11444            .outbox
11445            .iter()
11446            .flat_map(|commit| &commit.ops)
11447            .filter(|op| op.table == table.name)
11448        {
11449            if op.upsert {
11450                if let Some(values) = &op.values {
11451                    let key = json_to_column_value(
11452                        &table.columns[table.pk_index],
11453                        values.get(&table.primary_key),
11454                    )?;
11455                    keys.push(owned_sql_value(RowParam::Cell(&key))?);
11456                }
11457            } else if deleted.insert(&op.row_id) {
11458                keys.extend(
11459                    lookup
11460                        .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
11461                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
11462                        .collect::<Result<Vec<_>, _>>()
11463                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
11464                );
11465            }
11466        }
11467        // Delete the complete affected set before restoring any base value, so
11468        // old optimistic unique values cannot collide with the new server rows.
11469        for chunk in keys.chunks(400) {
11470            let holes = vec!["?"; chunk.len()].join(",");
11471            self.conn
11472                .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
11473                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
11474                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11475        }
11476        let updates = table
11477            .columns
11478            .iter()
11479            .map(|column| column.name.as_str())
11480            .chain(std::iter::once("_syncular_version"))
11481            .filter(|name| *name != table.primary_key)
11482            .map(|name| {
11483                let name = quote_ident(name);
11484                format!("{name}=excluded.{name}")
11485            })
11486            .collect::<Vec<_>>()
11487            .join(",");
11488        for chunk in keys.chunks(400) {
11489            let holes = vec!["?"; chunk.len()].join(",");
11490            self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
11491                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
11492                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11493        }
11494        Ok(())
11495    }
11496
11497    // -- application-authorized local purge ---------------------------------------
11498
11499    fn compile_local_data_purge(
11500        &self,
11501        input: &LocalDataPurgeInput,
11502    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
11503        let invalid = |message: String| format!("sync.invalid_request: {message}");
11504        if input.purge_id.is_empty()
11505            || input.purge_id.len() > 128
11506            || !is_local_operation_code_like(&input.purge_id)
11507        {
11508            return Err(invalid(
11509                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
11510            ));
11511        }
11512        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
11513            return Err(invalid(format!(
11514                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
11515            )));
11516        }
11517
11518        let mut deduplicated: BTreeMap<
11519            String,
11520            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
11521        > = BTreeMap::new();
11522        for target in &input.targets {
11523            let table = self.schema.table(&target.table).ok_or_else(|| {
11524                invalid(format!(
11525                    "local purge names unknown table {:?}",
11526                    target.table
11527                ))
11528            })?;
11529            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
11530                return Err(invalid(format!(
11531                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
11532                    target.table
11533                )));
11534            }
11535            let mut selectors = Vec::with_capacity(target.selectors.len());
11536            let mut canonical_selectors = BTreeMap::new();
11537            for (column_name, raw_values) in &target.selectors {
11538                let Some((column_index, column)) = table
11539                    .columns
11540                    .iter()
11541                    .enumerate()
11542                    .find(|(_, column)| column.name == *column_name)
11543                else {
11544                    return Err(invalid(format!(
11545                        "local purge target {:?} names unknown column {:?}",
11546                        target.table, column_name
11547                    )));
11548                };
11549                let encrypted = table
11550                    .encrypted_columns
11551                    .iter()
11552                    .any(|candidate| candidate.index == column_index);
11553                if column.ty != ColumnType::String || encrypted {
11554                    return Err(invalid(format!(
11555                        "local purge selector {:?}.{:?} must be a plaintext string column",
11556                        target.table, column_name
11557                    )));
11558                }
11559                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
11560                    return Err(invalid(format!(
11561                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
11562                        target.table, column_name
11563                    )));
11564                }
11565                let mut values = raw_values.clone();
11566                values.sort();
11567                values.dedup();
11568                if values.iter().any(|value| {
11569                    value.is_empty()
11570                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
11571                        || !is_local_operation_code_like(value)
11572                }) {
11573                    return Err(invalid(format!(
11574                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
11575                    )));
11576                }
11577                selectors.push((column_name.clone(), values.clone()));
11578                canonical_selectors.insert(column_name.clone(), values);
11579            }
11580            let canonical = LocalDataPurgeTarget {
11581                table: target.table.clone(),
11582                selectors: canonical_selectors,
11583            };
11584            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
11585            deduplicated.insert(
11586                key,
11587                (
11588                    CompiledLocalDataPurgeTarget {
11589                        table: target.table.clone(),
11590                        selectors,
11591                    },
11592                    canonical,
11593                ),
11594            );
11595        }
11596        let targets = deduplicated
11597            .values()
11598            .map(|(compiled, _)| compiled.clone())
11599            .collect::<Vec<_>>();
11600        let canonical = deduplicated
11601            .values()
11602            .map(|(_, target)| target.clone())
11603            .collect::<Vec<_>>();
11604        let canonical_plan =
11605            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
11606        Ok((targets, canonical_plan))
11607    }
11608
11609    fn local_purge_base_row_ids(
11610        &self,
11611        targets: &[CompiledLocalDataPurgeTarget],
11612    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
11613        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11614        for target in targets {
11615            let table = self
11616                .schema
11617                .table(&target.table)
11618                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
11619            let mut clauses = Vec::with_capacity(target.selectors.len());
11620            let mut params = Vec::new();
11621            for (column, values) in &target.selectors {
11622                clauses.push(format!(
11623                    "{} IN ({})",
11624                    quote_ident(column),
11625                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
11626                ));
11627                params.extend(values.iter().cloned().map(SqlValue::Text));
11628            }
11629            let sql = format!(
11630                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
11631                quote_ident(&table.primary_key),
11632                base_table(&target.table),
11633                clauses.join(" AND ")
11634            );
11635            let mut statement = self
11636                .conn
11637                .prepare(&sql)
11638                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11639            let rows = statement
11640                .query_map(rusqlite::params_from_iter(params), |row| {
11641                    row.get::<_, String>(0)
11642                })
11643                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11644            let ids = by_table.entry(target.table.clone()).or_default();
11645            for row in rows {
11646                ids.insert(
11647                    row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
11648                );
11649            }
11650        }
11651        Ok(by_table)
11652    }
11653
11654    fn local_purge_values_match(
11655        target: &CompiledLocalDataPurgeTarget,
11656        values: &Map<String, Value>,
11657    ) -> bool {
11658        target.selectors.iter().all(|(column, allowed)| {
11659            values
11660                .get(column)
11661                .and_then(Value::as_str)
11662                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
11663        })
11664    }
11665
11666    /// Apply one host-authorized local security purge. The host MUST gate the
11667    /// corresponding subscriptions first; this primitive owns local SQLite
11668    /// cleanup and never grants/revokes server authority by itself.
11669    pub fn purge_local_data(
11670        &mut self,
11671        input: &LocalDataPurgeInput,
11672    ) -> Result<LocalDataPurgeResult, String> {
11673        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
11674        let meta_key = format!("localPurge:{}", input.purge_id);
11675        let applied_plan = self
11676            .conn
11677            .query_row(
11678                "SELECT value FROM _syncular_meta WHERE key = ?1",
11679                rusqlite::params![meta_key],
11680                |row| row.get::<_, String>(0),
11681            )
11682            .optional()
11683            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11684        if let Some(applied_plan) = applied_plan {
11685            if applied_plan != canonical_plan {
11686                return Err(format!(
11687                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
11688                    input.purge_id
11689                ));
11690            }
11691            return Ok(LocalDataPurgeResult {
11692                already_applied: true,
11693                purged_rows: 0,
11694                dropped_commits: 0,
11695            });
11696        }
11697
11698        // RFC 0005 D7: the container file cannot be a purge target (its name is
11699        // not in the running schema), so purge drops it as a fixed step, before
11700        // any mirror row is touched and unconditionally.
11701        let _ = self.drop_previous_version();
11702
11703        let prior_outbox = self.outbox.clone();
11704        let prior_failed = self.failed_commits.clone();
11705        let prior_rejection_count = self.rejections.len();
11706        let prior_overlay_dirty = self.overlay_dirty.get();
11707        self.begin_observation("syncular_local_purge")?;
11708        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
11709            let row_ids = self.local_purge_base_row_ids(&targets)?;
11710            let retained_dropped = self.drop_failed_commits(|table, values| {
11711                targets.iter().any(|target| {
11712                    target.table == table && Self::local_purge_values_match(target, values)
11713                })
11714            })?;
11715            let doomed = self
11716                .outbox
11717                .iter()
11718                .filter(|commit| {
11719                    commit.ops.iter().any(|operation| {
11720                        let matching_targets = targets
11721                            .iter()
11722                            .filter(|target| target.table == operation.table)
11723                            .collect::<Vec<_>>();
11724                        if matching_targets.is_empty() {
11725                            return false;
11726                        }
11727                        if row_ids
11728                            .get(&operation.table)
11729                            .is_some_and(|ids| ids.contains(&operation.row_id))
11730                        {
11731                            return true;
11732                        }
11733                        operation.values.as_ref().is_some_and(|values| {
11734                            matching_targets
11735                                .iter()
11736                                .any(|target| Self::local_purge_values_match(target, values))
11737                        })
11738                    })
11739                })
11740                .cloned()
11741                .collect::<Vec<_>>();
11742            let doomed_ids = doomed
11743                .iter()
11744                .map(|commit| commit.client_commit_id.clone())
11745                .collect::<BTreeSet<_>>();
11746            let mut batch = ChangeAccumulator::default();
11747            for commit in &retained_dropped {
11748                batch.outcomes = true;
11749                for operation in &commit.ops {
11750                    batch.table(&operation.table);
11751                }
11752            }
11753            let mut rejections = Vec::new();
11754            for commit in &doomed {
11755                for operation in &commit.ops {
11756                    batch.table(&operation.table);
11757                }
11758                let results = commit
11759                    .ops
11760                    .iter()
11761                    .enumerate()
11762                    .map(|(op_index, operation)| {
11763                        let rejection = RejectionRecord {
11764                            client_commit_id: commit.client_commit_id.clone(),
11765                            op_index: op_index as i32,
11766                            code: "client.local_data_purged".to_owned(),
11767                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
11768                            retryable: false,
11769                            details: None,
11770                            operation: Some(CommitOperation::from(operation)),
11771                        };
11772                        rejections.push(rejection.clone());
11773                        CommitOperationOutcome::Error { rejection }
11774                    })
11775                    .collect::<Vec<_>>();
11776                self.persist_commit_outcome(
11777                    &commit.client_commit_id,
11778                    CommitOutcomeStatus::Rejected,
11779                    &results,
11780                    Some(&commit.ops),
11781                )?;
11782                self.delete_outbox_persisted(&commit.client_commit_id)?;
11783            }
11784            if !doomed.is_empty() {
11785                self.outbox
11786                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
11787                self.rejections.extend(rejections);
11788                self.prune_commit_outcomes()?;
11789                self.overlay_dirty.set(true);
11790                batch.status = true;
11791                batch.rejections = true;
11792                batch.outcomes = true;
11793            }
11794
11795            let mut purged_rows = 0usize;
11796            for (table, ids) in &row_ids {
11797                if ids.is_empty() {
11798                    continue;
11799                }
11800                batch.table(table);
11801                for row_id in ids {
11802                    self.delete_base_row(table, row_id)?;
11803                    purged_rows += 1;
11804                }
11805            }
11806            self.rebuild_overlay_if_dirty();
11807            self.delete_unreferenced_cached_blobs();
11808            self.conn
11809                .execute(
11810                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11811                    rusqlite::params![meta_key, canonical_plan],
11812                )
11813                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11814            Ok((
11815                batch,
11816                LocalDataPurgeResult {
11817                    already_applied: false,
11818                    purged_rows,
11819                    dropped_commits: doomed.len(),
11820                },
11821            ))
11822        })();
11823        let (batch, result) = match applied {
11824            Ok(value) => value,
11825            Err(error) => {
11826                self.rollback_observation("syncular_local_purge");
11827                self.outbox = prior_outbox;
11828                self.failed_commits = prior_failed;
11829                self.rejections.truncate(prior_rejection_count);
11830                self.overlay_dirty.set(prior_overlay_dirty);
11831                return Err(error);
11832            }
11833        };
11834        if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
11835            self.rollback_observation("syncular_local_purge");
11836            self.outbox = prior_outbox;
11837            self.failed_commits = prior_failed;
11838            self.rejections.truncate(prior_rejection_count);
11839            self.overlay_dirty.set(prior_overlay_dirty);
11840            return Err(error);
11841        }
11842        Ok(result)
11843    }
11844
11845    /// Application-authorized recovery of the replicated projection. Unlike a
11846    /// local security purge, this retains the entire outbox, device identity,
11847    /// lease, outcomes, subscription registrations, and protected bookkeeping.
11848    /// The projection reset, subscription rewind, optimistic replay, and
11849    /// idempotency marker share one savepoint for interruption safety.
11850    pub fn rebootstrap_local_data(
11851        &mut self,
11852        input: &LocalDataRebootstrapInput,
11853    ) -> Result<LocalDataRebootstrapResult, String> {
11854        if input.rebootstrap_id.is_empty()
11855            || input.rebootstrap_id.len() > 128
11856            || !is_local_operation_code_like(&input.rebootstrap_id)
11857        {
11858            return Err(
11859                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
11860                    .to_owned(),
11861            );
11862        }
11863        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
11864        if let Some(persisted) = self.get_meta_strict(&meta_key)? {
11865            let (retained_commits, reset_subscriptions) =
11866                decode_local_rebootstrap_receipt(&persisted)?;
11867            return Ok(LocalDataRebootstrapResult {
11868                already_applied: true,
11869                retained_commits,
11870                reset_subscriptions,
11871            });
11872        }
11873        if self.stopped || self.schema_floor.is_some() {
11874            return Err(
11875                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
11876                    .to_owned(),
11877            );
11878        }
11879
11880        let retained_commits = self.outbox.len();
11881        let reset_subscriptions = self.subs.len();
11882        let prior_subs = self.subs.clone();
11883        let prior_upgrading = self.upgrading;
11884        let prior_stopped = self.stopped;
11885        let prior_schema_floor = self.schema_floor.clone();
11886        let prior_overlay_dirty = self.overlay_dirty.get();
11887        let prior_sync_needed = self.sync_needed;
11888        let prior_sync_intents = self.sync_intent_queue.clone();
11889        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
11890
11891        self.begin_observation("syncular_local_rebootstrap")?;
11892        let mut batch = ChangeAccumulator::default();
11893        let applied = (|| -> Result<(), String> {
11894            self.run_schema_reset_observed(&mut batch, false, false)?;
11895            self.conn
11896                .execute(
11897                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11898                    rusqlite::params![meta_key, receipt],
11899                )
11900                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11901            self.sync_needed = true;
11902            self.sync_intent_queue.push_back(SyncIntent::Interactive);
11903            batch.status = true;
11904            Ok(())
11905        })();
11906        if let Err(error) = applied {
11907            self.rollback_observation("syncular_local_rebootstrap");
11908            self.subs = prior_subs;
11909            self.upgrading = prior_upgrading;
11910            self.stopped = prior_stopped;
11911            self.schema_floor = prior_schema_floor;
11912            self.overlay_dirty.set(prior_overlay_dirty);
11913            self.sync_needed = prior_sync_needed;
11914            self.sync_intent_queue = prior_sync_intents;
11915            return Err(error);
11916        }
11917        if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
11918            self.rollback_observation("syncular_local_rebootstrap");
11919            self.subs = prior_subs;
11920            self.upgrading = prior_upgrading;
11921            self.stopped = prior_stopped;
11922            self.schema_floor = prior_schema_floor;
11923            self.overlay_dirty.set(prior_overlay_dirty);
11924            self.sync_needed = prior_sync_needed;
11925            self.sync_intent_queue = prior_sync_intents;
11926            return Err(error);
11927        }
11928
11929        Ok(LocalDataRebootstrapResult {
11930            already_applied: false,
11931            retained_commits,
11932            reset_subscriptions,
11933        })
11934    }
11935
11936    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
11937
11938    /// Delete base rows matching the effective scopes; `Err(())` = no local
11939    /// scope-column mapping for a key (the fail-closed case).
11940    fn purge_scope_rows(
11941        &mut self,
11942        table_name: &str,
11943        effective: &[(String, Vec<String>)],
11944    ) -> Result<(), ()> {
11945        if effective.is_empty() {
11946            return Ok(());
11947        }
11948        let table = self.schema.table(table_name).ok_or(())?.clone();
11949        let mut clauses = Vec::new();
11950        let mut params: Vec<SqlValue> = Vec::new();
11951        for (variable, values) in effective {
11952            let column = table.scope_column(variable).ok_or(())?;
11953            let placeholders: Vec<String> = values
11954                .iter()
11955                .map(|v| {
11956                    params.push(SqlValue::Text(v.clone()));
11957                    "?".to_owned()
11958                })
11959                .collect();
11960            clauses.push(format!(
11961                "{} IN ({})",
11962                quote_ident(column),
11963                placeholders.join(", ")
11964            ));
11965        }
11966        let sql = format!(
11967            "DELETE FROM {} WHERE {}",
11968            base_table(table_name),
11969            clauses.join(" AND ")
11970        );
11971        self.overlay_dirty.set(true);
11972        self.conn
11973            .execute(&sql, rusqlite::params_from_iter(&params))
11974            .map_err(|_| ())?;
11975        self.conn
11976            .execute(
11977                &format!(
11978                    "DELETE FROM {} WHERE {}",
11979                    visible_table(table_name),
11980                    clauses.join(" AND ")
11981                ),
11982                rusqlite::params_from_iter(&params),
11983            )
11984            .map_err(|_| ())?;
11985        Ok(())
11986    }
11987
11988    /// §3.3: drop pending commits whose upserts provably land in the
11989    /// revoked effective scopes — whole-commit, never per-operation.
11990    fn drop_doomed_outbox(
11991        &mut self,
11992        table_name: &str,
11993        effective: &[(String, Vec<String>)],
11994    ) -> Result<bool, String> {
11995        if effective.is_empty() {
11996            return Ok(false);
11997        }
11998        let Some(table) = self.schema.table(table_name).cloned() else {
11999            return Ok(false);
12000        };
12001        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
12002        for (variable, values) in effective {
12003            match table.scope_column(variable) {
12004                Some(column) => mappings.push((column, values)),
12005                None => return Ok(false), // not provable without a mapping
12006            }
12007        }
12008        let doomed: Vec<OutboxCommit> = self
12009            .outbox
12010            .iter()
12011            .filter(|commit| {
12012                commit.ops.iter().any(|op| {
12013                    op.upsert
12014                        && op.table == table_name
12015                        && op.values.as_ref().is_some_and(|values| {
12016                            mappings
12017                                .iter()
12018                                .all(|(column, allowed)| match values.get(*column) {
12019                                    Some(Value::String(s)) => allowed.contains(s),
12020                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
12021                                    _ => false,
12022                                })
12023                        })
12024                })
12025            })
12026            .cloned()
12027            .collect();
12028        if doomed.is_empty() {
12029            return Ok(false);
12030        }
12031        let mut rejections = Vec::new();
12032        for commit in &doomed {
12033            let results = commit
12034                .ops
12035                .iter()
12036                .enumerate()
12037                .map(|(op_index, operation)| {
12038                    let rejection = RejectionRecord {
12039                        client_commit_id: commit.client_commit_id.clone(),
12040                        op_index: op_index as i32,
12041                        code: "sync.scope_revoked".to_owned(),
12042                        message: "the commit was dropped because its effective scope was revoked"
12043                            .to_owned(),
12044                        retryable: false,
12045                        details: None,
12046                        operation: Some(CommitOperation::from(operation)),
12047                    };
12048                    rejections.push(rejection.clone());
12049                    CommitOperationOutcome::Error { rejection }
12050                })
12051                .collect::<Vec<_>>();
12052            self.persist_commit_outcome(
12053                &commit.client_commit_id,
12054                CommitOutcomeStatus::Rejected,
12055                &results,
12056                Some(&commit.ops),
12057            )?;
12058            self.delete_outbox_persisted(&commit.client_commit_id)?;
12059        }
12060        self.prune_commit_outcomes()?;
12061        let doomed_ids = doomed
12062            .iter()
12063            .map(|commit| commit.client_commit_id.as_str())
12064            .collect::<BTreeSet<_>>();
12065        self.outbox
12066            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
12067        self.rejections.extend(rejections);
12068        self.overlay_dirty.set(true);
12069        Ok(true)
12070    }
12071
12072    // -- blobs (§5.9) ----------------------------------------------------------------
12073
12074    /// §5.9.7: hash bytes into the content address, cache them, queue the
12075    /// upload (flushed before the next push, B4). Returns the canonical
12076    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
12077    /// column value.
12078    pub fn upload_blob(
12079        &mut self,
12080        bytes: &[u8],
12081        media_type: Option<String>,
12082        name: Option<String>,
12083    ) -> Result<Value, String> {
12084        let blob_id = blob_id_for(bytes);
12085        let now = self.clock_now_ms();
12086        let tx = self
12087            .conn
12088            .transaction()
12089            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12090        tx.execute(
12091            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
12092             ON CONFLICT(blob_id) DO NOTHING",
12093            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
12094        )
12095        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12096        tx.execute(
12097            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
12098            rusqlite::params![blob_id, media_type, now],
12099        )
12100        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12101        tx.commit()
12102            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12103        self.enforce_blob_cache_cap();
12104        let mut obj = Map::new();
12105        obj.insert("blobId".to_owned(), Value::from(blob_id));
12106        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
12107        if let Some(mt) = media_type {
12108            obj.insert("mediaType".to_owned(), Value::from(mt));
12109        }
12110        if let Some(n) = name {
12111            obj.insert("name".to_owned(), Value::from(n));
12112        }
12113        Ok(Value::Object(obj))
12114    }
12115
12116    /// §5.9.7: resolve an owned blob body without driver encoding. A
12117    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
12118    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
12119    pub fn fetch_blob_bytes(
12120        &mut self,
12121        transport: &mut dyn Transport,
12122        blob_id_or_ref: &str,
12123    ) -> Result<FetchedBlob, (String, String)> {
12124        let simple = |m: String| ("client.failed".to_owned(), m);
12125        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
12126            blob_id_or_ref.to_owned()
12127        } else {
12128            let value: Value = serde_json::from_str(blob_id_or_ref)
12129                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
12130            value
12131                .get("blobId")
12132                .and_then(Value::as_str)
12133                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
12134                .to_owned()
12135        };
12136        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
12137            return Ok(cached);
12138        }
12139        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
12140        // blob.not_found) verbatim so the harness can assert on it. The
12141        // authorized endpoint serves bytes inline OR (always-issue, presign
12142        // configured) a signed url the client fetches directly — no host auth,
12143        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
12144        #[cfg(feature = "bench-internals")]
12145        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
12146        let bytes = match transport
12147            .blob_download(&blob_id)
12148            .map_err(|e| (e.code, e.message))?
12149        {
12150            BlobDownload::Bytes(bytes) => bytes,
12151            BlobDownload::Url {
12152                url,
12153                url_expires_at_ms,
12154            } => {
12155                // §5.9.5: MUST NOT start a fetch at/past expiry.
12156                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
12157                    return Err((
12158                        "sync.segment_expired".to_owned(),
12159                        format!(
12160                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
12161                        ),
12162                    ));
12163                }
12164                transport
12165                    .fetch_blob_url(&url)
12166                    .map_err(|e| (e.code, e.message))?
12167            }
12168        };
12169        #[cfg(feature = "bench-internals")]
12170        drop(download_phase);
12171        #[cfg(feature = "bench-internals")]
12172        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
12173        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
12174        if blob_id_for(&bytes) != blob_id {
12175            return Err(simple(format!(
12176                "blob content address mismatch for {blob_id}"
12177            )));
12178        }
12179        #[cfg(feature = "bench-internals")]
12180        drop(validate_phase);
12181        #[cfg(feature = "bench-internals")]
12182        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
12183        let now = self.clock_now_ms();
12184        self.conn
12185            .execute(
12186                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
12187                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
12188            )
12189            .map_err(|e| simple(e.to_string()))?;
12190        self.conn
12191            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
12192            .map_err(|e| simple(e.to_string()))?;
12193        self.enforce_blob_cache_cap();
12194        #[cfg(feature = "bench-internals")]
12195        drop(insert_phase);
12196        self.get_cached_blob_bytes(&blob_id)
12197            .map_err(simple)?
12198            .ok_or_else(|| simple("blob cache write failed".to_owned()))
12199    }
12200
12201    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
12202        #[cfg(feature = "bench-internals")]
12203        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
12204        let mut stmt = self
12205            .conn
12206            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
12207            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12208        let mut rows = stmt
12209            .query(rusqlite::params![blob_id])
12210            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12211        if let Some(row) = rows
12212            .next()
12213            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
12214        {
12215            let bytes: Vec<u8> = row
12216                .get(0)
12217                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12218            let byte_length: i64 = row
12219                .get(1)
12220                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12221            let media_type: Option<String> = row
12222                .get(2)
12223                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12224            return Ok(Some(FetchedBlob {
12225                blob_id: blob_id.to_owned(),
12226                byte_length,
12227                bytes,
12228                media_type,
12229            }));
12230        }
12231        Ok(None)
12232    }
12233
12234    /// §5.9.7 B4: upload every queued blob before push.
12235    fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
12236        let pending: Vec<(String, Option<String>)> = {
12237            let mut stmt = self
12238                .conn
12239                .prepare(
12240                    "SELECT blob_id, media_type FROM (
12241                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
12242                      UNION ALL
12243                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
12244                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
12245                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
12246                    ) ORDER BY created_at_ms, blob_id",
12247                )
12248                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
12249            let rows = stmt
12250                .query_map([], |row| {
12251                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
12252                })
12253                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
12254            rows.collect::<Result<_, _>>()
12255                .map_err(|error| match error {
12256                    rusqlite::Error::InvalidColumnType(..)
12257                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
12258                        "sync.local_corrupt",
12259                        "Pending blob upload metadata is invalid",
12260                    ),
12261                    _ => TransportError::new("client.failed", error.to_string()),
12262                })?
12263        };
12264        for (blob_id, media_type) in pending {
12265            let stored: Option<(SqlValue, SqlValue)> = self
12266                .conn
12267                .query_row(
12268                    "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
12269                    rusqlite::params![blob_id],
12270                    |row| Ok((row.get(0)?, row.get(1)?)),
12271                )
12272                .optional()
12273                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
12274            let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
12275                return Err(TransportError::new(
12276                    "sync.local_corrupt",
12277                    "Pending blob upload body is missing or corrupt",
12278                ));
12279            };
12280            if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
12281                return Err(TransportError::new(
12282                    "sync.local_corrupt",
12283                    "Pending blob upload body is missing or corrupt",
12284                ));
12285            }
12286            self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
12287            self.conn
12288                .execute(
12289                    "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
12290                    rusqlite::params![blob_id],
12291                )
12292                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
12293        }
12294        Ok(())
12295    }
12296
12297    /// §5.9.3: upload one blob, preferring the presigned direct-to-storage
12298    /// grant when the transport supports it, else streaming through the direct
12299    /// host-authenticated endpoint (capability, not fallback). A `Url` grant
12300    /// PUTs direct with no host auth; on a grant PUT failure the client streams
12301    /// through the direct endpoint — a *different, host-authenticated
12302    /// capability*, not a fall-through of the grant's authority.
12303    fn upload_one(
12304        &self,
12305        transport: &mut dyn Transport,
12306        blob_id: &str,
12307        bytes: &[u8],
12308        media_type: Option<&str>,
12309    ) -> Result<(), TransportError> {
12310        match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
12311            BlobUploadGrant::Present => return Ok(()), // idempotent §5.9.3
12312            BlobUploadGrant::Url {
12313                url,
12314                url_expires_at_ms,
12315            } => {
12316                let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
12317                if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
12318                    return Ok(());
12319                }
12320                // Failed/expired grant PUT — stream through the direct endpoint.
12321            }
12322            BlobUploadGrant::None => {
12323                // No presign store — stream through the direct endpoint.
12324            }
12325        }
12326        transport.blob_upload(blob_id, bytes, media_type)
12327    }
12328
12329    fn visible_blob_ids_sql(&self) -> String {
12330        let mut selects = Vec::new();
12331        for table in &self.schema.tables {
12332            for column in table
12333                .columns
12334                .iter()
12335                .filter(|column| column.ty == ColumnType::BlobRef)
12336            {
12337                let identifier = quote_ident(&column.name);
12338                selects.push(format!(
12339                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
12340                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
12341                     AND json_type({identifier}, '$.blobId') = 'text'",
12342                    quote_ident(&table.name),
12343                ));
12344            }
12345        }
12346        if selects.is_empty() {
12347            "SELECT NULL AS blob_id WHERE 0".to_owned()
12348        } else {
12349            selects.join(" UNION ")
12350        }
12351    }
12352
12353    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
12354    /// staged uploads pin bodies directly; no derived refcount is stored.
12355    fn enforce_blob_cache_cap(&self) {
12356        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
12357            return;
12358        };
12359        let mut total: i64 = self
12360            .conn
12361            .query_row(
12362                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
12363                [],
12364                |row| row.get(0),
12365            )
12366            .unwrap_or(0);
12367        if total <= max_bytes {
12368            return;
12369        }
12370        let query = format!(
12371            "SELECT blob_id, byte_length FROM _syncular_blobs
12372             WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
12373               AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
12374               AND blob_id NOT IN ({})
12375             ORDER BY created_at_ms ASC, blob_id ASC",
12376            self.visible_blob_ids_sql(),
12377        );
12378        let candidates: Vec<(String, i64)> = {
12379            let Ok(mut stmt) = self.conn.prepare(&query) else {
12380                return;
12381            };
12382            let Ok(rows) = stmt.query_map([], |row| {
12383                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
12384            }) else {
12385                return;
12386            };
12387            rows.filter_map(Result::ok).collect()
12388        };
12389        for (blob_id, byte_length) in candidates {
12390            if total <= max_bytes {
12391                break;
12392            }
12393            let _ = self.conn.execute(
12394                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
12395                rusqlite::params![blob_id],
12396            );
12397            total -= byte_length;
12398        }
12399    }
12400
12401    fn delete_unreferenced_cached_blobs(&mut self) {
12402        #[cfg(feature = "bench-internals")]
12403        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
12404        if !self.schema_has_blobs() {
12405            return;
12406        }
12407        self.rebuild_overlay_if_dirty();
12408        let _ = self.conn.execute(
12409            &format!(
12410                "DELETE FROM _syncular_blobs
12411                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
12412                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
12413                   AND blob_id NOT IN ({})",
12414                self.visible_blob_ids_sql(),
12415            ),
12416            [],
12417        );
12418    }
12419
12420    // -- local row storage ----------------------------------------------------------
12421
12422    /// The cached per-table primary-key upsert SQL for `full_table` (see
12423    /// the `insert_sql` field: built once, reused per row).
12424    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
12425        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
12426            return sql.clone();
12427        }
12428        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
12429        columns.push(quote_ident("_syncular_version"));
12430        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
12431        let primary_key = quote_ident(&table.primary_key);
12432        let updates = columns
12433            .iter()
12434            .filter(|column| **column != primary_key)
12435            .map(|column| format!("{column}=excluded.{column}"))
12436            .collect::<Vec<_>>()
12437            .join(", ");
12438        let sql = format!(
12439            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
12440            columns.join(", "),
12441            placeholders.join(", ")
12442        );
12443        self.insert_sql
12444            .borrow_mut()
12445            .insert(full_table.to_owned(), sql.clone());
12446        sql
12447    }
12448
12449    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
12450        self.overlay_dirty.set(true);
12451        self.write_row(&base_table(table_name), table_name, row, version)
12452    }
12453
12454    fn write_row(
12455        &self,
12456        full_table: &str,
12457        table_name: &str,
12458        row: &Row,
12459        version: i64,
12460    ) -> Result<(), String> {
12461        #[cfg(feature = "bench-internals")]
12462        let _phase = self.benchmark_phases.start(Phase::RowWrite);
12463        let table = self
12464            .schema
12465            .table(table_name)
12466            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
12467        let sql = self.insert_row_sql(full_table, table);
12468        let mut stmt = self
12469            .conn
12470            .prepare_cached(&sql)
12471            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12472        let params = row
12473            .iter()
12474            .map(RowParam::Cell)
12475            .chain(std::iter::once(RowParam::Version(version)));
12476        stmt.execute(rusqlite::params_from_iter(params))
12477            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12478        Ok(())
12479    }
12480
12481    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
12482        let table = self
12483            .schema
12484            .table(table_name)
12485            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
12486        self.overlay_dirty.set(true);
12487        let sql = format!(
12488            "DELETE FROM {} WHERE {}",
12489            base_table(table_name),
12490            row_id_predicate(table)
12491        );
12492        let mut stmt = self
12493            .conn
12494            .prepare_cached(&sql)
12495            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12496        stmt.execute(rusqlite::params![row_id])
12497            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12498        Ok(())
12499    }
12500
12501    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
12502    /// the outbox changed since the last rebuild (the no-op sync round).
12503    fn rebuild_overlay_if_dirty(&mut self) {
12504        if self.overlay_dirty.get() {
12505            self.rebuild_overlay();
12506        }
12507    }
12508
12509    /// §7.1: local reads see outbox state applied optimistically — rebuild
12510    /// every visible table as (base server state) + (pending outbox replay
12511    /// on top). Optimistic rows carry version `-1`.
12512    fn rebuild_overlay(&mut self) {
12513        #[cfg(feature = "bench-internals")]
12514        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
12515        #[cfg(test)]
12516        self.overlay_rebuild_count
12517            .set(self.overlay_rebuild_count.get() + 1);
12518        self.exec("SAVEPOINT syncular_overlay");
12519        for table in self.schema.tables.clone() {
12520            for index in &table.fts_indexes {
12521                let _ = self.drop_fts_triggers(index);
12522            }
12523            let visible = visible_table(&table.name);
12524            let base = base_table(&table.name);
12525            self.exec(&format!("DELETE FROM {visible}"));
12526            self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
12527        }
12528        for (commit, initial) in &self.failed_commits {
12529            for operation in &commit.ops {
12530                if !operation.upsert {
12531                    continue;
12532                }
12533                if let Some(table) = self.schema.table(&operation.table) {
12534                    if self.visible_row(table, &operation.row_id).is_none()
12535                        && operation.values.as_ref().is_some_and(|values| {
12536                            table
12537                                .columns
12538                                .iter()
12539                                .all(|column| values.contains_key(&column.name))
12540                        })
12541                    {
12542                        let key = serde_json::to_string(&(
12543                            operation.table.clone(),
12544                            operation.row_id.clone(),
12545                        ))
12546                        .expect("row key serialization");
12547                        if let Some(Value::Object(values)) = initial.get(&key) {
12548                            self.apply_outbox_ops(&[OutboxOp {
12549                                values: Some(values.clone()),
12550                                ..operation.clone()
12551                            }]);
12552                        }
12553                    }
12554                }
12555            }
12556            self.apply_outbox_ops(&commit.ops);
12557        }
12558        for commit in &self.outbox {
12559            self.apply_outbox_ops(&commit.ops);
12560        }
12561        for table in self.schema.tables.clone() {
12562            for index in &table.fts_indexes {
12563                let _ = self.rebuild_fts_projection(&table, index);
12564                let _ = self.create_fts_triggers(&table, index);
12565            }
12566        }
12567        self.exec("RELEASE syncular_overlay");
12568        self.overlay_dirty.set(false);
12569    }
12570
12571    /// §7.1 replay: every operation is applied over the current visible
12572    /// state. An operation that no longer applies (a server row now holds its
12573    /// unique value) stays pending and invisible until a later replay admits
12574    /// it or the server answers its push.
12575    fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
12576        #[cfg(feature = "bench-internals")]
12577        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
12578        for op in ops {
12579            #[cfg(feature = "bench-internals")]
12580            if let Some(phase) = &mut phase {
12581                phase.unit();
12582            }
12583            let _ = self.apply_outbox_op(op);
12584        }
12585    }
12586
12587    fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
12588        let Some(table) = self.schema.table(&op.table) else {
12589            return Ok(());
12590        };
12591        let visible = visible_table(&table.name);
12592        if !op.upsert {
12593            let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
12594            self.conn
12595                .prepare_cached(&sql)
12596                .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
12597                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12598            return Ok(());
12599        }
12600        let Some(values) = op.values.as_ref() else {
12601            return Ok(());
12602        };
12603        // §7.1: the overlay applies the operation's PRESENT columns
12604        // over the current local row. A partial operation over an
12605        // absent local row leaves it absent — the server answers it
12606        // with sync.row_deleted or sync.row_missing (§6.2).
12607        let full = table
12608            .columns
12609            .iter()
12610            .all(|column| values.contains_key(&column.name));
12611        let local = self.visible_row(table, &op.row_id);
12612        if local.is_none() && !full {
12613            return Ok(());
12614        }
12615        let mut row: Row = Vec::with_capacity(table.columns.len());
12616        for (index, column) in table.columns.iter().enumerate() {
12617            let incoming = match values.get(&column.name) {
12618                Some(raw) => json_to_column_value(column, Some(raw)),
12619                None => Ok(local
12620                    .as_ref()
12621                    .and_then(|(values, _)| values.get(index).cloned())
12622                    .flatten()),
12623            };
12624            match incoming {
12625                Ok(value) => row.push(value),
12626                Err(_) => return Ok(()),
12627            }
12628        }
12629        let version = local.as_ref().map_or(-1, |(_, version)| *version);
12630        self.write_row(&visible, &table.name, &row, version)
12631            .map_err(|error| {
12632                if error.starts_with("UNIQUE constraint failed:") {
12633                    "sync.constraint_violation: local write violates a unique constraint".to_owned()
12634                } else {
12635                    error
12636                }
12637            })
12638    }
12639
12640    /// The visible (optimistic) row's values plus version, or `None` when the
12641    /// row is absent from the overlay.
12642    fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
12643        self.stored_row(table, row_id, false)
12644    }
12645
12646    fn stored_row(&self, table: &TableSchema, row_id: &str, base: bool) -> Option<(Row, i64)> {
12647        let sql = format!(
12648            "SELECT * FROM {} WHERE {}",
12649            if base {
12650                base_table(&table.name)
12651            } else {
12652                visible_table(&table.name)
12653            },
12654            row_id_predicate(table)
12655        );
12656        let mut stmt = self.conn.prepare_cached(&sql).ok()?;
12657        let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
12658        let row = rows.next().ok()??;
12659        let mut values = Vec::with_capacity(table.columns.len());
12660        for (index, column) in table.columns.iter().enumerate() {
12661            let raw = row.get_ref(index).ok()?;
12662            let json = sql_ref_to_json(column, raw);
12663            values.push(json_to_column_value(column, Some(&json)).ok()?);
12664        }
12665        let version: i64 = row.get(table.columns.len()).ok()?;
12666        Some((values, version))
12667    }
12668
12669    fn exec(&self, sql: &str) {
12670        let _ = self.conn.execute_batch(sql);
12671    }
12672
12673    // -- realtime (§8) ---------------------------------------------------------------
12674
12675    /// §8.8: the configured realtime policy.
12676    #[must_use]
12677    pub fn realtime_policy(&self) -> RealtimePolicy {
12678        self.realtime_policy
12679    }
12680
12681    /// §8.8: the explicit realtime availability state.
12682    #[must_use]
12683    pub fn realtime_state(&self) -> RealtimeState {
12684        self.realtime_state
12685    }
12686
12687    /// §8.8: set the realtime policy. Refused while the socket is connected:
12688    /// a policy change never silently re-routes an ownership state.
12689    pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
12690        if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
12691            return Err(
12692                "sync.invalid_request: disconnect realtime before changing the realtime policy"
12693                    .to_owned(),
12694            );
12695        }
12696        self.realtime_policy = policy;
12697        match policy {
12698            RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
12699            RealtimePolicy::Required | RealtimePolicy::Optional
12700                if self.realtime_state == RealtimeState::Disabled =>
12701            {
12702                self.set_realtime_state(RealtimeState::Disconnected, None);
12703            }
12704            RealtimePolicy::Required | RealtimePolicy::Optional => {}
12705        }
12706        Ok(())
12707    }
12708
12709    /// Record an availability transition, keeping the reason code bounded and
12710    /// code-like (§7.6) and clearing it where it cannot apply.
12711    fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
12712        self.realtime_state = state;
12713        self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
12714        if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
12715            self.realtime_reason_code = None;
12716            self.realtime_retry_delay_ms = None;
12717        }
12718    }
12719
12720    #[must_use]
12721    fn realtime_connected(&self) -> bool {
12722        self.realtime_state == RealtimeState::Connected
12723    }
12724
12725    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
12726        if self.realtime_policy == RealtimePolicy::Off {
12727            return Err(
12728                "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
12729            );
12730        }
12731        if self.realtime_connected() {
12732            return Ok(());
12733        }
12734        self.realtime_state = RealtimeState::Connecting;
12735        match transport.realtime_connect_for_client(&self.client_id) {
12736            Ok(()) => {
12737                self.set_realtime_state(RealtimeState::Connected, None);
12738                Ok(())
12739            }
12740            Err(error) => {
12741                self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
12742                Err(format!("{}: {}", error.code, error.message))
12743            }
12744        }
12745    }
12746
12747    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
12748        // A deliberately disconnected or policy-disabled client has nothing
12749        // to release; a `lost` socket still does (the host owns the handle).
12750        if self.realtime_state == RealtimeState::Disconnected
12751            || self.realtime_state == RealtimeState::Disabled
12752        {
12753            return;
12754        }
12755        let _ = transport.realtime_close();
12756        self.set_realtime_state(
12757            if self.realtime_policy == RealtimePolicy::Off {
12758                RealtimeState::Disabled
12759            } else {
12760                RealtimeState::Disconnected
12761            },
12762            None,
12763        );
12764        self.presence.clear(); // §8.6.1: presence is per-connection
12765    }
12766
12767    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
12768    /// document for `scope_key`. Requires a live socket; the document is
12769    /// ephemeral (lost on disconnect). Authorization is the connection's
12770    /// registration (§8.6.3) — an unheld key is rejected loudly by the
12771    /// server with `presence.forbidden`.
12772    pub fn set_presence(
12773        &mut self,
12774        transport: &mut dyn Transport,
12775        scope_key: &str,
12776        doc: Option<&Value>,
12777    ) -> Result<(), String> {
12778        if !self.realtime_connected() {
12779            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
12780        }
12781        let text = encode_presence_publish(scope_key, doc);
12782        transport
12783            .realtime_send(&text)
12784            .map_err(|e| format!("{}: {}", e.code, e.message))
12785    }
12786
12787    /// §8.6: the peers currently present on a scope key (ephemeral).
12788    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
12789        self.presence
12790            .get(scope_key)
12791            .map(|peers| peers.values().cloned().collect())
12792            .unwrap_or_default()
12793    }
12794
12795    /// §8.6 apply an inbound presence fanout to the local map.
12796    fn apply_presence(
12797        &mut self,
12798        scope_key: String,
12799        kind: Option<PresenceKind>,
12800        actor_id: Option<String>,
12801        client_id: Option<String>,
12802        doc: Option<Value>,
12803        error: Option<String>,
12804    ) {
12805        // The publisher-directed error variant is out-of-band; nothing to
12806        // record in the peer map.
12807        if error.is_some() {
12808            return;
12809        }
12810        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
12811            return;
12812        };
12813        let peer_key = format!("{actor_id} {client_id}");
12814        match kind {
12815            PresenceKind::Leave => {
12816                if let Some(peers) = self.presence.get_mut(&scope_key) {
12817                    peers.remove(&peer_key);
12818                    if peers.is_empty() {
12819                        self.presence.remove(&scope_key);
12820                    }
12821                }
12822            }
12823            _ => {
12824                let doc = match doc {
12825                    Some(Value::Object(_)) => doc.unwrap(),
12826                    _ => return,
12827                };
12828                self.presence.entry(scope_key).or_default().insert(
12829                    peer_key,
12830                    PresencePeer {
12831                        actor_id,
12832                        client_id,
12833                        doc,
12834                    },
12835                );
12836            }
12837        }
12838    }
12839
12840    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
12841    pub fn on_realtime_text(&mut self, text: &str) {
12842        match parse_control(text) {
12843            Ok(ControlMessage::Hello { requires_sync, .. }) => {
12844                if requires_sync {
12845                    // §8.1: pull before trusting the socket for continuity.
12846                    self.set_sync_needed(true, true);
12847                }
12848            }
12849            Ok(ControlMessage::Presence {
12850                scope_key,
12851                kind,
12852                actor_id,
12853                client_id,
12854                doc,
12855                error,
12856                ..
12857            }) => {
12858                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
12859            }
12860            Ok(ControlMessage::Wake { .. }) => {
12861                // §8.3: any wake-up means "run a pull soon", never data.
12862                self.set_sync_needed(true, true);
12863            }
12864            _ => {}
12865        }
12866    }
12867
12868    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
12869    /// a pull response per section; an unapplied delta is a wake-up.
12870    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
12871        if self.stopped {
12872            return;
12873        }
12874        #[cfg(feature = "bench-internals")]
12875        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
12876        let message = match decode_message(bytes) {
12877            Ok(m) if m.msg_kind == MsgKind::Response => m,
12878            _ => {
12879                self.set_sync_needed(true, true);
12880                return;
12881            }
12882        };
12883        #[cfg(feature = "bench-internals")]
12884        drop(decode_phase);
12885        let mut frames = message.frames.into_iter();
12886        let mut applied_cursor: Option<i64> = None;
12887        let mut any_covered = false;
12888        let mut dropped = false;
12889        while let Some(frame) = frames.next() {
12890            let Frame::SubStart {
12891                id,
12892                status,
12893                effective_scopes,
12894                ..
12895            } = frame
12896            else {
12897                continue;
12898            };
12899            let mut body = Vec::new();
12900            let mut next_cursor: Option<i64> = None;
12901            for inner in frames.by_ref() {
12902                match inner {
12903                    Frame::SubEnd {
12904                        next_cursor: nc, ..
12905                    } => {
12906                        next_cursor = Some(nc);
12907                        break;
12908                    }
12909                    Frame::Unknown { .. } => {}
12910                    other => body.push(other),
12911                }
12912            }
12913            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
12914                dropped = true;
12915                continue;
12916            };
12917            let sub = &self.subs[sub_index];
12918            // §8.2: only locally active, not mid-bootstrap subscriptions
12919            // apply; skipped sections are repaired by the next pull.
12920            if status != SubStatus::Active
12921                || sub.state != SubState::Active
12922                || sub.bootstrap_state.is_some()
12923                || !sub.synced_once
12924            {
12925                dropped = true;
12926                continue;
12927            }
12928            if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
12929                // Idempotent redelivery of an already-covered window.
12930                any_covered = true;
12931                continue;
12932            }
12933            let previous_effective = self.subs[sub_index].effective.clone();
12934            let previous_cursor = self.subs[sub_index].cursor;
12935            self.subs[sub_index].effective = Some(effective_scopes);
12936            let mut failed = false;
12937            for inner in body {
12938                match inner {
12939                    Frame::Commit {
12940                        tables, changes, ..
12941                    } => {
12942                        if self.apply_commit_frame(&tables, &changes).is_err() {
12943                            failed = true;
12944                            break;
12945                        }
12946                    }
12947                    _ => {
12948                        failed = true;
12949                        break;
12950                    }
12951                }
12952            }
12953            let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
12954                self.apply_sub_end(sub_index, cursor, None)
12955                    .ok()
12956                    .map(|()| cursor)
12957            });
12958            let Some(next_cursor) = committed else {
12959                self.subs[sub_index].effective = previous_effective;
12960                self.subs[sub_index].cursor = previous_cursor;
12961                dropped = true;
12962                continue;
12963            };
12964            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
12965        }
12966        if let Some(cursor) = applied_cursor {
12967            // §8.2 ack point: the highest applied SUB_END.nextCursor.
12968            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
12969            let _ = transport.realtime_send(&ack);
12970        } else if !any_covered || dropped {
12971            // §8.2: a delta not applied at all is treated as a wake-up.
12972            self.set_sync_needed(true, true);
12973        }
12974    }
12975
12976    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
12977    /// cursor across active, non-bootstrapping subscriptions that have
12978    /// synced at least once. No such subscription, no ack.
12979    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
12980        if !self.realtime_connected() {
12981            return;
12982        }
12983        let floor = self
12984            .subs
12985            .iter()
12986            .filter(|s| {
12987                s.state == SubState::Active
12988                    && s.bootstrap_state.is_none()
12989                    && s.synced_once
12990                    && s.cursor >= 0
12991            })
12992            .map(|s| s.cursor)
12993            .min();
12994        if let Some(cursor) = floor {
12995            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
12996            let _ = transport.realtime_send(&ack);
12997        }
12998    }
12999}
13000
13001#[cfg(test)]
13002mod previous_version_wiring_tests {
13003    use super::*;
13004    use serde_json::{json, Value};
13005
13006    fn previous_version_schema(version: i32) -> Value {
13007        json!({
13008            "version": version,
13009            "tables": [{
13010                "name": "tasks",
13011                "primaryKey": "id",
13012                "columns": [
13013                    { "name": "id", "type": "string", "nullable": false },
13014                    { "name": "note", "type": "string", "nullable": true }
13015                ],
13016                "scopes": []
13017            }]
13018        })
13019    }
13020
13021    fn previous_version_limits() -> crate::api::ClientLimits {
13022        crate::api::ClientLimits {
13023            previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
13024                enabled: true,
13025                ..Default::default()
13026            }),
13027            ..Default::default()
13028        }
13029    }
13030
13031    /// The configuration the live client carries, for a direct capture call.
13032    fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
13033        previous_version_limits()
13034            .previous_version_context
13035            .expect("configured")
13036    }
13037
13038    /// Capture the container the way a bump would, so a read test starts from a
13039    /// container this client's own state will then decide about.
13040    fn recapture_previous_version(client: &SyncClient) {
13041        let replica_path = client
13042            .previous_version_replica_path()
13043            .expect("file-backed replica");
13044        let outcome = crate::previous_version::capture_previous_version_from_replica(
13045            &client.conn,
13046            &replica_path,
13047            2,
13048            2,
13049            &previous_version_config(),
13050            1,
13051        )
13052        .expect("capture");
13053        assert!(matches!(
13054            outcome,
13055            crate::previous_version::CaptureOutcome::Captured(_)
13056        ));
13057    }
13058
13059    fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
13060        Subscription {
13061            id: "wiring-sub".to_owned(),
13062            table: "tasks".to_owned(),
13063            requested: Vec::new(),
13064            params: None,
13065            cursor,
13066            bootstrap_state: bootstrap_state.map(str::to_owned),
13067            state,
13068            reason_code: None,
13069            effective: None,
13070            synced_once: false,
13071        }
13072    }
13073
13074    #[test]
13075    fn previous_version_read_reasons_come_from_live_client_state() {
13076        use crate::previous_version::{
13077            PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
13078        };
13079        let path = std::env::temp_dir()
13080            .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
13081            .to_string_lossy()
13082            .into_owned();
13083        let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
13084        {
13085            let mut setup = SyncClient::open_path(
13086                "wiring".to_owned(),
13087                &previous_version_schema(1),
13088                previous_version_limits(),
13089                &path,
13090            )
13091            .expect("v1 install");
13092            setup
13093                .mutate(vec![Mutation::Upsert {
13094                    table: "tasks".to_owned(),
13095                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
13096                    base_version: None,
13097                }])
13098                .expect("seed a row");
13099        }
13100        let mut client = SyncClient::open_path(
13101            "wiring".to_owned(),
13102            &previous_version_schema(2),
13103            previous_version_limits(),
13104            &path,
13105        )
13106        .expect("v2 bump captures");
13107        assert!(std::path::Path::new(&container).exists());
13108        let spec = PreviousVersionReadSpec {
13109            table: "tasks".to_owned(),
13110            ..Default::default()
13111        };
13112
13113        // §7.3.5 lease error ⇒ lease-inactive, and the read discards.
13114        recapture_previous_version(&client);
13115        client.lease_state = Some(LeaseState {
13116            lease_id: None,
13117            expires_at_ms: None,
13118            error_code: Some("lease.expired".to_owned()),
13119        });
13120        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13121        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
13122        assert!(!snapshot.available);
13123        assert!(!std::path::Path::new(&container).exists());
13124
13125        // An expired lease uses the client clock (injected, no sleeps).
13126        recapture_previous_version(&client);
13127        client.set_now_ms(1_000);
13128        client.lease_state = Some(LeaseState {
13129            lease_id: Some("lease".to_owned()),
13130            expires_at_ms: Some(1_000),
13131            error_code: None,
13132        });
13133        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13134        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
13135        assert!(!std::path::Path::new(&container).exists());
13136        client.lease_state = None;
13137
13138        // A revoked subscription ⇒ scope-revoked.
13139        recapture_previous_version(&client);
13140        client.subs.push(subscription(SubState::Revoked, 0, None));
13141        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13142        assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
13143        assert!(!std::path::Path::new(&container).exists());
13144        client.subs.clear();
13145
13146        // Coverage completion: every ACTIVE subscription has a cursor and no
13147        // resume token — a still-resuming subscription is not completion.
13148        recapture_previous_version(&client);
13149        client
13150            .subs
13151            .push(subscription(SubState::Active, 0, Some("resume")));
13152        assert!(!client.previous_version_coverage_complete());
13153        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13154        assert!(snapshot.available);
13155        assert_eq!(snapshot.previous_version, Some(2));
13156        assert_eq!(snapshot.rows.len(), 1);
13157
13158        recapture_previous_version(&client);
13159        client.subs[0].bootstrap_state = None;
13160        assert!(client.previous_version_coverage_complete());
13161        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13162        assert_eq!(
13163            snapshot.reason,
13164            Some(PreviousVersionReason::CoverageComplete)
13165        );
13166        assert!(!std::path::Path::new(&container).exists());
13167        client.subs.clear();
13168
13169        // With no active subscription at all, coverage is NOT complete.
13170        assert!(!client.previous_version_coverage_complete());
13171
13172        drop(client);
13173        let _ = std::fs::remove_file(&path);
13174    }
13175
13176    /// RFC 0005 D7 lifetime drops. The read surface already proves the reasons
13177    /// come from live state; this proves the OTHER caller —
13178    /// `previous_version_lifetime_check`, the once-per-sync-round trigger —
13179    /// actually removes the container for lease-inactivity, coverage
13180    /// completion and scope revocation, and leaves it alone while the
13181    /// replacement bootstrap is still resuming.
13182    #[test]
13183    fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
13184        let path = std::env::temp_dir()
13185            .join(format!(
13186                "syncular-prev-lifetime-{}.db",
13187                uuid::Uuid::new_v4()
13188            ))
13189            .to_string_lossy()
13190            .into_owned();
13191        let container = format!(
13192            "{path}{}",
13193            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
13194        );
13195        let exists = || std::path::Path::new(&container).exists();
13196        {
13197            let mut setup = SyncClient::open_path(
13198                "lifetime".to_owned(),
13199                &previous_version_schema(1),
13200                previous_version_limits(),
13201                &path,
13202            )
13203            .expect("v1 install");
13204            setup
13205                .mutate(vec![Mutation::Upsert {
13206                    table: "tasks".to_owned(),
13207                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
13208                    base_version: None,
13209                }])
13210                .expect("seed a row");
13211        }
13212        let mut client = SyncClient::open_path(
13213            "lifetime".to_owned(),
13214            &previous_version_schema(2),
13215            previous_version_limits(),
13216            &path,
13217        )
13218        .expect("v2 bump captures");
13219        // Pin the clock so the 24h TTL never fires in this test (the TTL has
13220        // its own coverage); the capture record's createdAtMs is 1.
13221        client.set_now_ms(1_000);
13222        assert!(exists());
13223
13224        // §7.3.5 lease error ⇒ the lifetime check drops the container.
13225        client.lease_state = Some(LeaseState {
13226            lease_id: None,
13227            expires_at_ms: None,
13228            error_code: Some("lease.expired".to_owned()),
13229        });
13230        client.previous_version_lifetime_check();
13231        assert!(!exists());
13232        client.lease_state = None;
13233
13234        // §7.4.5 coverage completion (every active sub has a cursor and no
13235        // resume token) ⇒ drop.
13236        recapture_previous_version(&client);
13237        client.subs.push(subscription(SubState::Active, 0, None));
13238        client.previous_version_lifetime_check();
13239        assert!(!exists());
13240        client.subs.clear();
13241
13242        // §3.3 scope revocation ⇒ drop.
13243        recapture_previous_version(&client);
13244        client.subs.push(subscription(SubState::Revoked, 0, None));
13245        client.previous_version_lifetime_check();
13246        assert!(!exists());
13247        client.subs.clear();
13248
13249        // Negative control: a still-resuming active subscription is NOT
13250        // coverage completion, so the container survives the same call.
13251        recapture_previous_version(&client);
13252        client
13253            .subs
13254            .push(subscription(SubState::Active, 0, Some("resume")));
13255        client.previous_version_lifetime_check();
13256        assert!(exists());
13257        client.subs.clear();
13258
13259        drop(client);
13260        let _ = std::fs::remove_file(&path);
13261        let _ = std::fs::remove_file(&container);
13262        let _ = std::fs::remove_file(format!("{container}-wal"));
13263        let _ = std::fs::remove_file(format!("{container}-shm"));
13264    }
13265}