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};
11#[cfg(test)]
12use ssp2::encode_message;
13#[cfg(test)]
14use std::cell::Cell;
15use std::cell::RefCell;
16use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
17
18use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
19use rusqlite::{Connection, OpenFlags, OptionalExtension};
20use serde::{Deserialize, Serialize};
21use serde_json::{Map, Value};
22use sha2::{Digest, Sha256};
23use ssp2::decode::WIRE_VERSION;
24use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
25use ssp2::primitives::RawJson;
26use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
27use ssp2::{decode_message, encode_presence_publish, parse_control, ControlMessage, PresenceKind};
28
29use crate::api::{
30    ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
31    ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
32    ClientDiagnosticsStorage, ClientLimits, CommandEffects, CommitOperation,
33    CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery, CommitOutcomeResolution,
34    CommitOutcomeStatus, ConflictRecord, CoverageSnapshot, DiagnosticLastChange,
35    DiagnosticLastRound, DiagnosticQueryFailure, DiagnosticRoundCounters, DiagnosticSubscription,
36    FetchedBlob, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, LocalDataPurgeTarget,
37    LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation, PresencePeer,
38    PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot, QueryValue,
39    RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RetainedCommitRow,
40    RetainedUniqueConflict, RowState, SchemaFloor, SubscriptionStateView, SyncIntent, SyncOutcome,
41    SyncReport, SyncStatusSnapshot, TableChange, WindowBase, WindowChange, WindowCoverage,
42    WindowState, WindowUnitRef, CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
43    MAX_DIAGNOSTIC_QUERY_FAILURES,
44};
45use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
46#[cfg(feature = "bench-internals")]
47use crate::bench::{Phase, Recorder};
48use crate::previous_version::{
49    capture_previous_version_from_replica, first_incompatibility,
50    reconcile_previous_version_at_boot, set_local_schema_descriptor,
51    sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
52    PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
53};
54use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
55use crate::transport::{BlobDownload, SegmentRequest, Transport, TransportError};
56use crate::values::{
57    bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
58    encode_sparse_row_json, full_row_values, json_to_column_value, json_to_scope_map,
59    normalize_values_casing, render_row_id_json, scope_map_to_json, sort_scope_map,
60};
61
62/// §4.2 default: rows and SQLite images, both applied in committed chunks.
63/// Bit 3 (signed URLs, §5.4) is
64/// added per transport capability at request-build time.
65const DEFAULT_ACCEPT: u8 = 0b0111;
66const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
67const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
68const ACCEPT_SQLITE: u8 = 1 << 2;
69const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
70const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
71
72/// §7.4.1 persisted local schema-version marker (`_syncular_meta` key).
73pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
74const LOCAL_REVISION_KEY: &str = "localRevision";
75const CLIENT_ID_KEY: &str = "clientId";
76const LEASE_STATE_KEY: &str = "leaseState";
77const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
78const LOG_EPOCH_KEY: &str = "logEpoch";
79/// Persisted fail-closed quarantine marker: present while a security preflight
80/// is pending and `activateSecurity` has yet to run. Storing it in the replica
81/// keeps the gate with the data it protects, so a rebuilt host handle reopening
82/// the same file returns in preflight even though its in-memory state is fresh.
83const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
84const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
85const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
86/// §7.4.4 client-local code: a pending outbox commit cannot re-encode under
87/// the new schema after a bump. Never a wire code (§10.3).
88const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
89/// §5.11 client-local code: an encode at the push seam resolved no usable key
90/// id or named an unknown key. Never a wire code (§10.3).
91const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
92
93#[derive(Debug, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase", deny_unknown_fields)]
95struct PersistedLocalDataRebootstrapReceipt {
96    version: u8,
97    retained_commits: u64,
98    reset_subscriptions: u64,
99}
100
101fn invalid_local_rebootstrap_receipt() -> String {
102    "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
103}
104
105fn encode_local_rebootstrap_receipt(
106    retained_commits: usize,
107    reset_subscriptions: usize,
108) -> Result<String, String> {
109    let retained_commits =
110        u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
111    let reset_subscriptions =
112        u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
113    if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
114        return Err(invalid_local_rebootstrap_receipt());
115    }
116    serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
117        version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
118        retained_commits,
119        reset_subscriptions,
120    })
121    .map_err(|_| invalid_local_rebootstrap_receipt())
122}
123
124fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
125    // Pre-0.15.36 markers proved application but did not retain the receipt.
126    if value == "v1" {
127        return Ok((0, 0));
128    }
129    let receipt: PersistedLocalDataRebootstrapReceipt =
130        serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
131    if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
132        || receipt.retained_commits > MAX_JS_SAFE_INTEGER
133        || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
134    {
135        return Err(invalid_local_rebootstrap_receipt());
136    }
137    Ok((
138        usize::try_from(receipt.retained_commits)
139            .map_err(|_| invalid_local_rebootstrap_receipt())?,
140        usize::try_from(receipt.reset_subscriptions)
141            .map_err(|_| invalid_local_rebootstrap_receipt())?,
142    ))
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146enum SubState {
147    Active,
148    Revoked,
149    Failed,
150}
151
152#[cfg(test)]
153mod observation_tests {
154    use super::*;
155    use crate::native_transport::HostTransport;
156    use crate::values::encode_row_json;
157    use serde_json::json;
158
159    fn client() -> SyncClient {
160        SyncClient::new(
161            "retry-test".to_owned(),
162            &json!({
163                "version": 1,
164                "tables": [{
165                    "name": "tasks",
166                    "primaryKey": "id",
167                    "columns": [
168                        { "name": "id", "type": "string", "nullable": false },
169                        { "name": "project_id", "type": "string", "nullable": false }
170                    ],
171                    "scopes": [{ "pattern": "project:{project_id}" }]
172                }]
173            }),
174            ClientLimits::default(),
175        )
176        .expect("test client")
177    }
178
179    #[cfg(feature = "e2ee")]
180    #[test]
181    fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
182        let schema = json!({
183            "version": 1,
184            "tables": [{
185                "name": "secrets",
186                "primaryKey": "id",
187                "columns": [
188                    { "name": "id", "type": "string", "nullable": false },
189                    { "name": "encryption_key_id", "type": "string", "nullable": true },
190                    { "name": "note", "type": "bytes", "nullable": true,
191                      "encrypted": true, "declaredType": "string" }
192                ],
193                "scopes": []
194            }]
195        });
196        let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
197            .expect("test client");
198        let mut config = crate::values::EncryptionConfig::default();
199        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
200        config
201            .key_id_columns
202            .insert("secrets".to_owned(), "encryption_key_id".to_owned());
203        client.set_encryption(config);
204        // The row exists, but its key selector is absent. The encrypted edit
205        // authors locally and fails at the push seam.
206        client
207            .write_base_row(
208                "secrets",
209                &vec![Some(ColumnValue::String("ghost".into())), None, None],
210                1,
211            )
212            .unwrap();
213        client.rebuild_overlay();
214        let commit_id = client
215            .patch(
216                "secrets",
217                "ghost",
218                Map::from_iter([("note".to_owned(), json!("x"))]),
219                None,
220            )
221            .expect("patch records without an author-time encrypt failure");
222        assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
223        // ...and the push seam raises a durable local rejection, not a throw.
224        assert!(client.drop_unencodable_outbox().unwrap());
225        assert!(client.pending_commit_ids().is_empty());
226        let rejections = client.rejections();
227        assert_eq!(rejections.len(), 1);
228        assert_eq!(rejections[0].code, "client.encrypt_failed");
229        assert_eq!(rejections[0].client_commit_id, commit_id);
230        assert_eq!(
231            client
232                .commit_outcome(&commit_id)
233                .unwrap()
234                .map(|outcome| outcome.status),
235            Some(CommitOutcomeStatus::Rejected)
236        );
237    }
238
239    #[test]
240    fn patch_with_a_scope_column_matches_the_stored_local_row() {
241        let mut client = client();
242        client.create_synced_tables().unwrap();
243        client
244            .conn
245            .execute(
246                "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
247                [],
248            )
249            .unwrap();
250
251        // §3.4 rule 5: a value equal to the stored local row is a no-op, so
252        // the scope column leaves the presence set.
253        client
254            .patch(
255                "tasks",
256                "t1",
257                Map::from_iter([
258                    ("project_id".to_owned(), json!("p1")),
259                    ("id".to_owned(), json!("t1")),
260                ]),
261                None,
262            )
263            .expect("an equal scope column is a no-op");
264        let values = client.outbox[0].ops[0]
265            .values
266            .clone()
267            .expect("the patch records values");
268        assert_eq!(values.get("id"), Some(&json!("t1")));
269        assert!(!values.contains_key("project_id"));
270
271        // A differing value stays rejected.
272        let differing = client.patch(
273            "tasks",
274            "t1",
275            Map::from_iter([("project_id".to_owned(), json!("p2"))]),
276            None,
277        );
278        assert!(differing
279            .expect_err("a differing scope column is rejected")
280            .contains("patch cannot write scope column"));
281
282        // An absent local row leaves nothing to prove equality against, so
283        // the patch fails closed.
284        let absent = client.patch(
285            "tasks",
286            "ghost",
287            Map::from_iter([("project_id".to_owned(), json!("p1"))]),
288            None,
289        );
290        assert!(absent
291            .expect_err("an absent local row is rejected")
292            .starts_with("sync.row_missing:"));
293        assert_eq!(client.pending_commit_ids().len(), 1);
294    }
295
296    #[test]
297    fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
298        let schema = json!({
299            "version": 1,
300            "tables": [
301                {
302                    "name": "tenants",
303                    "primaryKey": "tenant_id",
304                    "columns": [
305                        { "name": "tenant_id", "type": "string", "nullable": false },
306                        { "name": "body", "type": "string", "nullable": false }
307                    ],
308                    "scopes": [{ "pattern": "tenant:{tenant_id}" }]
309                },
310                {
311                    "name": "buckets",
312                    "primaryKey": "id",
313                    "columns": [
314                        { "name": "id", "type": "string", "nullable": false },
315                        { "name": "bucket", "type": "float", "nullable": false },
316                        { "name": "body", "type": "string", "nullable": false }
317                    ],
318                    "scopes": [{ "pattern": "bucket:{bucket}" }]
319                }
320            ]
321        });
322        let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
323            .expect("test client");
324        client.create_synced_tables().unwrap();
325
326        // A primary key that is also a scope column still needs a local base.
327        client
328            .conn
329            .execute(
330                "INSERT INTO tenants (tenant_id, body, _syncular_version) VALUES ('t1', 'seed', 1)",
331                [],
332            )
333            .unwrap();
334        client
335            .patch(
336                "tenants",
337                "t1",
338                Map::from_iter([
339                    ("tenant_id".to_owned(), json!("t1")),
340                    ("body".to_owned(), json!("created")),
341                ]),
342                None,
343            )
344            .expect("the primary-key scope column is proven equal by construction");
345
346        // A stored REAL scope value coerces from a supplied integer.
347        client
348            .conn
349            .execute(
350                "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
351                [],
352            )
353            .unwrap();
354        client
355            .patch(
356                "buckets",
357                "b1",
358                Map::from_iter([
359                    ("bucket".to_owned(), json!(2)),
360                    ("body".to_owned(), json!("coerced")),
361                ]),
362                None,
363            )
364            .expect("the stored REAL value coerces from a supplied integer");
365        let values = client.outbox[1].ops[0]
366            .values
367            .clone()
368            .expect("the patch records values");
369        assert!(!values.contains_key("bucket"));
370
371        // A differing value stays rejected.
372        let differing = client.patch(
373            "buckets",
374            "b1",
375            Map::from_iter([("bucket".to_owned(), json!(3))]),
376            None,
377        );
378        assert!(differing
379            .expect_err("a differing scope value is rejected")
380            .contains("patch cannot write scope column"));
381    }
382
383    #[cfg(feature = "e2ee")]
384    #[test]
385    fn stored_key_fallback_resolves_an_integer_primary_key() {
386        let schema = json!({
387            "version": 1,
388            "tables": [{
389                "name": "patients",
390                "primaryKey": "id",
391                "columns": [
392                    { "name": "id", "type": "integer", "nullable": false },
393                    { "name": "encryption_key_id", "type": "string", "nullable": true },
394                    { "name": "note", "type": "bytes", "nullable": true,
395                      "encrypted": true, "declaredType": "string" }
396                ],
397                "scopes": []
398            }]
399        });
400        let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
401            .expect("test client");
402        client.create_synced_tables().unwrap();
403        client
404            .conn
405            .execute(
406                "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
407                 VALUES (5, 'k1', NULL, 1)",
408                [],
409            )
410            .unwrap();
411        let mut config = crate::values::EncryptionConfig::default();
412        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
413        config
414            .key_id_columns
415            .insert("patients".to_owned(), "encryption_key_id".to_owned());
416        client.set_encryption(config);
417        let table = client.schema.table("patients").unwrap();
418        let fallback = client
419            .stored_key_fallback(table, "5")
420            .expect("the stored integer key resolves the fallback");
421        assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
422    }
423
424    #[test]
425    fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
426        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
427            {"name":"id","type":"string","nullable":false},
428            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
429        for state in ["absent", "cached", "pinned"] {
430            let path = std::env::temp_dir().join(format!(
431                "syncular-blob-stage-{}.sqlite",
432                uuid::Uuid::new_v4()
433            ));
434            let mut client = SyncClient::open_path(
435                "stage-test".into(),
436                &schema,
437                Default::default(),
438                path.to_str().unwrap(),
439            )
440            .unwrap();
441            let bytes = b"atomic stage";
442            if state != "absent" {
443                client
444                    .upload_blob(bytes, Some("text/plain".into()), None)
445                    .unwrap();
446                if state == "cached" {
447                    client
448                        .conn
449                        .execute("DELETE FROM _syncular_blob_uploads", [])
450                        .unwrap();
451                }
452            }
453            let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
454            client
455                .conn
456                .execute_batch(
457                    "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
458                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
459                 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
460                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
461                )
462                .unwrap();
463            let error = client
464                .upload_blob(bytes, Some("application/octet-stream".into()), None)
465                .unwrap_err();
466            assert!(error.contains("injected stage failure"), "{state}: {error}");
467            assert!(client.conn.is_autocommit());
468            let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
469            drop(client);
470            let mut reopened = SyncClient::open_path(
471                "stage-test".into(),
472                &schema,
473                Default::default(),
474                path.to_str().unwrap(),
475            )
476            .unwrap();
477            let durable = reopened
478                .query("SELECT * FROM _syncular_blobs", &[])
479                .unwrap();
480            reopened
481                .conn
482                .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
483                .unwrap();
484            let retried = reopened.upload_blob(bytes, None, None).unwrap();
485            assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
486            assert_eq!(
487                reopened
488                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
489                    .unwrap(),
490                vec![Map::from_iter([(
491                    "blob_id".into(),
492                    Value::from(blob_id_for(bytes)),
493                )])]
494            );
495            drop(reopened);
496            std::fs::remove_file(&path).unwrap();
497            assert_eq!(durable, bodies, "{state}: reopened body state");
498            assert_eq!(immediate, bodies, "{state}: immediate body state");
499        }
500    }
501
502    #[test]
503    fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
504        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
505            {"name":"id","type":"string","nullable":false},
506            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
507        for fault in [
508            "missing",
509            "body-type",
510            "length",
511            "hash",
512            "pin-metadata",
513            "read",
514            "pin-delete",
515        ] {
516            let path = std::env::temp_dir().join(format!(
517                "syncular-blob-upload-{}.sqlite",
518                uuid::Uuid::new_v4()
519            ));
520            let mut client = SyncClient::open_path(
521                "upload-test".into(),
522                &schema,
523                Default::default(),
524                path.to_str().unwrap(),
525            )
526            .unwrap();
527            let bytes = b"durable upload";
528            let ref_value = client
529                .upload_blob(bytes, Some("text/plain".into()), None)
530                .unwrap();
531            let commit = client
532                .mutate(vec![Mutation::Upsert {
533                    table: "attachments".into(),
534                    values: Map::from_iter([
535                        ("id".into(), Value::from("pending")),
536                        (
537                            "file".into(),
538                            Value::from(format!(
539                                "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
540                                blob_id_for(bytes),
541                                bytes.len()
542                            )),
543                        ),
544                    ]),
545                    base_version: None,
546                }])
547                .unwrap();
548            client
549                .conn
550                .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
551                .unwrap();
552            client.conn.execute_batch(match fault {
553                "missing" => "DELETE FROM _syncular_blobs",
554                "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
555                "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
556                "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
557                "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
558                "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
559                _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
560            }).unwrap();
561            let state = if fault == "read" {
562                Vec::new()
563            } else {
564                client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
565            };
566            let mut transport = CountingRealtimeTransport::default();
567            let error = client.flush_blob_uploads(&mut transport).unwrap_err();
568            assert_eq!(
569                error.code,
570                if matches!(fault, "read" | "pin-delete") {
571                    "client.failed"
572                } else {
573                    "sync.local_corrupt"
574                },
575                "{fault}"
576            );
577            assert_eq!(
578                transport.blob_uploads.len(),
579                usize::from(fault == "pin-delete"),
580                "{fault}: corrupt bytes must never be uploaded"
581            );
582            assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
583            if fault != "read" {
584                assert_eq!(
585                    client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
586                    state
587                );
588            } else {
589                client
590                    .conn
591                    .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
592                    .unwrap();
593            }
594            drop(client);
595            let mut reopened = SyncClient::open_path(
596                "upload-test".into(),
597                &schema,
598                Default::default(),
599                path.to_str().unwrap(),
600            )
601            .unwrap();
602            assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
603            if fault != "read" {
604                assert_eq!(
605                    reopened
606                        .query("SELECT * FROM _syncular_blobs", &[])
607                        .unwrap(),
608                    state
609                );
610            }
611            if fault == "pin-delete" {
612                reopened
613                    .conn
614                    .execute_batch("DROP TRIGGER fail_pin_delete")
615                    .unwrap();
616            }
617            if fault == "pin-metadata" {
618                reopened
619                    .conn
620                    .execute(
621                        "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
622                        [],
623                    )
624                    .unwrap();
625            }
626            reopened
627                .conn
628                .execute_batch(
629                    "DELETE FROM _syncular_blobs;
630                     INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
631                )
632                .unwrap();
633            transport.blob_uploads.clear();
634            reopened.flush_blob_uploads(&mut transport).unwrap();
635            assert_eq!(
636                transport.blob_uploads,
637                [(blob_id_for(bytes), bytes.to_vec())]
638            );
639            assert_eq!(
640                reopened
641                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
642                    .unwrap(),
643                Vec::<Map<String, Value>>::new()
644            );
645            assert_eq!(reopened.pending_commit_ids(), [commit]);
646            assert_eq!(
647                reopened
648                    .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
649                    .unwrap()
650                    .unwrap()
651                    .bytes,
652                bytes
653            );
654            drop(reopened);
655            std::fs::remove_file(path).unwrap();
656        }
657    }
658
659    #[test]
660    fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
661        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
662            {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
663            "scopes":[{"pattern":"project:{project_id}"}]}]});
664        for realtime in [false, true] {
665            for fault in [
666                "none",
667                "row",
668                "revision",
669                "commit",
670                "cursor",
671                "error",
672                "missing-end",
673            ] {
674                if realtime && fault == "missing-end" {
675                    continue;
676                }
677                let path = std::env::temp_dir()
678                    .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
679                let mut client = SyncClient::open_path(
680                    "frame-test".into(),
681                    &schema,
682                    Default::default(),
683                    path.to_str().unwrap(),
684                )
685                .unwrap();
686                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
687                client
688                    .subscribe(
689                        "tasks".into(),
690                        "tasks".into(),
691                        vec![("project_id".into(), vec!["p1".into()])],
692                        None,
693                    )
694                    .unwrap();
695                client.subs[0].cursor = 0;
696                client.subs[0].synced_once = true;
697                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
698                client.persist_sub(&client.subs[0]).unwrap();
699                client.realtime_state = RealtimeState::Connected;
700                client.realtime_reason_code = None;
701                client.drain_change_batches();
702                let mut transport = CountingRealtimeTransport::default();
703                let (_, meta) = client.build_request(false);
704                let mut response = Message {
705                    wire_version: WIRE_VERSION,
706                    msg_kind: MsgKind::Response,
707                    frames: vec![
708                        Frame::RespHeader {
709                            required_schema_version: None,
710                            latest_schema_version: None,
711                            log_epoch: Some("epoch-1".into()),
712                            reset_required: Some(false),
713                        },
714                        Frame::SubStart {
715                            id: "tasks".into(),
716                            status: SubStatus::Active,
717                            reason_code: "".into(),
718                            effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
719                            bootstrap: false,
720                        },
721                    ],
722                };
723                for (index, id) in ["first", "second"].iter().enumerate() {
724                    let values = Map::from_iter([
725                        ("id".into(), Value::from(*id)),
726                        ("project_id".into(), Value::from("p1")),
727                    ]);
728                    response.frames.push(Frame::Commit {
729                        commit_seq: index as i64 + 1,
730                        actor_id: "writer".into(),
731                        created_at_ms: 1,
732                        tables: vec!["tasks".into()],
733                        changes: vec![ssp2::model::Change {
734                            table_index: 0,
735                            row_id: (*id).into(),
736                            op: Op::Upsert,
737                            row_version: Some(index as i64 + 1),
738                            scopes: vec![("project_id".into(), "p1".into())],
739                            row: Some(
740                                encode_row_json(
741                                    client.schema.table("tasks").unwrap(),
742                                    id,
743                                    &values,
744                                    &client.encryption,
745                                )
746                                .unwrap(),
747                            ),
748                        }],
749                    });
750                }
751                response.frames.push(Frame::SubEnd {
752                    next_cursor: 2,
753                    bootstrap_state: None,
754                });
755                let valid = response.clone();
756                match fault {
757                    "row" => {
758                        let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
759                        let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
760                    },
761                    "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(),
762                    "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(),
763                    "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
764                    "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});},
765                    "missing-end" => {response.frames.truncate(3);},
766                    _ => {}
767                }
768                if realtime {
769                    client.on_realtime_binary(&mut transport, &encode_message(&response));
770                } else {
771                    assert_eq!(
772                        matches!(
773                            client.process_response(&mut transport, response, &meta),
774                            SyncOutcome::Ok(_)
775                        ),
776                        fault == "none",
777                        "{fault}"
778                    );
779                }
780                assert!(
781                    client.conn.is_autocommit(),
782                    "no open transaction after {realtime}/{fault}"
783                );
784                let count = if matches!(fault, "none" | "cursor") {
785                    2
786                } else {
787                    1
788                };
789                let expected_revision = count + u64::from(realtime && fault != "none");
790                assert_eq!(
791                    client.local_revision(),
792                    expected_revision,
793                    "{realtime}/{fault}"
794                );
795                let batches = client.drain_change_batches();
796                assert_eq!(
797                    batches.len(),
798                    expected_revision as usize,
799                    "{realtime}/{fault}"
800                );
801                let row_batches: Vec<_> = batches
802                    .iter()
803                    .filter(|batch| !batch.tables.is_empty())
804                    .collect();
805                assert_eq!(row_batches.len(), count as usize);
806                assert_eq!(
807                    row_batches
808                        .iter()
809                        .map(|b| b.revision.clone())
810                        .collect::<Vec<_>>(),
811                    (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
812                );
813                for table in ["tasks", "_syncular_base_tasks"] {
814                    assert_eq!(
815                        client
816                            .conn
817                            .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
818                                .get::<_, u64>(0))
819                            .unwrap(),
820                        count,
821                        "{realtime}/{fault}/{table}"
822                    );
823                }
824                assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
825                assert_eq!(
826                    transport
827                        .messages
828                        .iter()
829                        .filter(|m| m.contains("\"ack\""))
830                        .count(),
831                    usize::from(fault == "none")
832                );
833                if matches!(fault, "revision" | "commit" | "cursor") {
834                    client
835                        .conn
836                        .execute_batch("DROP TRIGGER fail_frame")
837                        .unwrap();
838                }
839                drop(client);
840                let mut reopened = SyncClient::open_path(
841                    "frame-test".into(),
842                    &schema,
843                    Default::default(),
844                    path.to_str().unwrap(),
845                )
846                .unwrap();
847                assert_eq!(reopened.local_revision(), expected_revision);
848                assert_eq!(
849                    reopened
850                        .query("SELECT id FROM tasks ORDER BY id", &[])
851                        .unwrap()
852                        .len(),
853                    count as usize
854                );
855                assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
856                let (_, meta) = reopened.build_request(false);
857                assert!(matches!(
858                    reopened.process_response(&mut transport, valid, &meta),
859                    SyncOutcome::Ok(_)
860                ));
861                assert_eq!(
862                    reopened
863                        .query("SELECT id FROM tasks ORDER BY id", &[])
864                        .unwrap()
865                        .len(),
866                    2
867                );
868                assert_eq!(reopened.subs[0].cursor, 2);
869                drop(reopened);
870                std::fs::remove_file(path).unwrap();
871            }
872        }
873    }
874
875    #[test]
876    fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
877        for status in [SubStatus::Reset, SubStatus::Revoked] {
878            for fault in ["subscription", "revision"] {
879                let mut client = client();
880                client
881                    .subscribe(
882                        "tasks".into(),
883                        "tasks".into(),
884                        vec![("project_id".into(), vec!["p1".into()])],
885                        None,
886                    )
887                    .unwrap();
888                client.subs[0].cursor = 41;
889                client.subs[0].synced_once = true;
890                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
891                client.persist_sub(&client.subs[0]).unwrap();
892                client
893                    .mutate(vec![Mutation::Upsert {
894                        table: "tasks".into(),
895                        values: Map::from_iter([
896                            ("id".into(), Value::from("pending")),
897                            ("project_id".into(), Value::from("p1")),
898                        ]),
899                        base_version: None,
900                    }])
901                    .unwrap();
902                let ids = client.pending_commit_ids();
903                let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
904                let revision = client.local_revision();
905                client.drain_change_batches();
906                let (_, meta) = client.build_request(false);
907                client.conn.execute_batch(if fault == "subscription" {
908                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
909                } else {
910                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
911                }).unwrap();
912                let mut transport = CountingRealtimeTransport::default();
913                let mut report = SyncReport::default();
914                // Reset changes window completeness and therefore needs a revision.
915                if status == SubStatus::Reset {
916                    client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
917                }
918                let result = client.process_section(
919                    &mut transport,
920                    "tasks",
921                    status,
922                    "sync.scope_revoked",
923                    vec![],
924                    vec![],
925                    Some((42, None)),
926                    &meta,
927                    &mut report,
928                );
929                assert!(result.is_err(), "{status:?}/{fault}");
930                assert!(client.conn.is_autocommit());
931                assert_eq!(client.subs[0].cursor, 41);
932                assert_eq!(client.subs[0].state, SubState::Active);
933                assert_eq!(client.pending_commit_ids(), ids);
934                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
935                assert!(client.rejections.is_empty());
936                assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
937                assert_eq!(client.local_revision(), revision);
938                assert!(client.drain_change_batches().is_empty());
939                assert!(report.revoked.is_empty() && report.resets.is_empty());
940            }
941        }
942    }
943
944    #[test]
945    fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
946        let mut client = client();
947        client
948            .subscribe(
949                "tasks".into(),
950                "tasks".into(),
951                vec![("project_id".into(), vec!["p1".into()])],
952                None,
953            )
954            .unwrap();
955        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
956        client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
957            CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
958        let row = |id: &str| ssp2::segment::SegmentRow {
959            server_version: 1,
960            values: vec![
961                Some(ColumnValue::String(id.into())),
962                Some(ColumnValue::String("p1".into())),
963            ],
964        };
965        let segment = RowsSegment {
966            table: "tasks".into(),
967            schema_version: 1,
968            columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
969            blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
970        };
971        assert!(client.apply_segment(0, &segment, true).is_err());
972        assert!(client.conn.is_autocommit());
973        assert_eq!(
974            client
975                .query("SELECT id FROM tasks ORDER BY id", &[])
976                .unwrap(),
977            vec![Map::from_iter([("id".into(), Value::from("first"))])]
978        );
979        assert_eq!(client.local_revision(), 1);
980        assert_eq!(client.drain_change_batches().len(), 1);
981        assert_eq!(client.subs[0].cursor, -1);
982        client
983            .conn
984            .execute_batch("DROP TRIGGER fail_block")
985            .unwrap();
986        assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
987        assert_eq!(client.local_revision(), 3);
988        assert_eq!(client.drain_change_batches().len(), 2);
989    }
990
991    #[test]
992    fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
993        assert_eq!(
994            client().diagnostics_storage().blob_cache_bytes_approx,
995            Some(0)
996        );
997        let client = SyncClient::new(
998            "diagnostic-storage".to_owned(),
999            &json!({
1000                "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
1001                    "columns": [{"name": "id", "type": "string", "nullable": false},
1002                        {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
1003            }),
1004            ClientLimits {
1005                blob_cache_max_bytes: Some(5),
1006                ..Default::default()
1007            },
1008        )
1009        .unwrap();
1010        let empty = client.diagnostics_storage();
1011        assert_eq!(empty.status, "healthy");
1012        assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
1013        assert_eq!(empty.retained_outcome_entries, Some(0));
1014        assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1015        assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1016        client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1017            INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1018                VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1019            INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1020                VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1021        let written = client.diagnostics_storage();
1022        assert_eq!(written.status, "pressure");
1023        assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1024        assert_eq!(written.retained_outcome_entries, Some(2));
1025        assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1026        assert_eq!(written.blob_cache_bytes_approx, Some(8));
1027        assert_eq!(
1028            written.pressure_reason_code.as_deref(),
1029            Some("client.blob_cache_over_limit")
1030        );
1031        client
1032            .conn
1033            .execute_batch(
1034                "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1035            DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1036            )
1037            .unwrap();
1038        let staged = client.diagnostics_storage();
1039        assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1040        assert_eq!(staged.retained_outcome_entries, Some(0));
1041        assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1042        assert_eq!(staged.status, "healthy");
1043        client.conn.execute_batch("ROLLBACK").unwrap();
1044        assert_eq!(client.diagnostics_storage(), written);
1045        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1046        let unreadable = client.diagnostics_storage();
1047        assert_eq!(unreadable.status, "unreadable");
1048        assert!(unreadable.database_bytes_approx.is_none());
1049        assert!(unreadable.pending_outbox_bytes_approx.is_none());
1050        assert!(unreadable.retained_outcome_entries.is_none());
1051        assert!(unreadable.retained_outcome_bytes_approx.is_none());
1052        assert!(unreadable.blob_cache_bytes_approx.is_none());
1053        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1054        let restored = client.diagnostics_storage();
1055        assert_eq!(restored.status, written.status);
1056        assert_eq!(
1057            restored.retained_outcome_bytes_approx,
1058            written.retained_outcome_bytes_approx
1059        );
1060        client.conn.set_prepared_statement_cache_capacity(0);
1061        assert_eq!(client.diagnostics_storage(), restored);
1062    }
1063
1064    /// Answers every round with one server `ERROR` frame (§1.6).
1065    struct ServerErrorTransport {
1066        code: &'static str,
1067        retryable: bool,
1068    }
1069
1070    impl Transport for ServerErrorTransport {
1071        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1072            Ok(encode_message(&Message {
1073                wire_version: WIRE_VERSION,
1074                msg_kind: MsgKind::Response,
1075                frames: vec![
1076                    Frame::RespHeader {
1077                        required_schema_version: None,
1078                        latest_schema_version: None,
1079                        log_epoch: Some("epoch-1".to_owned()),
1080                        reset_required: Some(false),
1081                    },
1082                    Frame::Error {
1083                        code: self.code.to_owned(),
1084                        message: "server error".to_owned(),
1085                        category: "internal".to_owned(),
1086                        retryable: self.retryable,
1087                        recommended_action: "retryLater".to_owned(),
1088                        details: None,
1089                    },
1090                ],
1091            }))
1092        }
1093
1094        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1095            self.sync(request)
1096        }
1097
1098        fn download_segment(
1099            &mut self,
1100            _request: &SegmentRequest,
1101            _on_progress: &mut dyn FnMut(u64),
1102        ) -> Result<Vec<u8>, TransportError> {
1103            Err(TransportError::new("sync.transport_failed", "offline"))
1104        }
1105
1106        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1107            Ok(())
1108        }
1109
1110        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1111            Ok(())
1112        }
1113
1114        fn realtime_close(&mut self) -> Result<(), TransportError> {
1115            Ok(())
1116        }
1117    }
1118
1119    #[test]
1120    fn retryable_server_error_schedules_a_background_retry() {
1121        let mut client = client();
1122        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1123        let mut transport = ServerErrorTransport {
1124            code: "sync.internal_error",
1125            retryable: true,
1126        };
1127        assert!(matches!(
1128            client.sync(&mut transport),
1129            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1130        ));
1131        assert!(matches!(
1132            client.drain_sync_intents().as_slice(),
1133            [SyncIntent::Background { delay_ms: 250 }]
1134        ));
1135        assert_eq!(
1136            client.progress().snapshot().unwrap().retry_delay_ms,
1137            Some(250)
1138        );
1139        let mut transport = ServerErrorTransport {
1140            code: "sync.invalid_request",
1141            retryable: false,
1142        };
1143        assert!(matches!(
1144            client.sync(&mut transport),
1145            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1146        ));
1147        assert!(client.drain_sync_intents().is_empty());
1148        let progress = client.progress().snapshot().unwrap();
1149        assert_eq!(progress.state, ProgressState::Failed);
1150        assert_eq!(progress.retry_delay_ms, None);
1151    }
1152
1153    #[test]
1154    fn failed_progress_retry_delay_doubles_to_the_cap() {
1155        let mut client = client();
1156        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1157        let mut transport = ServerErrorTransport {
1158            code: "sync.internal_error",
1159            retryable: true,
1160        };
1161        let delays: Vec<Option<u64>> = (0..9)
1162            .map(|_| {
1163                client.sync(&mut transport);
1164                client.progress().snapshot().unwrap().retry_delay_ms
1165            })
1166            .collect();
1167        assert_eq!(
1168            delays,
1169            [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
1170        );
1171    }
1172
1173    #[test]
1174    fn background_retry_deadlines_back_off_and_reset() {
1175        let mut client = client();
1176        client.schedule_background_retry();
1177        assert!(matches!(
1178            client.drain_sync_intents().as_slice(),
1179            [SyncIntent::Background { delay_ms: 250 }]
1180        ));
1181        client.schedule_background_retry();
1182        assert!(matches!(
1183            client.drain_sync_intents().as_slice(),
1184            [SyncIntent::Background { delay_ms: 500 }]
1185        ));
1186        client.reset_background_retry();
1187        client.schedule_background_retry();
1188        assert!(matches!(
1189            client.drain_sync_intents().as_slice(),
1190            [SyncIntent::Background { delay_ms: 250 }]
1191        ));
1192    }
1193
1194    #[test]
1195    fn log_epoch_reset_requests_an_immediate_follow_up_round() {
1196        let mut client = client();
1197        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1198        client
1199            .subscribe(
1200                "epoch-tasks".to_owned(),
1201                "tasks".to_owned(),
1202                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1203                None,
1204            )
1205            .expect("subscribe");
1206        client.drain_sync_intents();
1207
1208        client
1209            .run_log_epoch_reset("epoch-2")
1210            .expect("reset partition log epoch");
1211
1212        assert!(matches!(
1213            client.drain_sync_intents().as_slice(),
1214            [SyncIntent::Interactive]
1215        ));
1216    }
1217
1218    #[derive(Default)]
1219    struct CountingRealtimeTransport {
1220        connects: usize,
1221        closes: usize,
1222        messages: Vec<String>,
1223        blob_uploads: Vec<(String, Vec<u8>)>,
1224        blob_download: Option<Vec<u8>>,
1225        sync_calls: usize,
1226        realtime_sync_calls: usize,
1227        refuse_connect: bool,
1228    }
1229
1230    impl Transport for CountingRealtimeTransport {
1231        fn blob_upload(
1232            &mut self,
1233            blob_id: &str,
1234            bytes: &[u8],
1235            _media_type: Option<&str>,
1236        ) -> Result<(), TransportError> {
1237            self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1238            Ok(())
1239        }
1240
1241        fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1242            self.blob_download
1243                .clone()
1244                .map(BlobDownload::Bytes)
1245                .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1246        }
1247
1248        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1249            self.sync_calls += 1;
1250            Err(TransportError::new("sync.transport_failed", "offline"))
1251        }
1252
1253        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1254            self.realtime_sync_calls += 1;
1255            Err(TransportError::new("sync.transport_failed", "offline"))
1256        }
1257
1258        fn download_segment(
1259            &mut self,
1260            _request: &SegmentRequest,
1261            _on_progress: &mut dyn FnMut(u64),
1262        ) -> Result<Vec<u8>, TransportError> {
1263            Err(TransportError::new("sync.transport_failed", "offline"))
1264        }
1265
1266        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1267            self.connects += 1;
1268            if self.refuse_connect {
1269                return Err(TransportError::new("transport.failed", "refused"));
1270            }
1271            Ok(())
1272        }
1273
1274        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1275            self.messages.push(text.to_owned());
1276            Ok(())
1277        }
1278
1279        fn realtime_close(&mut self) -> Result<(), TransportError> {
1280            self.closes += 1;
1281            Ok(())
1282        }
1283    }
1284
1285    #[test]
1286    fn blob_results_own_downloaded_bytes() {
1287        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1288            {"name":"id","type":"string","nullable":false},
1289            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1290        let bytes = b"owned typed result".to_vec();
1291        let blob_id = blob_id_for(&bytes);
1292        let mut transport = CountingRealtimeTransport {
1293            blob_download: Some(bytes.clone()),
1294            ..Default::default()
1295        };
1296        let result = {
1297            let mut client =
1298                SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1299                    .unwrap();
1300            let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1301            assert_eq!(result.blob_id, blob_id);
1302            assert_eq!(result.byte_length, bytes.len() as i64);
1303            assert_eq!(result.bytes, bytes);
1304            assert_eq!(result.media_type, None);
1305            assert_eq!(
1306                client
1307                    .conn
1308                    .query_row(
1309                        "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1310                        rusqlite::params![blob_id],
1311                        |row| row.get::<_, i64>(0),
1312                    )
1313                    .unwrap(),
1314                0
1315            );
1316            result
1317        };
1318        assert_eq!(result.bytes, bytes);
1319
1320        let mut client =
1321            SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1322        assert_eq!(
1323            client
1324                .fetch_blob_bytes(&mut transport, "not json")
1325                .unwrap_err(),
1326            (
1327                "client.failed".to_owned(),
1328                "blob ref is not JSON".to_owned()
1329            )
1330        );
1331    }
1332
1333    #[test]
1334    fn visible_blob_references_survive_cache_trimming() {
1335        let schema = json!({"version":1,"tables":[
1336            {"name":"attachments","primaryKey":"id","columns":[
1337                {"name":"id","type":"string","nullable":false},
1338                {"name":"body","type":"blob_ref","nullable":true},
1339                {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
1340            {"name":"avatars","primaryKey":"id","columns":[
1341                {"name":"id","type":"string","nullable":false},
1342                {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
1343        ]});
1344        let bytes = b"referenced above cap".to_vec();
1345        let blob_id = blob_id_for(&bytes);
1346        let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
1347        let mut client = SyncClient::new(
1348            "visible-blob-refs".to_owned(),
1349            &schema,
1350            ClientLimits {
1351                blob_cache_max_bytes: Some(1),
1352                ..Default::default()
1353            },
1354        )
1355        .unwrap();
1356        client.conn.execute(
1357            "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
1358            rusqlite::params![reference, reference, reference, reference],
1359        ).unwrap();
1360        client
1361            .conn
1362            .execute(
1363                "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
1364                rusqlite::params![reference],
1365            )
1366            .unwrap();
1367        client.conn.execute(
1368            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
1369            [],
1370        ).unwrap();
1371        let mut transport = CountingRealtimeTransport {
1372            blob_download: Some(bytes.clone()),
1373            ..Default::default()
1374        };
1375
1376        assert_eq!(
1377            client
1378                .fetch_blob_bytes(&mut transport, &blob_id)
1379                .unwrap()
1380                .bytes,
1381            bytes
1382        );
1383        let rows = client
1384            .conn
1385            .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1386            .unwrap()
1387            .query_map([], |row| row.get::<_, String>(0))
1388            .unwrap()
1389            .collect::<Result<Vec<_>, _>>()
1390            .unwrap();
1391        assert_eq!(rows, vec![blob_id]);
1392    }
1393
1394    #[test]
1395    fn closed_transport_never_invokes_network_and_reopen_is_host_owned() {
1396        let mut transport = CountingRealtimeTransport::default();
1397        let path = std::env::temp_dir().join(format!(
1398            "syncular-transport-gate-{}.db",
1399            uuid::Uuid::new_v4()
1400        ));
1401        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1402            {"name":"id","type":"string","nullable":false},
1403            {"name":"project_id","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
1404        let mut client = SyncClient::open_path(
1405            "gate".into(),
1406            &schema,
1407            ClientLimits::default(),
1408            path.to_str().unwrap(),
1409        )
1410        .unwrap();
1411        client.connect_realtime(&mut transport).unwrap();
1412        client.set_transport_enabled(&mut transport, false);
1413        assert_eq!(transport.closes, 1);
1414        client.begin_security_preflight();
1415        client.activate_security(Default::default()).unwrap();
1416        client
1417            .subscribe(
1418                "tasks".into(),
1419                "tasks".into(),
1420                vec![("project_id".into(), vec!["p1".into()])],
1421                None,
1422            )
1423            .unwrap();
1424        for id in ["one", "two"] {
1425            client
1426                .mutate(vec![Mutation::Upsert {
1427                    table: "tasks".into(),
1428                    values: Map::from_iter([
1429                        ("id".into(), json!(id)),
1430                        ("project_id".into(), json!("p1")),
1431                    ]),
1432                    base_version: None,
1433                }])
1434                .unwrap();
1435        }
1436        assert_eq!(
1437            client
1438                .query("SELECT id FROM tasks ORDER BY id", &[])
1439                .unwrap()
1440                .len(),
1441            2
1442        );
1443        assert!(
1444            matches!(client.sync(&mut transport), SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
1445        );
1446        assert!(client
1447            .connect_realtime(&mut transport)
1448            .unwrap_err()
1449            .starts_with("sync.offline:"));
1450        assert!(client
1451            .set_presence(&mut transport, "project:p1", None)
1452            .unwrap_err()
1453            .starts_with("sync.offline:"));
1454        assert_eq!(transport.sync_calls, 0);
1455        assert_eq!(transport.realtime_sync_calls, 0);
1456        assert_eq!(transport.connects, 1);
1457        assert!(transport.messages.is_empty());
1458        assert!(matches!(
1459            client.drain_sync_intents().as_slice(),
1460            [SyncIntent::None]
1461        ));
1462        let pending = client.pending_commit_ids();
1463        drop(client);
1464        let mut reopened = SyncClient::open_path(
1465            "gate".into(),
1466            &schema,
1467            ClientLimits::default(),
1468            path.to_str().unwrap(),
1469        )
1470        .unwrap();
1471        assert!(reopened.transport_enabled());
1472        reopened.set_transport_enabled(&mut transport, false);
1473        assert_eq!(reopened.pending_commit_ids(), pending);
1474        assert_eq!(
1475            reopened.query("SELECT id FROM tasks", &[]).unwrap().len(),
1476            2
1477        );
1478        reopened.set_transport_enabled(&mut transport, true);
1479        assert!(matches!(
1480            reopened.drain_sync_intents().as_slice(),
1481            [SyncIntent::Interactive]
1482        ));
1483        reopened.set_transport_enabled(&mut transport, true);
1484        assert!(reopened.drain_sync_intents().is_empty());
1485        drop(reopened);
1486        std::fs::remove_file(path).unwrap();
1487    }
1488
1489    #[test]
1490    fn realtime_connection_ownership_is_idempotent() {
1491        let mut client = client();
1492        let mut transport = CountingRealtimeTransport {
1493            connects: 0,
1494            closes: 0,
1495            ..Default::default()
1496        };
1497        client
1498            .connect_realtime(&mut transport)
1499            .expect("first connect");
1500        client
1501            .connect_realtime(&mut transport)
1502            .expect("idempotent connect");
1503        assert_eq!(transport.connects, 1);
1504        client.disconnect_realtime(&mut transport);
1505        client.disconnect_realtime(&mut transport);
1506        assert_eq!(transport.closes, 1);
1507        client
1508            .connect_realtime(&mut transport)
1509            .expect("deliberate reconnect");
1510        assert_eq!(transport.connects, 2);
1511    }
1512
1513    #[test]
1514    fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
1515        let mut client = client();
1516        client
1517            .set_realtime_policy(RealtimePolicy::Required)
1518            .expect("set policy");
1519        let mut transport = CountingRealtimeTransport::default();
1520        match client.sync(&mut transport) {
1521            SyncOutcome::RealtimeUnavailable {
1522                state,
1523                reason_code,
1524                retry_delay_ms,
1525            } => {
1526                assert_eq!(state, RealtimeState::Disconnected);
1527                assert_eq!(reason_code, None);
1528                assert_eq!(retry_delay_ms, 250);
1529            }
1530            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1531        }
1532        assert_eq!(transport.sync_calls, 0);
1533        assert!(matches!(
1534            client.drain_sync_intents().as_slice(),
1535            [SyncIntent::Background { delay_ms: 250 }]
1536        ));
1537        let snapshot = client
1538            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1539            .expect("diagnostics");
1540        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1541        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
1542        assert_eq!(snapshot.host.realtime_reason_code, None);
1543        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1544        assert_eq!(
1545            snapshot
1546                .last_round
1547                .as_ref()
1548                .and_then(|round| round.error_code.clone()),
1549            Some(REALTIME_UNAVAILABLE_CODE.to_owned())
1550        );
1551    }
1552
1553    #[test]
1554    fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
1555        let mut client = client();
1556        client
1557            .set_realtime_policy(RealtimePolicy::Required)
1558            .expect("set policy");
1559        let mut transport = CountingRealtimeTransport {
1560            refuse_connect: true,
1561            ..Default::default()
1562        };
1563        assert!(client.connect_realtime(&mut transport).is_err());
1564        assert_eq!(client.realtime_state(), RealtimeState::Refused);
1565        let snapshot = client
1566            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1567            .expect("diagnostics");
1568        assert_eq!(
1569            snapshot.host.realtime_reason_code.as_deref(),
1570            Some("transport.failed")
1571        );
1572        match client.sync(&mut transport) {
1573            SyncOutcome::RealtimeUnavailable {
1574                state, reason_code, ..
1575            } => {
1576                assert_eq!(state, RealtimeState::Refused);
1577                assert_eq!(reason_code.as_deref(), Some("transport.failed"));
1578            }
1579            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1580        }
1581        assert_eq!(transport.sync_calls, 0);
1582        assert_eq!(transport.realtime_sync_calls, 0);
1583    }
1584
1585    #[test]
1586    fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
1587        let mut client = client();
1588        client
1589            .set_realtime_policy(RealtimePolicy::Required)
1590            .expect("set policy");
1591        let mut transport = CountingRealtimeTransport::default();
1592        client.connect_realtime(&mut transport).expect("connect");
1593        assert_eq!(client.realtime_state(), RealtimeState::Connected);
1594        assert!(
1595            client.set_realtime_policy(RealtimePolicy::Off).is_err(),
1596            "a policy change cannot silently re-route a connected socket"
1597        );
1598        assert!(matches!(
1599            client.sync(&mut transport),
1600            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
1601        ));
1602        assert_eq!(client.realtime_state(), RealtimeState::Lost);
1603        assert_eq!(transport.realtime_sync_calls, 1);
1604        assert_eq!(transport.sync_calls, 0);
1605        let snapshot = client
1606            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1607            .expect("diagnostics");
1608        assert_eq!(
1609            snapshot.host.realtime_reason_code.as_deref(),
1610            Some("sync.transport_failed")
1611        );
1612        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1613        match client.sync(&mut transport) {
1614            SyncOutcome::RealtimeUnavailable {
1615                state, reason_code, ..
1616            } => {
1617                assert_eq!(state, RealtimeState::Lost);
1618                assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
1619            }
1620            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1621        }
1622        assert_eq!(transport.sync_calls, 0);
1623        client.disconnect_realtime(&mut transport);
1624        assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
1625        assert_eq!(transport.closes, 1);
1626        client.connect_realtime(&mut transport).expect("reconnect");
1627        assert_eq!(client.realtime_state(), RealtimeState::Connected);
1628        assert_eq!(transport.connects, 2);
1629    }
1630
1631    #[test]
1632    fn off_policy_uses_http_and_refuses_an_explicit_connect() {
1633        let mut client = client();
1634        client
1635            .set_realtime_policy(RealtimePolicy::Off)
1636            .expect("set policy");
1637        let mut transport = CountingRealtimeTransport::default();
1638        assert!(client.connect_realtime(&mut transport).is_err());
1639        assert_eq!(client.realtime_state(), RealtimeState::Disabled);
1640        assert!(matches!(
1641            client.sync(&mut transport),
1642            SyncOutcome::Failed { .. }
1643        ));
1644        assert_eq!(transport.sync_calls, 1);
1645        assert_eq!(transport.realtime_sync_calls, 0);
1646        let snapshot = client
1647            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1648            .expect("diagnostics");
1649        assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
1650        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
1651    }
1652
1653    #[test]
1654    fn optional_policy_keeps_the_http_round() {
1655        let mut client = client();
1656        let mut transport = CountingRealtimeTransport::default();
1657        assert!(matches!(
1658            client.sync(&mut transport),
1659            SyncOutcome::Failed { .. }
1660        ));
1661        assert_eq!(transport.sync_calls, 1);
1662        assert_eq!(transport.realtime_sync_calls, 0);
1663        let snapshot = client
1664            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1665            .expect("diagnostics");
1666        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1667        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
1668    }
1669
1670    #[test]
1671    fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1672        let mut client = client();
1673        client
1674            .subscribe(
1675                "repair-tasks".to_owned(),
1676                "tasks".to_owned(),
1677                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1678                None,
1679            )
1680            .expect("subscribe");
1681        {
1682            let sub = client
1683                .subs
1684                .iter_mut()
1685                .find(|sub| sub.id == "repair-tasks")
1686                .expect("subscription");
1687            sub.cursor = 42;
1688            sub.synced_once = true;
1689            let persisted = sub.clone();
1690            client.persist_sub(&persisted).unwrap();
1691        }
1692        for table in ["_syncular_base_tasks", "tasks"] {
1693            client
1694                .conn
1695                .execute(
1696                    &format!(
1697                        "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1698                    ),
1699                    rusqlite::params!["server-row", "p1"],
1700                )
1701                .expect("seed server row");
1702        }
1703        let pending = client
1704            .mutate(vec![Mutation::Upsert {
1705                table: "tasks".to_owned(),
1706                values: Map::from_iter([
1707                    ("id".to_owned(), Value::from("offline-row")),
1708                    ("project_id".to_owned(), Value::from("p1")),
1709                ]),
1710                base_version: None,
1711            }])
1712            .expect("queue offline work");
1713        client.drain_change_batches();
1714        client.drain_sync_intents();
1715
1716        assert_eq!(
1717            client
1718                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1719                    rebootstrap_id: "support-case-001".to_owned(),
1720                })
1721                .expect("rebootstrap"),
1722            LocalDataRebootstrapResult {
1723                already_applied: false,
1724                retained_commits: 1,
1725                reset_subscriptions: 1,
1726            }
1727        );
1728        let visible_ids = client
1729            .conn
1730            .prepare("SELECT id FROM tasks ORDER BY id")
1731            .expect("prepare visible ids")
1732            .query_map([], |row| row.get::<_, String>(0))
1733            .expect("query visible ids")
1734            .collect::<Result<Vec<_>, _>>()
1735            .expect("collect visible ids");
1736        assert_eq!(visible_ids, vec!["offline-row"]);
1737        assert_eq!(client.pending_commit_ids(), vec![pending]);
1738        assert_eq!(
1739            client
1740                .subscription_state("repair-tasks")
1741                .expect("subscription")
1742                .cursor,
1743            -1
1744        );
1745        assert!(client.upgrading());
1746        assert!(client.sync_needed());
1747        assert!(matches!(
1748            client.drain_sync_intents().as_slice(),
1749            [SyncIntent::Interactive]
1750        ));
1751        assert_eq!(client.drain_change_batches().len(), 1);
1752
1753        assert_eq!(
1754            client
1755                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1756                    rebootstrap_id: "support-case-001".to_owned(),
1757                })
1758                .expect("idempotent retry"),
1759            LocalDataRebootstrapResult {
1760                already_applied: true,
1761                retained_commits: 1,
1762                reset_subscriptions: 1,
1763            }
1764        );
1765        assert!(client.drain_change_batches().is_empty());
1766    }
1767
1768    #[test]
1769    fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1770        let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1771        assert_eq!(
1772            decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1773            (3, 4)
1774        );
1775        assert_eq!(
1776            decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1777            (0, 0)
1778        );
1779        for malformed in [
1780            "",
1781            "{}",
1782            r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1783            r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1784            r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1785        ] {
1786            assert_eq!(
1787                decode_local_rebootstrap_receipt(malformed)
1788                    .expect_err("malformed receipt must fail"),
1789                "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1790            );
1791        }
1792    }
1793
1794    #[test]
1795    fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1796        let path = std::env::temp_dir().join(format!(
1797            "syncular-rebootstrap-receipt-{}.db",
1798            uuid::Uuid::new_v4()
1799        ));
1800        let schema = json!({
1801            "version": 1,
1802            "tables": [{
1803                "name": "tasks",
1804                "primaryKey": "id",
1805                "columns": [
1806                    { "name": "id", "type": "string", "nullable": false },
1807                    { "name": "project_id", "type": "string", "nullable": false }
1808                ],
1809                "scopes": [{ "pattern": "project:{project_id}" }]
1810            }]
1811        });
1812        let path_string = path.to_str().expect("UTF-8 temp path");
1813
1814        {
1815            let mut first = SyncClient::open_path(
1816                "repair-restart-client".to_owned(),
1817                &schema,
1818                ClientLimits::default(),
1819                path_string,
1820            )
1821            .expect("first open");
1822            first
1823                .subscribe(
1824                    "repair-tasks".to_owned(),
1825                    "tasks".to_owned(),
1826                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1827                    None,
1828                )
1829                .expect("subscribe");
1830            first
1831                .mutate(vec![Mutation::Upsert {
1832                    table: "tasks".to_owned(),
1833                    values: Map::from_iter([
1834                        ("id".to_owned(), Value::from("offline-row")),
1835                        ("project_id".to_owned(), Value::from("p1")),
1836                    ]),
1837                    base_version: None,
1838                }])
1839                .expect("queue offline work");
1840            assert_eq!(
1841                first
1842                    .rebootstrap_local_data(&LocalDataRebootstrapInput {
1843                        rebootstrap_id: "restart-receipt".to_owned(),
1844                    })
1845                    .expect("first rebootstrap"),
1846                LocalDataRebootstrapResult {
1847                    already_applied: false,
1848                    retained_commits: 1,
1849                    reset_subscriptions: 1,
1850                }
1851            );
1852        }
1853
1854        let mut reopened = SyncClient::open_path(
1855            "repair-restart-client".to_owned(),
1856            &schema,
1857            ClientLimits::default(),
1858            path_string,
1859        )
1860        .expect("reopen");
1861        reopened.drain_change_batches();
1862        reopened.drain_sync_intents();
1863        assert_eq!(
1864            reopened
1865                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1866                    rebootstrap_id: "restart-receipt".to_owned(),
1867                })
1868                .expect("receipt replay"),
1869            LocalDataRebootstrapResult {
1870                already_applied: true,
1871                retained_commits: 1,
1872                reset_subscriptions: 1,
1873            }
1874        );
1875        assert!(reopened.drain_change_batches().is_empty());
1876        assert!(reopened.drain_sync_intents().is_empty());
1877        drop(reopened);
1878        std::fs::remove_file(path).expect("remove temp database");
1879    }
1880
1881    #[test]
1882    fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1883        let mut malformed = client();
1884        malformed
1885            .subscribe(
1886                "repair-tasks".to_owned(),
1887                "tasks".to_owned(),
1888                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1889                None,
1890            )
1891            .expect("subscribe");
1892        malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1893        malformed.drain_change_batches();
1894        malformed.drain_sync_intents();
1895        let malformed_error = malformed
1896            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1897                rebootstrap_id: "malformed".to_owned(),
1898            })
1899            .expect_err("malformed receipt must fail closed");
1900        assert_eq!(
1901            malformed_error,
1902            "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1903        );
1904        assert!(!malformed.upgrading());
1905        assert_eq!(
1906            malformed
1907                .subscription_state("repair-tasks")
1908                .expect("unchanged subscription")
1909                .cursor,
1910            -1
1911        );
1912        assert!(malformed.drain_change_batches().is_empty());
1913        assert!(malformed.drain_sync_intents().is_empty());
1914
1915        let mut unreadable = client();
1916        unreadable
1917            .conn
1918            .execute(
1919                "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1920                rusqlite::params!["server-row", "p1"],
1921            )
1922            .expect("seed visible row");
1923        unreadable
1924            .conn
1925            .execute("DROP TABLE _syncular_meta", [])
1926            .expect("break marker storage");
1927        let unreadable_error = unreadable
1928            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1929                rebootstrap_id: "unreadable".to_owned(),
1930            })
1931            .expect_err("unreadable marker storage must fail closed");
1932        assert_eq!(
1933            unreadable_error,
1934            "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1935        );
1936        let visible_rows = unreadable
1937            .conn
1938            .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1939            .expect("visible projection remains");
1940        assert_eq!(visible_rows, 1);
1941        assert!(!unreadable.upgrading());
1942        assert!(unreadable.drain_change_batches().is_empty());
1943        assert!(unreadable.drain_sync_intents().is_empty());
1944    }
1945
1946    #[test]
1947    fn local_rebootstrap_cannot_bypass_schema_floor() {
1948        let mut client = client();
1949        client.set_schema_floor(Some(SchemaFloor {
1950            required_schema_version: Some(2),
1951            latest_schema_version: Some(2),
1952        }));
1953        let error = client
1954            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1955                rebootstrap_id: "blocked-floor".to_owned(),
1956            })
1957            .expect_err("schema floor must block repair");
1958        assert!(error.contains("cannot bypass an active schema-floor stop"));
1959    }
1960
1961    #[test]
1962    fn diego_applied_sparse_moves_stay_visible_until_pull_image_arrives() {
1963        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1964            "columns":[{"name":"id","type":"string","nullable":false},
1965                {"name":"project_id","type":"string","nullable":false},
1966                {"name":"starts_at_ms","type":"integer","nullable":false},
1967                {"name":"version","type":"integer","nullable":false}],
1968            "scopes":[{"pattern":"project:{project_id}"}]}]});
1969        let mut client =
1970            SyncClient::new("diego-ack-gap".into(), &schema, ClientLimits::default()).unwrap();
1971        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1972        client
1973            .write_base_row(
1974                "tasks",
1975                &vec![
1976                    Some(ColumnValue::String("t1".into())),
1977                    Some(ColumnValue::String("p1".into())),
1978                    Some(ColumnValue::Integer(100)),
1979                    Some(ColumnValue::Integer(1)),
1980                ],
1981                1,
1982            )
1983            .unwrap();
1984        client.rebuild_overlay();
1985        let first = client
1986            .patch(
1987                "tasks",
1988                "t1",
1989                Map::from_iter([
1990                    ("starts_at_ms".into(), json!(200)),
1991                    ("version".into(), json!(2)),
1992                ]),
1993                None,
1994            )
1995            .unwrap();
1996        let (_, first_request) = client.build_request(false);
1997        // This second edit happens while the first request is in flight.
1998        let second = client
1999            .patch(
2000                "tasks",
2001                "t1",
2002                Map::from_iter([
2003                    ("starts_at_ms".into(), json!(300)),
2004                    ("version".into(), json!(3)),
2005                ]),
2006                None,
2007            )
2008            .unwrap();
2009        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2010        for (id, seq, meta) in [
2011            (first, 2, first_request),
2012            (second, 3, client.build_request(false).1),
2013        ] {
2014            let response = Message {
2015                wire_version: WIRE_VERSION,
2016                msg_kind: MsgKind::Response,
2017                frames: vec![
2018                    Frame::RespHeader {
2019                        required_schema_version: None,
2020                        latest_schema_version: None,
2021                        log_epoch: Some("epoch-1".into()),
2022                        reset_required: Some(false),
2023                    },
2024                    Frame::PushResult {
2025                        client_commit_id: id,
2026                        status: PushStatus::Applied,
2027                        commit_seq: Some(seq),
2028                        results: vec![OpResult::Applied { op_index: 0 }],
2029                    },
2030                ],
2031            };
2032            assert!(matches!(
2033                client.process_response(&mut transport, response, &meta),
2034                SyncOutcome::Ok(_)
2035            ));
2036            let row = &client
2037                .query("SELECT starts_at_ms, version FROM tasks WHERE id='t1'", &[])
2038                .unwrap()[0];
2039            assert_eq!(
2040                row["starts_at_ms"],
2041                json!(300),
2042                "an applied ACK without a pull image must not expose the old base"
2043            );
2044            assert_eq!(
2045                row["version"],
2046                json!(3),
2047                "the queued domain version must remain monotonic"
2048            );
2049        }
2050    }
2051
2052    #[test]
2053    fn first_epoch_acquisition_preserves_tables_subscriptions_and_local_intent() {
2054        for stored in [None, Some("epoch-old")] {
2055            let mut client = client();
2056            client
2057                .subscribe(
2058                    "tasks".into(),
2059                    "tasks".into(),
2060                    vec![("project_id".into(), vec!["p1".into()])],
2061                    None,
2062                )
2063                .unwrap();
2064            client
2065                .conn
2066                .execute_batch(
2067                    "CREATE TRIGGER epoch_table_identity AFTER INSERT ON tasks BEGIN SELECT 1; END",
2068                )
2069                .unwrap();
2070            let id = client
2071                .mutate(vec![Mutation::Upsert {
2072                    table: "tasks".into(),
2073                    values: Map::from_iter([
2074                        ("id".into(), json!("offline")),
2075                        ("project_id".into(), json!("p1")),
2076                    ]),
2077                    base_version: None,
2078                }])
2079                .unwrap();
2080            if let Some(epoch) = stored {
2081                client.set_meta(LOG_EPOCH_KEY, epoch);
2082            }
2083            let before = serde_json::to_value(client.subscription_state("tasks")).unwrap();
2084            let (_, meta) = client.build_request(false);
2085            client.drain_change_batches();
2086            client.drain_sync_intents();
2087            let response = Message {
2088                wire_version: WIRE_VERSION,
2089                msg_kind: MsgKind::Response,
2090                frames: vec![Frame::RespHeader {
2091                    required_schema_version: None,
2092                    latest_schema_version: None,
2093                    log_epoch: Some("epoch-new".into()),
2094                    reset_required: Some(true),
2095                }],
2096            };
2097            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2098            let outcome = client.process_response(&mut transport, response, &meta);
2099            let SyncOutcome::Ok(report) = outcome else {
2100                panic!("epoch round failed: {outcome:?}");
2101            };
2102            assert_eq!(
2103                report.resets,
2104                if stored.is_none() {
2105                    Vec::<String>::new()
2106                } else {
2107                    vec!["tasks".into()]
2108                }
2109            );
2110            assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
2111            assert_eq!(client.pending_commit_ids(), vec![id]);
2112            assert_eq!(
2113                client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
2114                json!("offline")
2115            );
2116            let trigger: i64 = client
2117                .conn
2118                .query_row(
2119                    "SELECT count(*) FROM sqlite_master WHERE name='epoch_table_identity'",
2120                    [],
2121                    |row| row.get(0),
2122                )
2123                .unwrap();
2124            if stored.is_none() {
2125                assert!(!client.upgrading);
2126                assert!(client
2127                    .drain_change_batches()
2128                    .iter()
2129                    .all(|batch| batch.status.as_ref().is_none_or(|status| !status.upgrading)));
2130                assert_eq!(
2131                    serde_json::to_value(client.subscription_state("tasks")).unwrap(),
2132                    before
2133                );
2134                assert_eq!(trigger, 1, "acquisition must not drop the synced table");
2135            } else {
2136                assert!(client.upgrading);
2137                assert_eq!(trigger, 0, "an epoch change must drop the old table");
2138            }
2139            assert!(matches!(
2140                client.drain_sync_intents().as_slice(),
2141                [SyncIntent::Interactive]
2142            ));
2143        }
2144    }
2145
2146    #[test]
2147    fn epoch_acquisition_storage_failures_preserve_ready_state_and_intent() {
2148        for key in ["logEpoch", "localRevision"] {
2149            let mut client = client();
2150            let id = client
2151                .mutate(vec![Mutation::Upsert {
2152                    table: "tasks".into(),
2153                    values: Map::from_iter([
2154                        ("id".into(), json!("offline")),
2155                        ("project_id".into(), json!("p1")),
2156                    ]),
2157                    base_version: None,
2158                }])
2159                .unwrap();
2160            let revision = client.local_revision();
2161            let needed = client.sync_needed;
2162            client.drain_change_batches();
2163            client.drain_sync_intents();
2164            client.conn.execute_batch(&format!("CREATE TRIGGER fail_acquisition BEFORE INSERT ON _syncular_meta WHEN NEW.key='{key}' BEGIN SELECT RAISE(FAIL,'injected acquisition failure'); END")).unwrap();
2165            assert!(client.run_log_epoch_reset("epoch-new").is_err());
2166            assert!(client.get_meta(LOG_EPOCH_KEY).is_none());
2167            assert!(!client.upgrading);
2168            assert_eq!(client.sync_needed, needed);
2169            assert_eq!(client.local_revision(), revision);
2170            assert_eq!(client.pending_commit_ids(), [id]);
2171            assert!(client.drain_change_batches().is_empty());
2172            assert!(client.drain_sync_intents().is_empty());
2173        }
2174    }
2175
2176    #[test]
2177    fn acknowledged_restore_and_replay_ignore_unrelated_subscription_count() {
2178        for subscriptions in [0, 2, 6, 12] {
2179            let mut client = SyncClient::new("ack-scope".into(), &json!({ "version": 1, "tables": [
2180                { "name": "tasks", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] },
2181                { "name": "docs", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] }
2182            ] }), ClientLimits::default()).unwrap();
2183            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2184            client.conn.execute_batch("CREATE TABLE ack_writes(kind TEXT); CREATE TRIGGER count_ack_restore AFTER DELETE ON tasks BEGIN INSERT INTO ack_writes VALUES('restore'); END; CREATE TRIGGER count_ack_replay AFTER INSERT ON tasks BEGIN INSERT INTO ack_writes VALUES('replay'); END;").unwrap();
2185            for index in 0..32 {
2186                client
2187                    .mutate(vec![Mutation::Upsert {
2188                        table: "tasks".into(),
2189                        values: Map::from_iter([
2190                            ("id".into(), json!(format!("task-{index}"))),
2191                            ("project_id".into(), json!("p1")),
2192                        ]),
2193                        base_version: None,
2194                    }])
2195                    .unwrap();
2196            }
2197            let (_, meta) = client.build_request(false);
2198            let mut frames = vec![Frame::RespHeader {
2199                required_schema_version: None,
2200                latest_schema_version: None,
2201                log_epoch: Some("epoch-1".into()),
2202                reset_required: Some(false),
2203            }];
2204            frames.extend(meta.pushed_ids.iter().enumerate().map(|(index, id)| {
2205                Frame::PushResult {
2206                    client_commit_id: id.clone(),
2207                    status: PushStatus::Applied,
2208                    commit_seq: Some(index as i64 + 1),
2209                    results: vec![OpResult::Applied { op_index: 0 }],
2210                }
2211            }));
2212            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2213            client.acknowledged_replay_count.set(0);
2214            client.conn.execute("DELETE FROM ack_writes", []).unwrap();
2215            assert!(matches!(
2216                client.process_response(
2217                    &mut transport,
2218                    Message {
2219                        wire_version: WIRE_VERSION,
2220                        msg_kind: MsgKind::Response,
2221                        frames
2222                    },
2223                    &meta
2224                ),
2225                SyncOutcome::Ok(_)
2226            ));
2227            assert_eq!(client.acknowledged_replay_count.get(), 32);
2228            assert_eq!(
2229                client
2230                    .query(
2231                        "SELECT count(*) AS n FROM ack_writes WHERE kind='restore'",
2232                        &[]
2233                    )
2234                    .unwrap()[0]["n"],
2235                json!(32)
2236            );
2237            assert_eq!(
2238                client
2239                    .query(
2240                        "SELECT count(*) AS n FROM ack_writes WHERE kind='replay'",
2241                        &[]
2242                    )
2243                    .unwrap()[0]["n"],
2244                json!(32)
2245            );
2246            client.conn.execute("DELETE FROM ack_writes", []).unwrap();
2247            client.acknowledged_replay_count.set(0);
2248            for index in 0..subscriptions {
2249                client
2250                    .subscribe(
2251                        format!("docs-{index}"),
2252                        "docs".into(),
2253                        vec![("project_id".into(), vec![format!("scope-{index}")])],
2254                        None,
2255                    )
2256                    .unwrap();
2257                let sub = client.subs.len() - 1;
2258                client.subs[sub].effective =
2259                    Some(vec![("project_id".into(), vec![format!("scope-{index}")])]);
2260                let segment = RowsSegment {
2261                    table: "docs".into(),
2262                    schema_version: 1,
2263                    columns: client.schema.table("docs").unwrap().wire_columns.clone(),
2264                    blocks: if index % 2 == 0 {
2265                        vec![vec![ssp2::segment::SegmentRow {
2266                            values: vec![
2267                                Some(ColumnValue::String(format!("doc-{index}"))),
2268                                Some(ColumnValue::String(format!("scope-{index}"))),
2269                            ],
2270                            server_version: 1,
2271                        }]]
2272                    } else {
2273                        vec![]
2274                    },
2275                };
2276                assert!(client.apply_segment(sub, &segment, true).is_ok());
2277            }
2278            assert_eq!(
2279                client
2280                    .query("SELECT count(*) AS n FROM ack_writes", &[])
2281                    .unwrap()[0]["n"],
2282                json!(0),
2283                "unrelated bootstrap must not restore/replay tasks"
2284            );
2285            assert_eq!(
2286                client.acknowledged_replay_count.get(),
2287                0,
2288                "unrelated bootstrap must not decode task ACKs"
2289            );
2290            assert_eq!(
2291                client
2292                    .query("SELECT count(*) AS n FROM tasks", &[])
2293                    .unwrap()[0]["n"],
2294                json!(32)
2295            );
2296            assert_eq!(
2297                client
2298                    .query("SELECT count(*) AS n FROM _syncular_acked_rows", &[])
2299                    .unwrap()[0]["n"],
2300                json!(32)
2301            );
2302        }
2303    }
2304
2305    #[test]
2306    fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
2307        let mut client = client();
2308        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2309        const COMMIT_COUNT: usize = 32;
2310
2311        for index in 0..COMMIT_COUNT {
2312            client
2313                .mutate(vec![Mutation::Upsert {
2314                    table: "tasks".to_owned(),
2315                    values: Map::from_iter([
2316                        ("id".to_owned(), Value::from(format!("task-{index}"))),
2317                        ("project_id".to_owned(), Value::from("project-1")),
2318                    ]),
2319                    base_version: None,
2320                }])
2321                .expect("queue commit");
2322        }
2323
2324        let (_, request_meta) = client.build_request(false);
2325        assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
2326        let mut frames = vec![Frame::RespHeader {
2327            required_schema_version: None,
2328            latest_schema_version: None,
2329            log_epoch: Some("epoch-1".to_owned()),
2330            reset_required: Some(false),
2331        }];
2332        frames.extend(request_meta.pushed_ids.iter().enumerate().map(
2333            |(index, client_commit_id)| Frame::PushResult {
2334                client_commit_id: client_commit_id.clone(),
2335                status: PushStatus::Applied,
2336                commit_seq: Some(index as i64 + 1),
2337                results: vec![OpResult::Applied { op_index: 0 }],
2338            },
2339        ));
2340        let response = Message {
2341            wire_version: WIRE_VERSION,
2342            msg_kind: MsgKind::Response,
2343            frames,
2344        };
2345        let mut transport =
2346            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
2347
2348        client.overlay_rebuild_count.set(0);
2349        client.outcome_prune_count.set(0);
2350        let outcome = client.process_response(&mut transport, response, &request_meta);
2351        assert!(matches!(outcome, SyncOutcome::Ok(_)));
2352        assert!(client.pending_commit_ids().is_empty());
2353        assert_eq!(
2354            client.overlay_rebuild_count.get(),
2355            1,
2356            "one response must reconcile its acknowledged commits with one overlay rebuild"
2357        );
2358        assert_eq!(
2359            client.outcome_prune_count.get(),
2360            1,
2361            "one response must enforce outcome retention once"
2362        );
2363    }
2364
2365    #[test]
2366    fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
2367        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
2368            "columns": [
2369                {"name":"id", "type":"string", "nullable":false},
2370                {"name":"project_id", "type":"string", "nullable":false},
2371                {"name":"title", "type":"string", "nullable":false}
2372            ], "scopes": [{"pattern":"project:{project_id}"}],
2373            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2374            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
2375        }] });
2376        let mut incremental =
2377            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
2378        let mut reference =
2379            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
2380        let tables = vec!["tasks".to_owned()];
2381        for index in 0..32 {
2382            let id = format!("task-{}", index % 8);
2383            let values = Map::from_iter([
2384                ("id".to_owned(), Value::from(id.clone())),
2385                (
2386                    "project_id".to_owned(),
2387                    Value::from(format!("p{}", index % 2)),
2388                ),
2389                ("title".to_owned(), Value::from(format!("needle{index}"))),
2390            ]);
2391            let payload = encode_row_json(
2392                incremental.schema.table("tasks").unwrap(),
2393                &id,
2394                &values,
2395                &incremental.encryption,
2396            )
2397            .unwrap();
2398            let change = ssp2::model::Change {
2399                table_index: 0,
2400                row_id: id,
2401                op: if index % 5 == 0 {
2402                    Op::Delete
2403                } else {
2404                    Op::Upsert
2405                },
2406                row_version: Some(index + 1),
2407                scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
2408                row: Some(payload),
2409            };
2410            reference.overlay_dirty.set(true);
2411            for instance in [&mut incremental, &mut reference] {
2412                instance.begin_observation("mirror_test").unwrap();
2413                let mut batch = ChangeAccumulator::default();
2414                instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
2415                instance
2416                    .apply_commit_changes(&tables, std::slice::from_ref(&change))
2417                    .unwrap();
2418                instance.rebuild_overlay_if_dirty();
2419                instance.finish_observation("mirror_test", batch).unwrap();
2420            }
2421            for sql in [
2422                "SELECT * FROM tasks ORDER BY id",
2423                "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2424            ] {
2425                assert_eq!(
2426                    incremental.query(sql, &[]).unwrap(),
2427                    reference.query(sql, &[]).unwrap()
2428                );
2429            }
2430            assert_eq!(incremental.local_revision(), reference.local_revision());
2431            assert_eq!(
2432                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2433                serde_json::to_value(reference.drain_change_batches()).unwrap()
2434            );
2435        }
2436        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2437        assert_eq!(reference.overlay_rebuild_count.get(), 32);
2438    }
2439
2440    #[test]
2441    fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
2442        let cases = [
2443            (
2444                "string",
2445                vec![
2446                    json!("1"),
2447                    json!("01"),
2448                    json!(""),
2449                    json!("nul\u{0}id"),
2450                    json!("é"),
2451                ],
2452            ),
2453            (
2454                "integer",
2455                vec![
2456                    json!(i64::MIN),
2457                    json!(-1),
2458                    json!(0),
2459                    json!(1),
2460                    json!(i64::MAX),
2461                ],
2462            ),
2463            ("boolean", vec![json!(false), json!(true)]),
2464            ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
2465        ];
2466        for (kind, ids) in cases {
2467            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2468                "columns":[{"name":"id","type":kind,"nullable":false},
2469                    {"name":"project_id","type":"string","nullable":false}],
2470                "scopes":[{"pattern":"project:{project_id}"}]}]});
2471            let mut client =
2472                SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
2473            for (index, id) in ids.iter().enumerate() {
2474                let table = client.schema.table("tasks").unwrap();
2475                client
2476                    .write_base_row(
2477                        "tasks",
2478                        &vec![
2479                            json_to_column_value(&table.columns[0], Some(id)).unwrap(),
2480                            Some(ColumnValue::String(format!("p{index}"))),
2481                        ],
2482                        1,
2483                    )
2484                    .unwrap();
2485            }
2486            client.rebuild_overlay_if_dirty();
2487            for base in [true, false] {
2488                let full = if base {
2489                    base_table("tasks")
2490                } else {
2491                    visible_table("tasks")
2492                };
2493                let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
2494                for row_id in [
2495                    "1",
2496                    "01",
2497                    "1.0",
2498                    "1.0000000000000002",
2499                    "+1",
2500                    "1e0",
2501                    " 1",
2502                    "-1",
2503                    "0",
2504                    "9223372036854775807",
2505                    "-9223372036854775808",
2506                    "true",
2507                    "false",
2508                    "",
2509                    "nul\u{0}id",
2510                    "é",
2511                    "null",
2512                    "{\"a\":1}",
2513                ] {
2514                    let old_sql =
2515                        format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
2516                    let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
2517                    let old = client
2518                        .conn
2519                        .prepare(&old_sql)
2520                        .unwrap()
2521                        .query_map([row_id], |row| row.get::<_, String>(0))
2522                        .unwrap()
2523                        .collect::<Result<Vec<_>, _>>()
2524                        .unwrap();
2525                    let new = client
2526                        .conn
2527                        .prepare_cached(&new_sql)
2528                        .unwrap()
2529                        .query_map([row_id], |row| row.get::<_, String>(0))
2530                        .unwrap()
2531                        .collect::<Result<Vec<_>, _>>()
2532                        .unwrap();
2533                    assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
2534                    let mut batch = ChangeAccumulator::default();
2535                    assert_eq!(
2536                        client.record_row_scopes(&mut batch, "tasks", row_id, base),
2537                        !old.is_empty()
2538                    );
2539                    let expected = old.first().map(|scope| {
2540                        BTreeMap::from([(
2541                            "tasks".to_owned(),
2542                            Some(BTreeSet::from([format!("project:{scope}")])),
2543                        )])
2544                    });
2545                    assert_eq!(batch.tables, expected.unwrap_or_default());
2546                    client
2547                        .conn
2548                        .execute_batch("SAVEPOINT lookup_delete")
2549                        .unwrap();
2550                    if base {
2551                        client.delete_base_row("tasks", row_id).unwrap();
2552                    } else {
2553                        client.apply_outbox_ops(&[OutboxOp {
2554                            upsert: false,
2555                            table: "tasks".into(),
2556                            row_id: row_id.into(),
2557                            base_version: None,
2558                            values: None,
2559                        }]);
2560                    }
2561                    let remaining: i64 = client
2562                        .conn
2563                        .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
2564                            row.get(0)
2565                        })
2566                        .unwrap();
2567                    assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
2568                    client
2569                        .conn
2570                        .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
2571                        .unwrap();
2572                    client.overlay_dirty.set(false);
2573                }
2574                for (predicate, expected) in [
2575                    ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
2576                    (predicate, "SEARCH"),
2577                ] {
2578                    let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
2579                    let details = client
2580                        .conn
2581                        .prepare(&sql)
2582                        .unwrap()
2583                        .query_map(["1"], |row| row.get::<_, String>(3))
2584                        .unwrap()
2585                        .collect::<Result<Vec<_>, _>>()
2586                        .unwrap();
2587                    assert!(
2588                        details.iter().any(|detail| detail.contains(expected)),
2589                        "{kind}: {details:?}"
2590                    );
2591                }
2592            }
2593        }
2594    }
2595
2596    #[test]
2597    fn primary_keys_reject_every_ineligible_column_type() {
2598        // §2.4: a `rowId` is a string, so a primary key type whose string form
2599        // differs per renderer, or cannot be reproduced by local storage
2600        // comparison, is a schema error rather than a silent lookup miss.
2601        for ty in ["float", "bytes", "crdt", "blob_ref"] {
2602            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2603                "columns":[{"name":"id","type":ty,"nullable":false},
2604                    {"name":"project_id","type":"string","nullable":false}],
2605                "scopes":[{"pattern":"project:{project_id}"}]}]});
2606            let error =
2607                match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
2608                    Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
2609                    Err(error) => error,
2610                };
2611            assert!(
2612                error.contains("has an ineligible column type (§2.4)"),
2613                "{ty}: {error}"
2614            );
2615        }
2616        for ty in ["string", "integer", "boolean", "json"] {
2617            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2618                "columns":[{"name":"id","type":ty,"nullable":false},
2619                    {"name":"project_id","type":"string","nullable":false}],
2620                "scopes":[{"pattern":"project:{project_id}"}]}]});
2621            SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
2622                .unwrap_or_else(|error| panic!("{ty}: {error}"));
2623        }
2624    }
2625
2626    #[test]
2627    fn pending_row_reconciliation_matches_full_fifo_replay() {
2628        for unique in [false, true] {
2629            let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
2630                "name": name, "primaryKey": "id", "columns": [
2631                    {"name":"id", "type":"string", "nullable":false},
2632                    {"name":"project_id", "type":"string", "nullable":false},
2633                    {"name":"title", "type":"string", "nullable":false}],
2634                "scopes": [{"pattern":"project:{project_id}"}],
2635                "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
2636                "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
2637            })))});
2638            let mut incremental =
2639                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2640            let mut reference =
2641                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2642            let tables = vec!["tasks".to_owned(), "notes".to_owned()];
2643            for instance in [&mut incremental, &mut reference] {
2644                for table in &tables {
2645                    for index in 0..12 {
2646                        instance
2647                            .write_base_row(
2648                                table,
2649                                &vec![
2650                                    Some(ColumnValue::String(format!("row{index}"))),
2651                                    Some(ColumnValue::String("p0".into())),
2652                                    Some(ColumnValue::String(format!("base{index}"))),
2653                                ],
2654                                1,
2655                            )
2656                            .unwrap();
2657                    }
2658                }
2659                instance.rebuild_overlay_if_dirty();
2660            }
2661            for index in 0..80 {
2662                let mutations = (0..3)
2663                    .map(|offset| {
2664                        let table = tables[(index + offset) % 2].clone();
2665                        let id = format!("row{}", (index + offset) % 16);
2666                        if (index + offset) % 7 == 0 {
2667                            Mutation::Delete {
2668                                table,
2669                                row_id: id,
2670                                base_version: None,
2671                            }
2672                        } else {
2673                            Mutation::Upsert {
2674                                table,
2675                                values: Map::from_iter([
2676                                    ("id".into(), Value::from(id)),
2677                                    ("project_id".into(), Value::from(format!("p{}", index % 3))),
2678                                    (
2679                                        "title".into(),
2680                                        Value::from(format!("needle{}", (index + offset) % 16)),
2681                                    ),
2682                                ]),
2683                                base_version: None,
2684                            }
2685                        }
2686                    })
2687                    .collect::<Vec<_>>();
2688                for instance in [&mut incremental, &mut reference] {
2689                    instance.mutate(mutations.clone()).unwrap();
2690                }
2691                // Most frames touch only the table without unique constraints.
2692                // Mixed frames must retain full replay for both tables.
2693                let changes = (0..3)
2694                    .map(|offset| {
2695                        let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
2696                        let id = format!("row{}", (index + offset) % 16);
2697                        let values = Map::from_iter([
2698                            ("id".into(), Value::from(id.clone())),
2699                            (
2700                                "project_id".into(),
2701                                Value::from(format!("p{}", (index + 1) % 3)),
2702                            ),
2703                            (
2704                                "title".into(),
2705                                Value::from(format!("remote{index}_{offset}")),
2706                            ),
2707                        ]);
2708                        ssp2::model::Change {
2709                            table_index: table_index as u16,
2710                            row_id: id.clone(),
2711                            op: if (index + offset) % 5 == 0 {
2712                                Op::Delete
2713                            } else {
2714                                Op::Upsert
2715                            },
2716                            row_version: Some(index as i64 + 2),
2717                            scopes: vec![],
2718                            row: Some(
2719                                encode_row_json(
2720                                    incremental.schema.table(&tables[table_index]).unwrap(),
2721                                    &id,
2722                                    &values,
2723                                    &incremental.encryption,
2724                                )
2725                                .unwrap(),
2726                            ),
2727                        }
2728                    })
2729                    .collect::<Vec<_>>();
2730                reference.overlay_dirty.set(true);
2731                incremental
2732                    .apply_commit_frame(&tables, &changes, None)
2733                    .unwrap();
2734                reference
2735                    .apply_commit_frame(&tables, &changes, None)
2736                    .unwrap();
2737                for table in &tables {
2738                    for sql in [format!("SELECT * FROM {table} ORDER BY id"),
2739                        format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
2740                        format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
2741                        format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
2742                        assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
2743                    }
2744                }
2745                assert_eq!(incremental.local_revision(), reference.local_revision());
2746                assert_eq!(
2747                    serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2748                    serde_json::to_value(reference.drain_change_batches()).unwrap()
2749                );
2750            }
2751            assert_eq!(
2752                incremental.overlay_rebuild_count.get(),
2753                if unique { 81 } else { 21 }
2754            );
2755            assert_eq!(reference.overlay_rebuild_count.get(), 81);
2756        }
2757    }
2758
2759    #[test]
2760    fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
2761        let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
2762            "columns":[{"name":"id", "type":"string", "nullable":false},
2763                {"name":"title", "type":"string", "nullable":false}],
2764            "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
2765        let mut client =
2766            SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
2767        for (id, title) in [("a", "original"), ("b", "occupied")] {
2768            client
2769                .write_base_row(
2770                    "tasks",
2771                    &vec![
2772                        Some(ColumnValue::String(id.into())),
2773                        Some(ColumnValue::String(title.into())),
2774                    ],
2775                    1,
2776                )
2777                .unwrap();
2778        }
2779        client.rebuild_overlay_if_dirty();
2780        let pending = client
2781            .mutate(vec![Mutation::Upsert {
2782                table: "tasks".into(),
2783                values: Map::from_iter([
2784                    ("id".into(), Value::from("a")),
2785                    ("title".into(), Value::from("available")),
2786                ]),
2787                base_version: None,
2788            }])
2789            .unwrap();
2790        assert_eq!(
2791            client
2792                .conn
2793                .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2794                    .get::<_, String>(
2795                    0
2796                ))
2797                .unwrap(),
2798            "available"
2799        );
2800        for (title, expected) in [("available", "original"), ("occupied", "available")] {
2801            let change = ssp2::model::Change {
2802                table_index: 0,
2803                row_id: "b".into(),
2804                op: Op::Upsert,
2805                row_version: Some(2),
2806                scopes: vec![],
2807                row: Some(
2808                    encode_row_json(
2809                        client.schema.table("tasks").unwrap(),
2810                        "b",
2811                        &Map::from_iter([
2812                            ("id".into(), Value::from("b")),
2813                            ("title".into(), Value::from(title)),
2814                        ]),
2815                        &client.encryption,
2816                    )
2817                    .unwrap(),
2818                ),
2819            };
2820            client
2821                .apply_commit_frame(&["tasks".into()], &[change], None)
2822                .unwrap();
2823            assert_eq!(
2824                client
2825                    .conn
2826                    .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2827                        .get::<_, String>(
2828                        0
2829                    ))
2830                    .unwrap(),
2831                expected
2832            );
2833            assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
2834        }
2835        assert_eq!(client.overlay_rebuild_count.get(), 3);
2836    }
2837
2838    fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
2839        let schema = json!({ "version": 1, "tables": [
2840            { "name":"tasks", "primaryKey":"id", "columns":[
2841                {"name":"id","type":"string","nullable":false},
2842                {"name":"project_id","type":"string","nullable":false}],
2843                "scopes":[{"pattern":"project:{project_id}"}] },
2844            { "name":"catalogue", "primaryKey":"id", "columns":[
2845                {"name":"id","type":"string","nullable":false},
2846                {"name":"project_id","type":"string","nullable":false},
2847                {"name":"title","type":"string","nullable":false}],
2848                "scopes":[{"pattern":"project:{project_id}"}],
2849                "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
2850        ] });
2851        let mut client =
2852            SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
2853        if unchanged_rows > 0 {
2854            client.conn.execute_batch(&format!("WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM n WHERE i<{unchanged_rows}) INSERT INTO _syncular_base_catalogue SELECT 'c'||i,'p1','needle '||i,1 FROM n")).unwrap();
2855        }
2856        client.rebuild_overlay();
2857        client.conn.execute_batch("CREATE TABLE audit(tbl TEXT); CREATE TRIGGER catalogue_changed AFTER DELETE ON catalogue BEGIN INSERT INTO audit VALUES ('catalogue'); END;").unwrap();
2858        let mut elapsed = Vec::new();
2859        for round in 1..=rounds {
2860            let pending = client
2861                .mutate(vec![Mutation::Upsert {
2862                    table: "tasks".into(),
2863                    values: Map::from_iter([
2864                        ("id".into(), json!("t1")),
2865                        ("project_id".into(), json!("p1")),
2866                    ]),
2867                    base_version: None,
2868                }])
2869                .unwrap();
2870            let started = std::time::Instant::now();
2871            client
2872                .write_base_row(
2873                    "tasks",
2874                    &vec![
2875                        Some(ColumnValue::String("t1".into())),
2876                        Some(ColumnValue::String("p1".into())),
2877                    ],
2878                    round,
2879                )
2880                .unwrap();
2881            let mut report = SyncReport::default();
2882            client
2883                .handle_push_results(
2884                    &[Frame::PushResult {
2885                        client_commit_id: pending.clone(),
2886                        status: PushStatus::Applied,
2887                        commit_seq: Some(round),
2888                        results: vec![OpResult::Applied { op_index: 0 }],
2889                    }],
2890                    &HashSet::from([pending.as_str()]),
2891                    &HashMap::new(),
2892                    &mut report,
2893                    Some(&pending),
2894                )
2895                .unwrap();
2896            client.rebuild_overlay_if_dirty();
2897            elapsed.push(started.elapsed().as_nanos());
2898        }
2899        assert_eq!(
2900            client
2901                .conn
2902                .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
2903                .unwrap(),
2904            0,
2905            "unchanged catalogue must never be copied"
2906        );
2907        assert_eq!(
2908            client
2909                .conn
2910                .query_row(
2911                    "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
2912                    [],
2913                    |r| r.get::<_, i64>(0)
2914                )
2915                .unwrap(),
2916            unchanged_rows
2917        );
2918        assert_eq!(
2919            client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
2920            json!(rounds)
2921        );
2922        elapsed
2923    }
2924
2925    #[test]
2926    fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
2927        let elapsed = measure_scoped_overlay(100_000, 1);
2928        eprintln!(
2929            "table-scoped overlay with 100k unchanged rows: {} ns",
2930            elapsed[0]
2931        );
2932    }
2933
2934    #[test]
2935    #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
2936    fn benchmark_scoped_overlay() {
2937        for rows in [0, 100_000, 200_000] {
2938            let mut elapsed = measure_scoped_overlay(rows, 30);
2939            elapsed.sort_unstable();
2940            eprintln!(
2941                "{}",
2942                json!({"unchangedRows": rows, "rounds": elapsed.len(),
2943                "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
2944                "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
2945            );
2946        }
2947    }
2948
2949    #[test]
2950    fn released_context_discards_in_flight_ack_and_server_rows() {
2951        for barrier in ["preflight", "purge", "subscription"] {
2952            let mut client = client();
2953            client.set_meta(LOG_EPOCH_KEY, "epoch");
2954            client
2955                .subscribe(
2956                    "tasks".into(),
2957                    "tasks".into(),
2958                    vec![("project_id".into(), vec!["p1".into()])],
2959                    None,
2960                )
2961                .unwrap();
2962            let commit = client
2963                .mutate(vec![Mutation::Upsert {
2964                    table: "tasks".into(),
2965                    values: Map::from_iter([
2966                        ("id".into(), json!("local")),
2967                        ("project_id".into(), json!("p1")),
2968                    ]),
2969                    base_version: None,
2970                }])
2971                .unwrap();
2972            let prepared = client.prepare_sync_round(false).unwrap();
2973            client.set_transport_enabled(&mut CountingRealtimeTransport::default(), false);
2974            let payload = encode_row_json(
2975                &client.schema.tables[0],
2976                "server",
2977                &Map::from_iter([
2978                    ("id".into(), json!("server")),
2979                    ("project_id".into(), json!("p1")),
2980                ]),
2981                &client.encryption,
2982            )
2983            .unwrap();
2984            let completed = crate::CompletedSyncRound {
2985                prepared,
2986                exchange: crate::round::ExchangeResult::Reply {
2987                    transport_failed: false,
2988                    downloads: Default::default(),
2989                    response: Ok(Message {
2990                        wire_version: WIRE_VERSION,
2991                        msg_kind: MsgKind::Response,
2992                        frames: vec![
2993                            Frame::RespHeader {
2994                                required_schema_version: None,
2995                                latest_schema_version: None,
2996                                log_epoch: Some("epoch".into()),
2997                                reset_required: Some(false),
2998                            },
2999                            Frame::PushResult {
3000                                client_commit_id: commit.clone(),
3001                                status: PushStatus::Applied,
3002                                commit_seq: Some(1),
3003                                results: vec![OpResult::Applied { op_index: 0 }],
3004                            },
3005                            Frame::SubStart {
3006                                id: "tasks".into(),
3007                                status: SubStatus::Active,
3008                                reason_code: String::new(),
3009                                effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
3010                                bootstrap: true,
3011                            },
3012                            Frame::Commit {
3013                                commit_seq: 1,
3014                                created_at_ms: 0,
3015                                actor_id: "actor".into(),
3016                                tables: vec!["tasks".into()],
3017                                changes: vec![ssp2::model::Change {
3018                                    table_index: 0,
3019                                    row_id: "server".into(),
3020                                    op: Op::Upsert,
3021                                    row_version: Some(1),
3022                                    scopes: vec![("project_id".into(), "p1".into())],
3023                                    row: Some(payload),
3024                                }],
3025                            },
3026                            Frame::SubEnd {
3027                                next_cursor: 1,
3028                                bootstrap_state: None,
3029                            },
3030                        ],
3031                    }),
3032                },
3033            };
3034            match barrier {
3035                "preflight" => client.begin_security_preflight(),
3036                "purge" => {
3037                    client
3038                        .purge_local_data(&LocalDataPurgeInput {
3039                            purge_id: "pending-round".into(),
3040                            targets: vec![LocalDataPurgeTarget {
3041                                table: "tasks".into(),
3042                                selectors: BTreeMap::from([(
3043                                    "project_id".into(),
3044                                    vec!["p1".into()],
3045                                )]),
3046                            }],
3047                        })
3048                        .unwrap();
3049                }
3050                "subscription" => {
3051                    client.unsubscribe("tasks");
3052                    client
3053                        .subscribe(
3054                            "tasks".into(),
3055                            "tasks".into(),
3056                            vec![("project_id".into(), vec!["p2".into()])],
3057                            None,
3058                        )
3059                        .unwrap();
3060                }
3061                _ => unreachable!(),
3062            }
3063            let pending_before_reply = client.pending_commit_ids();
3064            let revision = client.local_revision();
3065            let applied = client.apply_sync_round(completed);
3066            assert!(
3067                matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
3068            );
3069            assert_eq!(client.local_revision(), revision);
3070            assert!(client
3071                .conn
3072                .query_row(
3073                    "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
3074                    [],
3075                    |r| r.get::<_, bool>(0)
3076                )
3077                .unwrap());
3078            assert_eq!(client.pending_commit_ids(), pending_before_reply);
3079        }
3080    }
3081
3082    #[test]
3083    fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
3084        for failure in ["visible", "revision", "commit"] {
3085            let mut client = client();
3086            let id = client
3087                .mutate(vec![Mutation::Upsert {
3088                    table: "tasks".into(),
3089                    values: Map::from_iter([
3090                        ("id".into(), Value::from("t1")),
3091                        ("project_id".into(), Value::from("pending")),
3092                    ]),
3093                    base_version: None,
3094                }])
3095                .unwrap();
3096            client.drain_change_batches();
3097            let revision = client.local_revision();
3098            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
3099            let trigger = match failure {
3100                "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
3101                "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"),
3102                _ => "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(),
3103            };
3104            client.conn.execute_batch(&trigger).unwrap();
3105            let change = ssp2::model::Change {
3106                table_index: 0,
3107                row_id: "t1".into(),
3108                op: Op::Upsert,
3109                row_version: Some(2),
3110                scopes: vec![],
3111                row: Some(
3112                    encode_row_json(
3113                        client.schema.table("tasks").unwrap(),
3114                        "t1",
3115                        &Map::from_iter([
3116                            ("id".into(), Value::from("t1")),
3117                            ("project_id".into(), Value::from("remote")),
3118                        ]),
3119                        &client.encryption,
3120                    )
3121                    .unwrap(),
3122                ),
3123            };
3124            assert!(client
3125                .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), None)
3126                .is_err());
3127            assert!(client.conn.is_autocommit());
3128            assert!(!client.overlay_dirty.get());
3129            assert_eq!(client.local_revision(), revision);
3130            assert!(client.drain_change_batches().is_empty());
3131            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
3132            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
3133            assert!(client
3134                .query("SELECT * FROM _syncular_base_tasks", &[])
3135                .unwrap()
3136                .is_empty());
3137            client
3138                .conn
3139                .execute_batch("DROP TRIGGER fail_pending")
3140                .unwrap();
3141            client
3142                .apply_commit_frame(&["tasks".into()], &[change], None)
3143                .unwrap();
3144            assert_eq!(client.local_revision(), revision + 1);
3145            assert_eq!(client.pending_commit_ids(), vec![id]);
3146            // The overlay keeps the pending write visible; the row now carries
3147            // the server row's version it is layered on (§7.1).
3148            let mut expected = before[0].clone();
3149            expected.insert("_syncular_version".to_owned(), Value::from(2));
3150            assert_eq!(
3151                client.query("SELECT * FROM tasks", &[]).unwrap(),
3152                vec![expected]
3153            );
3154            assert_eq!(
3155                client
3156                    .conn
3157                    .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
3158                        .get::<_, String>(0))
3159                    .unwrap(),
3160                "remote"
3161            );
3162        }
3163    }
3164
3165    #[test]
3166    fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
3167        let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
3168            "columns": [
3169                {"name":"id", "type":"string", "nullable":false},
3170                {"name":"project_id", "type":"string", "nullable":false},
3171                {"name":"title", "type":"string", "nullable":false}],
3172            "scopes": [{"pattern":"project:{project_id}"}],
3173            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
3174            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
3175        let mut incremental =
3176            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
3177        let mut reference =
3178            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
3179        for instance in [&mut incremental, &mut reference] {
3180            for index in 0..16 {
3181                instance
3182                    .write_base_row(
3183                        "tasks",
3184                        &vec![
3185                            Some(ColumnValue::String(format!("task{index}"))),
3186                            Some(ColumnValue::String("p0".to_owned())),
3187                            Some(ColumnValue::String(format!("base{index}"))),
3188                        ],
3189                        1,
3190                    )
3191                    .unwrap();
3192            }
3193            instance.rebuild_overlay_if_dirty();
3194            instance.overlay_rebuild_count.set(0);
3195        }
3196        for index in 0..100 {
3197            let mutations = (0..1 + index % 4)
3198                .map(|offset| {
3199                    let id = format!("task{}", (index + offset) % 24);
3200                    if (index + offset) % 7 == 0 {
3201                        Mutation::Delete {
3202                            table: "tasks".to_owned(),
3203                            row_id: id,
3204                            base_version: None,
3205                        }
3206                    } else {
3207                        Mutation::Upsert {
3208                            table: "tasks".to_owned(),
3209                            values: Map::from_iter([
3210                                ("id".to_owned(), Value::from(id)),
3211                                (
3212                                    "project_id".to_owned(),
3213                                    Value::from(format!("p{}", index % 2)),
3214                                ),
3215                                (
3216                                    "title".to_owned(),
3217                                    Value::from(format!("needle{}", (index + offset) % 24)),
3218                                ),
3219                            ]),
3220                            base_version: None,
3221                        }
3222                    }
3223                })
3224                .collect::<Vec<_>>();
3225            reference.overlay_dirty.set(true);
3226            incremental.mutate(mutations.clone()).unwrap();
3227            reference.mutate(mutations).unwrap();
3228            for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
3229                "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
3230                assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
3231            }
3232            assert_eq!(incremental.local_revision(), reference.local_revision());
3233            assert_eq!(
3234                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
3235                serde_json::to_value(reference.drain_change_batches()).unwrap()
3236            );
3237        }
3238        assert_eq!(incremental.outbox.len(), 100);
3239        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
3240        assert_eq!(reference.overlay_rebuild_count.get(), 100);
3241    }
3242
3243    #[test]
3244    fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
3245        for fail_at in ["outbox", "revision"] {
3246            let mut client = client();
3247            let values = Map::from_iter([
3248                ("id".to_owned(), Value::from("t1")),
3249                ("project_id".to_owned(), Value::from("p1")),
3250            ]);
3251            let mutation = Mutation::Upsert {
3252                table: "tasks".to_owned(),
3253                values,
3254                base_version: None,
3255            };
3256            let trigger = if fail_at == "outbox" {
3257                "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
3258            } else {
3259                format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
3260            };
3261            client.conn.execute_batch(&trigger).unwrap();
3262            let revision = client.local_revision();
3263            assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
3264            assert_eq!(client.local_revision(), revision);
3265            assert!(client.outbox.is_empty());
3266            assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
3267            assert_eq!(
3268                client
3269                    .conn
3270                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
3271                        .get::<_, i64>(0))
3272                    .unwrap(),
3273                0
3274            );
3275            assert!(client.drain_change_batches().is_empty());
3276            assert!(!client.overlay_dirty.get());
3277            client
3278                .conn
3279                .execute_batch("DROP TRIGGER fail_append")
3280                .unwrap();
3281            client.mutate(vec![mutation]).unwrap();
3282            assert_eq!(client.outbox.len(), 1);
3283            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
3284        }
3285    }
3286
3287    #[test]
3288    fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
3289        let mut client = client();
3290        let tables = vec!["tasks".to_owned(), "missing".to_owned()];
3291        let values = Map::from_iter([
3292            ("id".to_owned(), Value::from("t1")),
3293            ("project_id".to_owned(), Value::from("p1")),
3294        ]);
3295        let payload = encode_row_json(
3296            client.schema.table("tasks").unwrap(),
3297            "t1",
3298            &values,
3299            &client.encryption,
3300        )
3301        .unwrap();
3302        let first = ssp2::model::Change {
3303            table_index: 0,
3304            row_id: "t1".to_owned(),
3305            op: Op::Upsert,
3306            row_version: Some(1),
3307            scopes: vec![("project_id".to_owned(), "p1".to_owned())],
3308            row: Some(payload),
3309        };
3310        let mut invalid = first.clone();
3311        invalid.table_index = 1;
3312        let revision = client.local_revision();
3313        client.begin_observation("failed_mirror").unwrap();
3314        assert!(client
3315            .apply_commit_changes(&tables, &[first, invalid])
3316            .is_err());
3317        client.rollback_observation("failed_mirror");
3318        client.rebuild_overlay_if_dirty();
3319        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
3320        assert_eq!(
3321            client
3322                .conn
3323                .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
3324                    .get::<_, i64>(
3325                    0
3326                ))
3327                .unwrap(),
3328            0
3329        );
3330        assert_eq!(client.local_revision(), revision);
3331        assert!(client.drain_change_batches().is_empty());
3332    }
3333
3334    #[test]
3335    fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
3336        for failure in ["journal", "revision", "commit", "later-frame"] {
3337            let mut client = client();
3338            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3339            for id in ["first", "second"] {
3340                client
3341                    .mutate(vec![Mutation::Upsert {
3342                        table: "tasks".to_owned(),
3343                        values: Map::from_iter([
3344                            ("id".to_owned(), Value::from(id)),
3345                            ("project_id".to_owned(), Value::from("p1")),
3346                        ]),
3347                        base_version: None,
3348                    }])
3349                    .unwrap();
3350            }
3351            let (_, meta) = client.build_request(false);
3352            // A malicious or stale response must not drain an unsent local commit.
3353            client
3354                .mutate(vec![Mutation::Upsert {
3355                    table: "tasks".to_owned(),
3356                    values: Map::from_iter([
3357                        ("id".to_owned(), Value::from("later")),
3358                        ("project_id".to_owned(), Value::from("p1")),
3359                    ]),
3360                    base_version: None,
3361                }])
3362                .unwrap();
3363            let ids = client.pending_commit_ids();
3364            let rows = client
3365                .query("SELECT * FROM tasks ORDER BY id", &[])
3366                .unwrap();
3367            let revision = client.local_revision();
3368            client.drain_change_batches();
3369            if failure != "later-frame" {
3370                client.conn.execute_batch(match failure {
3371                    "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",
3372                    "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",
3373                    _ => "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",
3374                }).unwrap();
3375            }
3376            let mut response = Message {
3377                wire_version: WIRE_VERSION,
3378                msg_kind: MsgKind::Response,
3379                frames: vec![Frame::RespHeader {
3380                    required_schema_version: None,
3381                    latest_schema_version: None,
3382                    log_epoch: Some("epoch-1".to_owned()),
3383                    reset_required: Some(false),
3384                }],
3385            };
3386            for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
3387                response.frames.push(Frame::PushResult {
3388                    client_commit_id: id.to_owned(),
3389                    status: PushStatus::Applied,
3390                    commit_seq: Some(1),
3391                    results: vec![OpResult::Applied { op_index: 0 }],
3392                });
3393            }
3394            if failure == "later-frame" {
3395                response.frames.push(Frame::Error {
3396                    code: "sync.invalid_request".to_owned(),
3397                    message: "later error".to_owned(),
3398                    category: "protocol".to_owned(),
3399                    retryable: false,
3400                    recommended_action: "retry".to_owned(),
3401                    details: None,
3402                });
3403            }
3404            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3405            let outcome = client.process_response(&mut transport, response.clone(), &meta);
3406            assert!(
3407                matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
3408            );
3409            if failure == "later-frame" {
3410                assert_eq!(client.pending_commit_ids(), ids[2..]);
3411                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
3412                assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
3413                let batches = client.drain_change_batches();
3414                assert_eq!(batches.len(), 1);
3415                assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
3416                assert_eq!(client.local_revision(), revision + 1);
3417                response.frames.pop();
3418            } else {
3419                assert_eq!(client.pending_commit_ids(), ids);
3420                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
3421                assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
3422                assert_eq!(client.local_revision(), revision);
3423                assert!(client.drain_change_batches().is_empty());
3424                assert_eq!(
3425                    client
3426                        .query("SELECT * FROM tasks ORDER BY id", &[])
3427                        .unwrap(),
3428                    rows
3429                );
3430                client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
3431            }
3432            for frame in &mut response.frames {
3433                if let Frame::PushResult { status, .. } = frame {
3434                    *status = PushStatus::Cached;
3435                }
3436            }
3437            let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
3438            else {
3439                panic!("retry failed");
3440            };
3441            assert_eq!(
3442                report.applied,
3443                if failure == "later-frame" {
3444                    vec![]
3445                } else {
3446                    ids[..2].to_vec()
3447                }
3448            );
3449            assert_eq!(client.pending_commit_ids(), ids[2..]);
3450            let batches = client.drain_change_batches();
3451            assert_eq!(batches.len(), usize::from(failure != "later-frame"));
3452            if let Some(batch) = batches.first() {
3453                assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
3454            }
3455            assert_eq!(client.local_revision(), revision + 1);
3456        }
3457    }
3458
3459    #[test]
3460    fn failed_push_result_preserves_memory_disk_and_retry_identity() {
3461        for failure in ["revision", "journal", "commit"] {
3462            for status in ["applied", "cached", "rejected", "conflict"] {
3463                let mut client = client();
3464                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3465                for id in ["first", "later"] {
3466                    client
3467                        .mutate(vec![Mutation::Upsert {
3468                            table: "tasks".to_owned(),
3469                            values: Map::from_iter([
3470                                ("id".to_owned(), Value::from(id)),
3471                                ("project_id".to_owned(), Value::from("p1")),
3472                            ]),
3473                            base_version: None,
3474                        }])
3475                        .unwrap();
3476                }
3477                let (_, meta) = client.build_request(false);
3478                let ids = client.pending_commit_ids();
3479                let rows = client
3480                    .query("SELECT * FROM tasks ORDER BY id", &[])
3481                    .unwrap();
3482                let revision = client.local_revision();
3483                client.drain_change_batches();
3484                let (push_status, results) = match status {
3485                    "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
3486                    "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
3487                    "conflict" => (
3488                        PushStatus::Rejected,
3489                        vec![OpResult::Conflict {
3490                            op_index: 0,
3491                            code: "sync.version_conflict".to_owned(),
3492                            message: "conflict".to_owned(),
3493                            server_version: 1,
3494                            server_row: encode_row_json(
3495                                client.schema.table("tasks").unwrap(),
3496                                "first",
3497                                &Map::from_iter([
3498                                    ("id".to_owned(), Value::from("first")),
3499                                    ("project_id".to_owned(), Value::from("p1")),
3500                                ]),
3501                                &client.encryption,
3502                            )
3503                            .unwrap(),
3504                            conflict_columns: Vec::new(),
3505                        }],
3506                    ),
3507                    _ => (
3508                        PushStatus::Rejected,
3509                        vec![OpResult::Error {
3510                            op_index: 0,
3511                            code: "sync.validation_failed".to_owned(),
3512                            message: "rejected".to_owned(),
3513                            retryable: false,
3514                        }],
3515                    ),
3516                };
3517                let response = Message {
3518                    wire_version: WIRE_VERSION,
3519                    msg_kind: MsgKind::Response,
3520                    frames: vec![
3521                        Frame::RespHeader {
3522                            required_schema_version: None,
3523                            latest_schema_version: None,
3524                            log_epoch: Some("epoch-1".to_owned()),
3525                            reset_required: Some(false),
3526                        },
3527                        Frame::PushResult {
3528                            client_commit_id: ids[0].clone(),
3529                            status: push_status,
3530                            commit_seq: Some(1),
3531                            results,
3532                        },
3533                    ],
3534                };
3535                client.conn.execute_batch(match failure {
3536                    "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
3537                    "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
3538                    _ => "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",
3539                }).unwrap();
3540                let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3541                let outcome = client.process_response(&mut transport, response.clone(), &meta);
3542                assert_eq!(
3543                    client.pending_commit_ids(),
3544                    ids,
3545                    "{failure}/{status}: pending identity"
3546                );
3547                assert_eq!(
3548                    client.local_revision(),
3549                    revision,
3550                    "{failure}/{status}: revision"
3551                );
3552                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
3553                assert!(client.conflicts().is_empty());
3554                assert!(client.rejections().is_empty());
3555                assert!(client.drain_change_batches().is_empty());
3556                assert_eq!(
3557                    client
3558                        .query("SELECT * FROM tasks ORDER BY id", &[])
3559                        .unwrap(),
3560                    rows
3561                );
3562                assert!(
3563                    matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
3564                    "{failure}/{status}: failed round"
3565                );
3566                client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
3567                assert!(matches!(
3568                    client.process_response(&mut transport, response, &meta),
3569                    SyncOutcome::Ok(_)
3570                ));
3571                assert_eq!(client.pending_commit_ids(), ids[1..]);
3572                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
3573                assert_eq!(client.drain_change_batches().len(), 1);
3574            }
3575        }
3576    }
3577
3578    #[test]
3579    fn mixed_push_results_reconcile_and_prune_once_per_response() {
3580        let mut client = client();
3581        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3582        for index in 0..4 {
3583            client
3584                .mutate(vec![Mutation::Upsert {
3585                    table: "tasks".to_owned(),
3586                    values: Map::from_iter([
3587                        ("id".to_owned(), Value::from(format!("task-{index}"))),
3588                        ("project_id".to_owned(), Value::from("project-1")),
3589                    ]),
3590                    base_version: None,
3591                }])
3592                .expect("queue commit");
3593        }
3594
3595        let (_, request_meta) = client.build_request(false);
3596        let ids = &request_meta.pushed_ids;
3597        assert_eq!(ids.len(), 4);
3598        let response = Message {
3599            wire_version: WIRE_VERSION,
3600            msg_kind: MsgKind::Response,
3601            frames: vec![
3602                Frame::RespHeader {
3603                    required_schema_version: None,
3604                    latest_schema_version: None,
3605                    log_epoch: Some("epoch-1".to_owned()),
3606                    reset_required: Some(false),
3607                },
3608                Frame::PushResult {
3609                    client_commit_id: ids[0].clone(),
3610                    status: PushStatus::Applied,
3611                    commit_seq: Some(1),
3612                    results: vec![OpResult::Applied { op_index: 0 }],
3613                },
3614                Frame::PushResult {
3615                    client_commit_id: ids[1].clone(),
3616                    status: PushStatus::Cached,
3617                    commit_seq: Some(2),
3618                    results: vec![OpResult::Applied { op_index: 0 }],
3619                },
3620                Frame::PushResult {
3621                    client_commit_id: ids[2].clone(),
3622                    status: PushStatus::Rejected,
3623                    commit_seq: None,
3624                    results: vec![OpResult::Error {
3625                        op_index: 0,
3626                        code: "sync.validation_failed".to_owned(),
3627                        message: "rejected".to_owned(),
3628                        retryable: false,
3629                    }],
3630                },
3631                Frame::PushResult {
3632                    client_commit_id: ids[3].clone(),
3633                    status: PushStatus::Rejected,
3634                    commit_seq: None,
3635                    results: vec![OpResult::Error {
3636                        op_index: 0,
3637                        code: "sync.idempotency_cache_miss".to_owned(),
3638                        message: "retry".to_owned(),
3639                        retryable: true,
3640                    }],
3641                },
3642            ],
3643        };
3644        let mut transport =
3645            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
3646
3647        client.overlay_rebuild_count.set(0);
3648        client.outcome_prune_count.set(0);
3649        let outcome = client.process_response(&mut transport, response, &request_meta);
3650        let SyncOutcome::Ok(report) = outcome else {
3651            panic!("mixed push-result response failed");
3652        };
3653
3654        assert_eq!(report.applied, ids[..2]);
3655        assert_eq!(report.rejected, ids[2..3]);
3656        assert_eq!(report.retryable, ids[3..4]);
3657        assert_eq!(client.pending_commit_ids(), ids[3..4]);
3658        assert_eq!(
3659            client
3660                .query("SELECT id FROM tasks ORDER BY id", &[])
3661                .expect("query visible overlay"),
3662            ["task-0", "task-1", "task-3"]
3663                .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
3664                .to_vec()
3665        );
3666        assert_eq!(client.overlay_rebuild_count.get(), 1);
3667        assert_eq!(client.outcome_prune_count.get(), 1);
3668    }
3669
3670    #[test]
3671    fn secondary_unique_collision_preserves_existing_synced_row() {
3672        let client = SyncClient::new(
3673            "unique-upsert-test".to_owned(),
3674            &json!({
3675                "version": 1,
3676                "tables": [{
3677                    "name": "tasks",
3678                    "primaryKey": "id",
3679                    "columns": [
3680                        { "name": "id", "type": "string", "nullable": false },
3681                        { "name": "project_id", "type": "string", "nullable": false },
3682                        { "name": "title", "type": "string", "nullable": false }
3683                    ],
3684                    "scopes": [{ "pattern": "project:{project_id}" }],
3685                    "indexes": [{
3686                        "name": "tasks_by_project_title",
3687                        "columns": ["project_id", "title"],
3688                        "unique": true
3689                    }]
3690                }]
3691            }),
3692            ClientLimits::default(),
3693        )
3694        .expect("test client");
3695        let table = client.schema.table("tasks").expect("tasks table");
3696        let sql = client.insert_row_sql(&base_table("tasks"), table);
3697
3698        client
3699            .conn
3700            .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
3701            .expect("insert first row");
3702        client
3703            .conn
3704            .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
3705            .expect("update same primary key");
3706        client
3707            .conn
3708            .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
3709            .expect("insert second row");
3710        assert!(client
3711            .conn
3712            .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
3713            .is_err());
3714
3715        let rows = client
3716            .conn
3717            .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
3718            .expect("prepare rows")
3719            .query_map([], |row| {
3720                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3721            })
3722            .expect("query rows")
3723            .collect::<Result<Vec<_>, _>>()
3724            .expect("collect rows");
3725        assert_eq!(
3726            rows,
3727            vec![
3728                ("t1".to_owned(), "updated".to_owned()),
3729                ("t2".to_owned(), "original".to_owned())
3730            ]
3731        );
3732    }
3733
3734    #[test]
3735    fn reopening_active_subscriptions_emits_a_catch_up_intent() {
3736        let path = std::env::temp_dir().join(format!(
3737            "syncular-startup-intent-{}.db",
3738            uuid::Uuid::new_v4()
3739        ));
3740        let schema = json!({
3741            "version": 1,
3742            "tables": [{
3743                "name": "tasks",
3744                "primaryKey": "id",
3745                "columns": [
3746                    { "name": "id", "type": "string", "nullable": false },
3747                    { "name": "project_id", "type": "string", "nullable": false }
3748                ],
3749                "scopes": [{ "pattern": "project:{project_id}" }]
3750            }]
3751        });
3752
3753        {
3754            let mut first = SyncClient::open_path_with_identity(
3755                None,
3756                &schema,
3757                ClientLimits::default(),
3758                path.to_str().expect("UTF-8 temp path"),
3759            )
3760            .expect("first open");
3761            first
3762                .set_window(
3763                    &WindowBase {
3764                        table: "tasks".to_owned(),
3765                        variable: "project_id".to_owned(),
3766                        fixed_scopes: Vec::new(),
3767                        params: None,
3768                    },
3769                    &["persisted".to_owned()],
3770                )
3771                .expect("persist window");
3772        }
3773
3774        let mut reopened = SyncClient::open_path_with_identity(
3775            None,
3776            &schema,
3777            ClientLimits::default(),
3778            path.to_str().expect("UTF-8 temp path"),
3779        )
3780        .expect("reopen");
3781        assert!(reopened.sync_needed());
3782        assert!(matches!(
3783            reopened.drain_sync_intents().as_slice(),
3784            [SyncIntent::Interactive]
3785        ));
3786        drop(reopened);
3787        std::fs::remove_file(path).expect("remove temp database");
3788    }
3789
3790    #[test]
3791    fn reopening_preserves_immutable_subscription_identity_and_progress() {
3792        let path = std::env::temp_dir().join(format!(
3793            "syncular-subscription-identity-{}.db",
3794            uuid::Uuid::new_v4()
3795        ));
3796        let schema = json!({
3797            "version": 1,
3798            "tables": [
3799                {
3800                    "name": "tasks",
3801                    "primaryKey": "id",
3802                    "columns": [
3803                        { "name": "id", "type": "string", "nullable": false },
3804                        { "name": "project_id", "type": "string", "nullable": false }
3805                    ],
3806                    "scopes": [{ "pattern": "project:{project_id}" }]
3807                },
3808                {
3809                    "name": "docs",
3810                    "primaryKey": "id",
3811                    "columns": [
3812                        { "name": "id", "type": "string", "nullable": false },
3813                        { "name": "org_id", "type": "string", "nullable": false },
3814                        { "name": "project_id", "type": "string", "nullable": false }
3815                    ],
3816                    "scopes": [
3817                        { "pattern": "org:{org_id}" },
3818                        { "pattern": "project:{projectId}", "column": "project_id" }
3819                    ]
3820                }
3821            ]
3822        });
3823
3824        {
3825            let mut first = SyncClient::open_path_with_identity(
3826                None,
3827                &schema,
3828                ClientLimits::default(),
3829                path.to_str().expect("UTF-8 temp path"),
3830            )
3831            .expect("first open");
3832            first
3833                .subscribe(
3834                    "stable-subscription".to_owned(),
3835                    "tasks".to_owned(),
3836                    vec![(
3837                        "project_id".to_owned(),
3838                        vec!["p2".to_owned(), "p1".to_owned()],
3839                    )],
3840                    Some(r#"{"view":"v1"}"#.to_owned()),
3841                )
3842                .expect("persist subscription");
3843            let persisted = {
3844                let subscription = first
3845                    .subs
3846                    .iter_mut()
3847                    .find(|subscription| subscription.id == "stable-subscription")
3848                    .expect("subscription");
3849                subscription.cursor = 41;
3850                subscription.bootstrap_state = Some("resume-token".to_owned());
3851                subscription.effective = Some(vec![(
3852                    "project_id".to_owned(),
3853                    vec!["p1".to_owned(), "p2".to_owned()],
3854                )]);
3855                subscription.synced_once = true;
3856                subscription.clone()
3857            };
3858            first.persist_sub(&persisted).unwrap();
3859        }
3860
3861        let mut reopened = SyncClient::open_path_with_identity(
3862            None,
3863            &schema,
3864            ClientLimits::default(),
3865            path.to_str().expect("UTF-8 temp path"),
3866        )
3867        .expect("reopen");
3868        let progress = reopened
3869            .subscription_state("stable-subscription")
3870            .expect("persisted state");
3871        assert_eq!(progress.cursor, 41);
3872        assert!(progress.has_resume_token);
3873
3874        reopened
3875            .subscribe(
3876                "stable-subscription".to_owned(),
3877                "tasks".to_owned(),
3878                vec![(
3879                    "project_id".to_owned(),
3880                    vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
3881                )],
3882                Some(r#"{"view":"v1"}"#.to_owned()),
3883            )
3884            .expect("canonical intent is idempotent");
3885        assert_eq!(
3886            reopened
3887                .subscription_state("stable-subscription")
3888                .expect("unchanged state")
3889                .cursor,
3890            progress.cursor
3891        );
3892
3893        for (table, scopes, params) in [
3894            (
3895                "tasks",
3896                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3897                Some(r#"{"view":"v1"}"#.to_owned()),
3898            ),
3899            (
3900                "tasks",
3901                vec![(
3902                    "project_id".to_owned(),
3903                    vec!["p2".to_owned(), "p1".to_owned()],
3904                )],
3905                Some(r#"{"view":"v2"}"#.to_owned()),
3906            ),
3907            (
3908                "docs",
3909                vec![
3910                    ("org_id".to_owned(), vec!["o1".to_owned()]),
3911                    ("projectId".to_owned(), vec!["p1".to_owned()]),
3912                ],
3913                Some(r#"{"view":"v1"}"#.to_owned()),
3914            ),
3915        ] {
3916            let error = reopened
3917                .subscribe(
3918                    "stable-subscription".to_owned(),
3919                    table.to_owned(),
3920                    scopes,
3921                    params,
3922                )
3923                .expect_err("identity rebind must fail");
3924            assert!(error.starts_with("client.subscription_intent_mismatch:"));
3925            assert_eq!(
3926                reopened
3927                    .subscription_state("stable-subscription")
3928                    .expect("unchanged state")
3929                    .cursor,
3930                progress.cursor
3931            );
3932        }
3933
3934        drop(reopened);
3935        std::fs::remove_file(path).expect("remove temp database");
3936    }
3937
3938    #[test]
3939    fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
3940        let path = std::env::temp_dir().join(format!(
3941            "syncular-satisfied-schema-floor-{}.db",
3942            uuid::Uuid::new_v4()
3943        ));
3944        let schema = json!({
3945            "version": 23,
3946            "tables": [{
3947                "name": "tasks",
3948                "primaryKey": "id",
3949                "columns": [
3950                    { "name": "id", "type": "string", "nullable": false },
3951                    { "name": "project_id", "type": "string", "nullable": false }
3952                ],
3953                "scopes": [{ "pattern": "project:{project_id}" }]
3954            }]
3955        });
3956
3957        {
3958            let mut first = SyncClient::open_path_with_identity(
3959                None,
3960                &schema,
3961                ClientLimits::default(),
3962                path.to_str().expect("UTF-8 temp path"),
3963            )
3964            .expect("first open");
3965            first
3966                .subscribe(
3967                    "tasks".to_owned(),
3968                    "tasks".to_owned(),
3969                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3970                    None,
3971                )
3972                .expect("persist subscription");
3973            first.set_schema_floor(Some(SchemaFloor {
3974                required_schema_version: Some(22),
3975                latest_schema_version: Some(22),
3976            }));
3977        }
3978
3979        let mut reopened = SyncClient::open_path_with_identity(
3980            None,
3981            &schema,
3982            ClientLimits::default(),
3983            path.to_str().expect("UTF-8 temp path"),
3984        )
3985        .expect("reopen");
3986        assert!(reopened.schema_floor().is_none());
3987        assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
3988        assert!(reopened.sync_needed());
3989        assert!(matches!(
3990            reopened.drain_sync_intents().as_slice(),
3991            [SyncIntent::Interactive]
3992        ));
3993        drop(reopened);
3994        std::fs::remove_file(path).expect("remove temp database");
3995    }
3996
3997    #[test]
3998    fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
3999        let path = std::env::temp_dir().join(format!(
4000            "syncular-unsatisfied-schema-floor-{}.db",
4001            uuid::Uuid::new_v4()
4002        ));
4003        let schema = json!({
4004            "version": 1,
4005            "tables": [{
4006                "name": "tasks",
4007                "primaryKey": "id",
4008                "columns": [
4009                    { "name": "id", "type": "string", "nullable": false },
4010                    { "name": "project_id", "type": "string", "nullable": false }
4011                ],
4012                "scopes": [{ "pattern": "project:{project_id}" }]
4013            }]
4014        });
4015
4016        {
4017            let mut first = SyncClient::open_path_with_identity(
4018                None,
4019                &schema,
4020                ClientLimits::default(),
4021                path.to_str().expect("UTF-8 temp path"),
4022            )
4023            .expect("first open");
4024            first
4025                .subscribe(
4026                    "tasks".to_owned(),
4027                    "tasks".to_owned(),
4028                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
4029                    None,
4030                )
4031                .expect("persist subscription");
4032            first.set_schema_floor(Some(SchemaFloor {
4033                required_schema_version: Some(2),
4034                latest_schema_version: Some(2),
4035            }));
4036        }
4037
4038        let reopened = SyncClient::open_path_with_identity(
4039            None,
4040            &schema,
4041            ClientLimits::default(),
4042            path.to_str().expect("UTF-8 temp path"),
4043        )
4044        .expect("reopen");
4045        assert_eq!(
4046            reopened.schema_floor(),
4047            Some(&SchemaFloor {
4048                required_schema_version: Some(2),
4049                latest_schema_version: Some(2),
4050            })
4051        );
4052        assert!(!reopened.sync_needed());
4053        drop(reopened);
4054        std::fs::remove_file(path).expect("remove temp database");
4055    }
4056
4057    #[test]
4058    fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
4059        let path = std::env::temp_dir().join(format!(
4060            "syncular-indexed-column-bump-{}.db",
4061            uuid::Uuid::new_v4()
4062        ));
4063        let old_schema = json!({
4064            "version": 1,
4065            "tables": [
4066                {
4067                    "name": "tasks",
4068                    "primaryKey": "id",
4069                    "columns": [
4070                        { "name": "id", "type": "string", "nullable": false },
4071                        { "name": "project_id", "type": "string", "nullable": false }
4072                    ],
4073                    "scopes": [{ "pattern": "project:{project_id}" }]
4074                },
4075                {
4076                    "name": "legacy",
4077                    "primaryKey": "id",
4078                    "columns": [
4079                        { "name": "id", "type": "string", "nullable": false },
4080                        { "name": "project_id", "type": "string", "nullable": false }
4081                    ],
4082                    "scopes": [{ "pattern": "project:{project_id}" }]
4083                }
4084            ]
4085        });
4086        {
4087            let mut first = SyncClient::open_path_with_identity(
4088                None,
4089                &old_schema,
4090                ClientLimits::default(),
4091                path.to_str().expect("UTF-8 temp path"),
4092            )
4093            .expect("open old schema");
4094            first
4095                .subscribe(
4096                    "legacy-sub".to_owned(),
4097                    "legacy".to_owned(),
4098                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
4099                    None,
4100                )
4101                .expect("persist legacy subscription");
4102        }
4103
4104        let new_schema = json!({
4105            "version": 2,
4106            "tables": [{
4107                "name": "tasks",
4108                "primaryKey": "id",
4109                "columns": [
4110                    { "name": "id", "type": "string", "nullable": false },
4111                    { "name": "project_id", "type": "string", "nullable": false },
4112                    { "name": "facility_membership_id", "type": "string", "nullable": true }
4113                ],
4114                "scopes": [{ "pattern": "project:{project_id}" }],
4115                "indexes": [{
4116                    "name": "tasks_by_membership",
4117                    "columns": ["project_id", "facility_membership_id"],
4118                    "unique": false
4119                }]
4120            }]
4121        });
4122        let upgraded = SyncClient::open_path_with_identity(
4123            None,
4124            &new_schema,
4125            ClientLimits::default(),
4126            path.to_str().expect("UTF-8 temp path"),
4127        )
4128        .expect("open upgraded schema");
4129        let columns = upgraded
4130            .conn
4131            .prepare("PRAGMA table_info(tasks)")
4132            .expect("prepare columns")
4133            .query_map([], |row| row.get::<_, String>(1))
4134            .expect("query columns")
4135            .collect::<Result<Vec<_>, _>>()
4136            .expect("collect columns");
4137        assert!(columns.contains(&"facility_membership_id".to_owned()));
4138        let index_count: i64 = upgraded
4139            .conn
4140            .query_row(
4141                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
4142                [],
4143                |row| row.get(0),
4144            )
4145            .expect("query index");
4146        assert_eq!(index_count, 1);
4147        assert!(upgraded.subscription_state("legacy-sub").is_none());
4148        drop(upgraded);
4149        std::fs::remove_file(path).expect("remove temp database");
4150    }
4151
4152    #[test]
4153    fn migrates_pre_envelope_outcome_journal_additively() {
4154        let path = std::env::temp_dir().join(format!(
4155            "syncular-outcome-migration-{}.db",
4156            uuid::Uuid::new_v4()
4157        ));
4158        let conn = Connection::open(&path).expect("open legacy database");
4159        conn.execute_batch(
4160            "CREATE TABLE _syncular_commit_outcomes (
4161               seq INTEGER PRIMARY KEY AUTOINCREMENT,
4162               client_commit_id TEXT NOT NULL UNIQUE,
4163               status TEXT NOT NULL,
4164               recorded_at_ms INTEGER NOT NULL,
4165               results_json TEXT NOT NULL,
4166               resolution TEXT NOT NULL DEFAULT 'active',
4167               resolved_at_ms INTEGER,
4168               replacement_client_commit_id TEXT);",
4169        )
4170        .expect("create legacy outcome journal");
4171        drop(conn);
4172        let schema = json!({
4173            "version": 1,
4174            "tables": [{
4175                "name": "tasks",
4176                "primaryKey": "id",
4177                "columns": [
4178                    { "name": "id", "type": "string", "nullable": false },
4179                    { "name": "project_id", "type": "string", "nullable": false }
4180                ],
4181                "scopes": [{ "pattern": "project:{project_id}" }]
4182            }]
4183        });
4184        let client = SyncClient::open_path(
4185            "migration-native".to_owned(),
4186            &schema,
4187            ClientLimits::default(),
4188            path.to_str().expect("UTF-8 temp path"),
4189        )
4190        .expect("migrate database");
4191        let has_operations = client
4192            .conn
4193            .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
4194            .expect("prepare table info")
4195            .query_map([], |row| row.get::<_, String>(1))
4196            .expect("query table info")
4197            .filter_map(Result::ok)
4198            .any(|column| column == "operations_json");
4199        assert!(has_operations);
4200        drop(client);
4201        std::fs::remove_file(path).expect("remove temp database");
4202    }
4203
4204    #[test]
4205    fn retained_rejection_survives_file_reopen_and_security_purge() {
4206        let path =
4207            std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
4208        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
4209            { "name": "id", "type": "string", "nullable": false },
4210            { "name": "project_id", "type": "string", "nullable": false },
4211            { "name": "title", "type": "string", "nullable": false }
4212        ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
4213        {
4214            let mut first = SyncClient::open_path(
4215                "retained-native".to_owned(),
4216                &schema,
4217                ClientLimits::default(),
4218                path.to_str().unwrap(),
4219            )
4220            .unwrap();
4221            first
4222                .write_base_row(
4223                    "tasks",
4224                    &vec![
4225                        Some(ColumnValue::String("t1".into())),
4226                        Some(ColumnValue::String("p1".into())),
4227                        Some(ColumnValue::String("server".into())),
4228                    ],
4229                    1,
4230                )
4231                .unwrap();
4232            first.rebuild_overlay();
4233            let operation = OutboxOp {
4234                table: "tasks".into(),
4235                row_id: "t1".into(),
4236                upsert: true,
4237                base_version: Some(1),
4238                values: Some(Map::from_iter([
4239                    ("id".into(), json!("t1")),
4240                    ("title".into(), json!("mine")),
4241                ])),
4242            };
4243            first
4244                .retain_failed_commit("retained", std::slice::from_ref(&operation))
4245                .unwrap();
4246            first
4247                .persist_commit_outcome(
4248                    "retained",
4249                    CommitOutcomeStatus::Rejected,
4250                    &[],
4251                    Some(&[operation]),
4252                )
4253                .unwrap();
4254            first.rebuild_overlay();
4255        }
4256        {
4257            let mut reopened = SyncClient::open_path(
4258                "retained-native".to_owned(),
4259                &schema,
4260                ClientLimits::default(),
4261                path.to_str().unwrap(),
4262            )
4263            .unwrap();
4264            let row = reopened
4265                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
4266                .unwrap();
4267            assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
4268            assert_eq!(
4269                reopened
4270                    .commit_outcome("retained")
4271                    .unwrap()
4272                    .unwrap()
4273                    .retained_rows
4274                    .unwrap()[0]
4275                    .server_row
4276                    .as_ref()
4277                    .unwrap()["title"],
4278                json!("server")
4279            );
4280            reopened
4281                .conn
4282                .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
4283                .unwrap();
4284            reopened.rebuild_overlay();
4285            assert!(reopened
4286                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
4287                .is_none());
4288            let retained = reopened.commit_outcome("retained").unwrap().unwrap();
4289            assert!(retained.retained_rows.is_some());
4290            assert_eq!(
4291                retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
4292                json!("mine")
4293            );
4294            reopened
4295                .purge_local_data(&LocalDataPurgeInput {
4296                    purge_id: "protected-purge".into(),
4297                    targets: vec![LocalDataPurgeTarget {
4298                        table: "tasks".into(),
4299                        selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
4300                    }],
4301                })
4302                .unwrap();
4303            assert!(reopened
4304                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
4305                .is_none());
4306            assert!(reopened
4307                .commit_outcome("retained")
4308                .unwrap()
4309                .unwrap()
4310                .retained_rows
4311                .is_none());
4312        }
4313        std::fs::remove_file(path).unwrap();
4314    }
4315
4316    #[test]
4317    fn durable_conflict_outcome_and_resolution_survive_reopen() {
4318        let path = std::env::temp_dir().join(format!(
4319            "syncular-durable-outcome-{}.db",
4320            uuid::Uuid::new_v4()
4321        ));
4322        let schema = json!({
4323            "version": 1,
4324            "tables": [{
4325                "name": "tasks",
4326                "primaryKey": "id",
4327                "columns": [
4328                    { "name": "id", "type": "string", "nullable": false },
4329                    { "name": "project_id", "type": "string", "nullable": false }
4330                ],
4331                "scopes": [{ "pattern": "project:{project_id}" }]
4332            }]
4333        });
4334        let conflict = ConflictRecord {
4335            client_commit_id: "losing-commit".to_owned(),
4336            op_index: 0,
4337            table: "tasks".to_owned(),
4338            row_id: "t1".to_owned(),
4339            code: "sync.version_conflict".to_owned(),
4340            message: "stale base version".to_owned(),
4341            server_version: 2,
4342            server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
4343            conflict_columns: vec!["project_id".to_owned()],
4344            operation: Some(CommitOperation {
4345                table: "tasks".to_owned(),
4346                row_id: "t1".to_owned(),
4347                op: "upsert".to_owned(),
4348                base_version: Some(1),
4349                values: None,
4350            }),
4351        };
4352        let failed_operations = vec![
4353            OutboxOp {
4354                upsert: true,
4355                table: "tasks".to_owned(),
4356                row_id: "t1".to_owned(),
4357                base_version: Some(1),
4358                values: None,
4359            },
4360            OutboxOp {
4361                upsert: true,
4362                table: "tasks".to_owned(),
4363                row_id: "status-event-1".to_owned(),
4364                base_version: Some(0),
4365                values: None,
4366            },
4367        ];
4368
4369        {
4370            let mut first = SyncClient::open_path(
4371                "durable-native".to_owned(),
4372                &schema,
4373                ClientLimits::default(),
4374                path.to_str().expect("UTF-8 temp path"),
4375            )
4376            .expect("first open");
4377            first
4378                .begin_observation("test_outcome")
4379                .expect("begin outcome");
4380            first
4381                .persist_commit_outcome(
4382                    "losing-commit",
4383                    CommitOutcomeStatus::Conflict,
4384                    &[CommitOperationOutcome::Conflict {
4385                        conflict: conflict.clone(),
4386                    }],
4387                    Some(&failed_operations),
4388                )
4389                .expect("persist outcome");
4390            first.conflicts.push(conflict);
4391            first
4392                .finish_observation(
4393                    "test_outcome",
4394                    ChangeAccumulator {
4395                        conflicts: true,
4396                        outcomes: true,
4397                        ..ChangeAccumulator::default()
4398                    },
4399                )
4400                .expect("commit outcome");
4401        }
4402
4403        {
4404            let mut reopened = SyncClient::open_path(
4405                "durable-native".to_owned(),
4406                &schema,
4407                ClientLimits::default(),
4408                path.to_str().expect("UTF-8 temp path"),
4409            )
4410            .expect("reopen");
4411            assert_eq!(reopened.conflicts().len(), 1);
4412            assert_eq!(
4413                reopened.conflicts()[0].conflict_columns,
4414                vec!["project_id".to_owned()],
4415                "conflictColumns survives the journal round trip"
4416            );
4417            let outcome = reopened
4418                .commit_outcome("losing-commit")
4419                .expect("read outcome")
4420                .expect("outcome");
4421            assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
4422            let operations = outcome.operations.expect("aggregate envelope");
4423            assert_eq!(operations.len(), 2);
4424            assert_eq!(operations[1].row_id, "status-event-1");
4425            let resolved = reopened
4426                .resolve_commit_outcome(ResolveCommitOutcomeInput {
4427                    client_commit_id: "losing-commit".to_owned(),
4428                    resolution: CommitOutcomeResolution::ResolvedKeepServer,
4429                    replacement_client_commit_id: None,
4430                })
4431                .expect("resolve");
4432            assert_eq!(
4433                resolved.resolution,
4434                CommitOutcomeResolution::ResolvedKeepServer
4435            );
4436            assert!(reopened.conflicts().is_empty());
4437        }
4438
4439        let reopened = SyncClient::open_path(
4440            "durable-native".to_owned(),
4441            &schema,
4442            ClientLimits::default(),
4443            path.to_str().expect("UTF-8 temp path"),
4444        )
4445        .expect("second reopen");
4446        assert!(reopened.conflicts().is_empty());
4447        assert_eq!(
4448            reopened
4449                .commit_outcome("losing-commit")
4450                .expect("read outcome")
4451                .expect("outcome")
4452                .resolution,
4453            CommitOutcomeResolution::ResolvedKeepServer
4454        );
4455        drop(reopened);
4456        std::fs::remove_file(path).expect("remove temp database");
4457    }
4458
4459    #[test]
4460    fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
4461        let path =
4462            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
4463        let schema = json!({
4464            "version": 1,
4465            "tables": [{
4466                "name": "tasks",
4467                "primaryKey": "id",
4468                "columns": [
4469                    { "name": "id", "type": "string", "nullable": false },
4470                    { "name": "project_id", "type": "string", "nullable": false }
4471                ],
4472                "scopes": [{ "pattern": "project:{project_id}" }]
4473            }]
4474        });
4475        let mut client = SyncClient::open_path_with_identity(
4476            Some("sidecar-client".to_owned()),
4477            &schema,
4478            ClientLimits::default(),
4479            path.to_str().expect("UTF-8 temp path"),
4480        )
4481        .expect("open owner");
4482        let base = WindowBase {
4483            table: "tasks".to_owned(),
4484            variable: "project_id".to_owned(),
4485            fixed_scopes: Vec::new(),
4486            params: None,
4487        };
4488        client
4489            .set_window(&base, &["one".to_owned()])
4490            .expect("set window");
4491        client
4492            .mutate(vec![Mutation::Upsert {
4493                table: "tasks".to_owned(),
4494                values: Map::from_iter([
4495                    ("id".to_owned(), Value::from("t1")),
4496                    ("project_id".to_owned(), Value::from("one")),
4497                ]),
4498                base_version: None,
4499            }])
4500            .expect("local mutate");
4501
4502        let coverage = [WindowCoverage {
4503            base,
4504            units: vec!["one".to_owned(), "missing".to_owned()],
4505        }];
4506        let owner = client
4507            .query_snapshot(
4508                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
4509                &[],
4510                &coverage,
4511                None,
4512            )
4513            .expect("owner snapshot");
4514        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
4515        let sidecar = reader
4516            .query_snapshot(
4517                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
4518                &[],
4519                &coverage,
4520            )
4521            .expect("sidecar snapshot");
4522
4523        assert_eq!(sidecar.revision, owner.revision);
4524        assert_eq!(sidecar.rows, owner.rows);
4525        assert_eq!(
4526            serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
4527            serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
4528        );
4529        assert_eq!(sidecar.revision, "2");
4530        assert_eq!(sidecar.rows[0]["id"], "t1");
4531        assert_eq!(sidecar.rows[0]["server_version"], -1);
4532        assert!(!sidecar.coverage.complete);
4533        assert_eq!(sidecar.coverage.pending.len(), 1);
4534        assert_eq!(sidecar.coverage.missing.len(), 1);
4535
4536        drop(reader);
4537        drop(client);
4538        std::fs::remove_file(path).expect("remove temp database");
4539    }
4540
4541    #[test]
4542    fn owned_query_failures_classify_order_bound_and_clear() {
4543        let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
4544            rusqlite::ffi::Error::new(266),
4545            Some("private SQLite prose".to_owned()),
4546        ));
4547        assert_eq!(io.code, Some("client.storage_io"));
4548        assert_eq!(io.sqlite_code, Some(266));
4549        assert_eq!(io.message, "local SQLite storage I/O failed");
4550        let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
4551            rusqlite::ffi::Error::new(26),
4552            None,
4553        ));
4554        assert_eq!(corrupt.code, Some("client.storage_corrupt"));
4555
4556        let mut client = client();
4557        let generic = QueryReadFailure::from("private query prose".to_owned());
4558        client.record_query_read(
4559            QueryOwner {
4560                id: "first",
4561                tables: &["tasks", "tasks"],
4562            },
4563            Some(&generic),
4564        );
4565        client.record_query_read(
4566            QueryOwner {
4567                id: "second",
4568                tables: &["tasks"],
4569            },
4570            Some(&generic),
4571        );
4572        client.record_query_read(
4573            QueryOwner {
4574                id: "first",
4575                tables: &["docs"],
4576            },
4577            Some(&io),
4578        );
4579        let failures = &client
4580            .diagnostics_snapshot(&Default::default())
4581            .expect("diagnostics")
4582            .query_failures;
4583        assert_eq!(
4584            failures
4585                .iter()
4586                .map(|entry| entry.id.as_str())
4587                .collect::<Vec<_>>(),
4588            vec!["second", "first"]
4589        );
4590        assert_eq!(failures[1].tables, vec!["docs"]);
4591        assert_eq!(failures[1].code, "client.storage_io");
4592
4593        for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
4594            client.record_query_read(
4595                QueryOwner {
4596                    id: &format!("query-{index}"),
4597                    tables: &["tasks"],
4598                },
4599                Some(&generic),
4600            );
4601        }
4602        let failures = &client
4603            .diagnostics_snapshot(&Default::default())
4604            .expect("bounded diagnostics")
4605            .query_failures;
4606        assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
4607        assert_eq!(failures.last().expect("last failure").id, "query-256");
4608
4609        client
4610            .query_snapshot(
4611                "SELECT id FROM tasks",
4612                &[],
4613                &[],
4614                Some(QueryOwner {
4615                    id: "query-256",
4616                    tables: &["tasks"],
4617                }),
4618            )
4619            .expect("successful owned read");
4620        assert!(client
4621            .diagnostics_snapshot(&Default::default())
4622            .expect("cleared diagnostics")
4623            .query_failures
4624            .iter()
4625            .all(|entry| entry.id != "query-256"));
4626    }
4627
4628    #[test]
4629    fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
4630        let mut client = client();
4631        let image = Connection::open_in_memory().unwrap();
4632        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
4633            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4634            INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
4635            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4636            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
4637        let table = client.schema.table("tasks").unwrap().clone();
4638        let scopes = vec![("project_id".into(), vec!["p1".into()])];
4639        assert!(client
4640            .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
4641            .is_err());
4642        assert!(client.conn.is_autocommit());
4643        assert_eq!(
4644            client
4645                .query("SELECT count(*) AS n FROM tasks", &[])
4646                .unwrap()[0]["n"],
4647            1024
4648        );
4649        assert_eq!(client.drain_change_batches().len(), 1);
4650        image
4651            .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
4652            .unwrap();
4653        assert!(matches!(
4654            client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
4655            Ok(2050)
4656        ));
4657        assert_eq!(
4658            client
4659                .query("SELECT count(*) AS n FROM tasks", &[])
4660                .unwrap()[0]["n"],
4661            2050
4662        );
4663        assert_eq!(client.drain_change_batches().len(), 3);
4664    }
4665
4666    #[test]
4667    fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
4668        let mut client = client();
4669        let base = WindowBase {
4670            table: "tasks".into(),
4671            variable: "project_id".into(),
4672            fixed_scopes: Vec::new(),
4673            params: None,
4674        };
4675        client
4676            .set_window(&base, &["p1".into(), "p2".into()])
4677            .unwrap();
4678        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4679            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
4680            INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
4681            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
4682            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
4683        client.drain_change_batches();
4684        assert!(client.set_window(&base, &["p2".into()]).is_err());
4685        assert!(client.conn.is_autocommit());
4686        assert_eq!(
4687            client
4688                .query("SELECT count(*) AS n FROM tasks", &[])
4689                .unwrap()[0]["n"],
4690            1027
4691        );
4692        assert_eq!(client.window_state(&base).units, vec!["p2"]);
4693        assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
4694        assert_eq!(client.drain_change_batches().len(), 1);
4695        client
4696            .conn
4697            .execute_batch("DROP TRIGGER interrupt_evict")
4698            .unwrap();
4699        client.drain_pending_evictions().unwrap();
4700        assert!(client.load_pending_evictions().unwrap().is_empty());
4701        assert_eq!(
4702            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
4703            "held"
4704        );
4705        assert_eq!(client.drain_change_batches().len(), 2);
4706    }
4707
4708    #[test]
4709    fn eviction_marker_failures_roll_back_window_changes() {
4710        let mut client = client();
4711        let base = WindowBase {
4712            table: "tasks".into(),
4713            variable: "project_id".into(),
4714            fixed_scopes: Vec::new(),
4715            params: None,
4716        };
4717        client.set_window(&base, &["p1".into()]).unwrap();
4718        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4719            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
4720            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
4721            CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4722        let revision = client.local_revision();
4723        assert!(client.set_window(&base, &[]).is_err());
4724        assert!(client.conn.is_autocommit());
4725        assert_eq!(client.local_revision(), revision);
4726        assert_eq!(
4727            client
4728                .query("SELECT count(*) AS n FROM tasks", &[])
4729                .unwrap()[0]["n"],
4730            2050
4731        );
4732        assert_eq!(client.window_state(&base).units, vec!["p1"]);
4733        client.conn.execute_batch("DROP TRIGGER fail_marker;
4734            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
4735        assert!(client.set_window(&base, &[]).is_err());
4736        client.conn.execute_batch("DROP TRIGGER interrupt_evict;
4737            CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4738        assert!(client.drain_pending_evictions().is_err());
4739        assert!(client.conn.is_autocommit());
4740        assert_eq!(
4741            client
4742                .query("SELECT count(*) AS n FROM tasks", &[])
4743                .unwrap()[0]["n"],
4744            2
4745        );
4746        assert!(client.set_window(&base, &["p1".into()]).is_err());
4747        assert!(client.conn.is_autocommit());
4748        assert!(client.window_state(&base).units.is_empty());
4749        assert!(client.subs.is_empty());
4750        assert_eq!(
4751            client
4752                .query(
4753                    "SELECT count(*) AS n FROM _syncular_window_pending_evict",
4754                    &[]
4755                )
4756                .unwrap()[0]["n"],
4757            1
4758        );
4759        client
4760            .conn
4761            .execute_batch("DROP TRIGGER fail_marker")
4762            .unwrap();
4763        client.set_window(&base, &["p1".into()]).unwrap();
4764        assert_eq!(client.window_state(&base).units, vec!["p1"]);
4765    }
4766
4767    #[test]
4768    fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
4769        let mut client = client();
4770        client
4771            .conn
4772            .execute(
4773                "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
4774                [],
4775            )
4776            .unwrap();
4777        assert_eq!(
4778            client.drain_pending_evictions().unwrap_err(),
4779            "sync.local_corrupt: invalid pending eviction scopes"
4780        );
4781        assert!(client.conn.is_autocommit());
4782        assert!(client.enqueue_startup_sync_if_needed().is_err());
4783    }
4784
4785    #[test]
4786    fn image_chunks_reject_null_primary_keys() {
4787        let mut client = client();
4788        let image = Connection::open_in_memory().unwrap();
4789        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4790            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
4791            INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
4792            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4793            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
4794        let table = client.schema.table("tasks").unwrap().clone();
4795        assert!(client
4796            .apply_sqlite_image(
4797                &image,
4798                &table,
4799                true,
4800                &[("project_id".into(), vec!["p1".into()])],
4801                1025,
4802                7,
4803                "digest"
4804            )
4805            .is_err());
4806        assert!(client.conn.is_autocommit());
4807        assert_eq!(
4808            client
4809                .query("SELECT count(*) AS n FROM tasks", &[])
4810                .unwrap()[0]["n"],
4811            0
4812        );
4813    }
4814
4815    #[test]
4816    fn import_chunks_with_unique_constraints_match_full_pending_replay() {
4817        for images in [false, true] {
4818            let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4819                "name":name,"primaryKey":"id","columns":[
4820                    {"name":"id","type":"string","nullable":false},
4821                    {"name":"project_id","type":"string","nullable":false},
4822                    {"name":"title","type":"string","nullable":false}],
4823                "scopes":[{"pattern":"project:{project_id}"}],
4824                "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
4825                "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4826            })))});
4827            let mut actual =
4828                SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
4829            let mut reference =
4830                SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
4831            let scopes = vec![("project_id".into(), vec!["p1".into()])];
4832            for client in [&mut actual, &mut reference] {
4833                client
4834                    .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
4835                    .unwrap();
4836                client.subs[0].effective = Some(scopes.clone());
4837                for (table, id, title) in [
4838                    ("tasks", "a", "original"),
4839                    ("tasks", "b", "occupied"),
4840                    ("tasks", "c", "deleted"),
4841                    ("notes", "n", "untouched"),
4842                ] {
4843                    client
4844                        .write_base_row(
4845                            table,
4846                            &vec![
4847                                Some(ColumnValue::String(id.into())),
4848                                Some(ColumnValue::String("p1".into())),
4849                                Some(ColumnValue::String(title.into())),
4850                            ],
4851                            1,
4852                        )
4853                        .unwrap();
4854                }
4855                client.rebuild_overlay_if_dirty();
4856                client
4857                    .mutate(vec![
4858                        Mutation::Upsert {
4859                            table: "tasks".into(),
4860                            values: Map::from_iter([
4861                                ("id".into(), Value::from("a")),
4862                                ("project_id".into(), Value::from("p1")),
4863                                ("title".into(), Value::from("available")),
4864                            ]),
4865                            base_version: None,
4866                        },
4867                        Mutation::Delete {
4868                            table: "tasks".into(),
4869                            row_id: "c".into(),
4870                            base_version: None,
4871                        },
4872                        Mutation::Upsert {
4873                            table: "tasks".into(),
4874                            values: Map::from_iter([
4875                                ("id".into(), Value::from("local")),
4876                                ("project_id".into(), Value::from("p1")),
4877                                ("title".into(), Value::from("local value")),
4878                            ]),
4879                            base_version: None,
4880                        },
4881                        Mutation::Upsert {
4882                            table: "notes".into(),
4883                            values: Map::from_iter([
4884                                ("id".into(), Value::from("n")),
4885                                ("project_id".into(), Value::from("p1")),
4886                                ("title".into(), Value::from("pending note")),
4887                            ]),
4888                            base_version: None,
4889                        },
4890                    ])
4891                    .unwrap();
4892            }
4893            let pending = actual.pending_commit_ids();
4894            actual.overlay_rebuild_count.set(0);
4895            for (id, title) in [
4896                ("b", "available"),
4897                ("b", "occupied"),
4898                ("a", "server original"),
4899                ("d", "local value"),
4900                ("d", "released"),
4901                ("c", "redelivered"),
4902            ] {
4903                let table = actual.schema.table("tasks").unwrap().clone();
4904                let values = vec![
4905                    Some(ColumnValue::String(id.into())),
4906                    Some(ColumnValue::String("p1".into())),
4907                    Some(ColumnValue::String(title.into())),
4908                ];
4909                if images {
4910                    let image = Connection::open_in_memory().unwrap();
4911                    image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4912                        CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4913                        INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4914                    image
4915                        .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
4916                        .unwrap();
4917                    assert!(matches!(
4918                        actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
4919                        Ok(1)
4920                    ));
4921                } else {
4922                    let segment = RowsSegment {
4923                        table: "tasks".into(),
4924                        schema_version: 1,
4925                        columns: table.wire_columns.clone(),
4926                        blocks: vec![vec![ssp2::segment::SegmentRow {
4927                            server_version: 2,
4928                            values: values.clone(),
4929                        }]],
4930                    };
4931                    assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
4932                }
4933                reference.write_base_row("tasks", &values, 2).unwrap();
4934                reference.rebuild_overlay();
4935                for table in ["tasks", "notes"] {
4936                    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")] {
4937                        assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
4938                    }
4939                }
4940                assert_eq!(actual.pending_commit_ids(), pending);
4941            }
4942            assert_eq!(
4943                actual.overlay_rebuild_count.get(),
4944                0,
4945                "each chunk must leave unrelated tables alone"
4946            );
4947            let before = actual
4948                .query("SELECT * FROM tasks ORDER BY id", &[])
4949                .unwrap();
4950            let fts_before = actual
4951                .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4952                .unwrap();
4953            let revision = actual.local_revision();
4954            actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
4955            let table = actual.schema.table("tasks").unwrap().clone();
4956            let segment = RowsSegment {
4957                table: "tasks".into(),
4958                schema_version: 1,
4959                columns: table.wire_columns.clone(),
4960                blocks: vec![vec![ssp2::segment::SegmentRow {
4961                    server_version: 3,
4962                    values: vec![
4963                        Some(ColumnValue::String("b".into())),
4964                        Some(ColumnValue::String("p1".into())),
4965                        Some(ColumnValue::String("fresh".into())),
4966                    ],
4967                }]],
4968            };
4969            assert!(actual.apply_segment(0, &segment, false).is_err());
4970            assert!(actual.conn.is_autocommit());
4971            assert_eq!(
4972                actual
4973                    .query("SELECT * FROM tasks ORDER BY id", &[])
4974                    .unwrap(),
4975                before
4976            );
4977            assert_eq!(
4978                actual
4979                    .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4980                    .unwrap(),
4981                fts_before
4982            );
4983            assert_eq!(actual.local_revision(), revision);
4984            assert_eq!(actual.pending_commit_ids(), pending);
4985        }
4986    }
4987
4988    #[test]
4989    fn import_reconciliation_preserves_typed_pending_primary_keys() {
4990        for (kind, a, b) in [
4991            ("string", json!("a"), json!("b")),
4992            ("integer", json!(1), json!(2)),
4993            ("boolean", json!(true), json!(false)),
4994            ("json", json!("1"), json!("2")),
4995        ] {
4996            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}]}]});
4997            for images in [false, true] {
4998                let mut client =
4999                    SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
5000                let table = client.schema.table("tasks").unwrap().clone();
5001                client
5002                    .subscribe("tasks".into(), "tasks".into(), vec![], None)
5003                    .unwrap();
5004                for (id, title) in [(&a, "original"), (&b, "occupied")] {
5005                    client
5006                        .write_base_row(
5007                            "tasks",
5008                            &vec![
5009                                json_to_column_value(&table.columns[0], Some(id)).unwrap(),
5010                                Some(ColumnValue::String(title.into())),
5011                            ],
5012                            1,
5013                        )
5014                        .unwrap();
5015                }
5016                client.rebuild_overlay_if_dirty();
5017                client
5018                    .mutate(vec![Mutation::Upsert {
5019                        table: "tasks".into(),
5020                        values: Map::from_iter([
5021                            ("id".into(), a.clone()),
5022                            ("title".into(), json!("available")),
5023                        ]),
5024                        base_version: None,
5025                    }])
5026                    .unwrap();
5027                client.overlay_rebuild_count.set(0);
5028                for (title, expected) in [("available", "original"), ("occupied", "available")] {
5029                    let values = vec![
5030                        json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
5031                        Some(ColumnValue::String(title.into())),
5032                    ];
5033                    if images {
5034                        let image = Connection::open_in_memory().unwrap();
5035                        image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
5036                            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
5037                            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
5038                        image
5039                            .execute(
5040                                "INSERT INTO tasks VALUES (?1,?2,2)",
5041                                rusqlite::params![RowParam::Cell(&values[0]), title],
5042                            )
5043                            .unwrap();
5044                        assert!(
5045                            matches!(
5046                                client.apply_sqlite_image(
5047                                    &image,
5048                                    &table,
5049                                    false,
5050                                    &[],
5051                                    1,
5052                                    7,
5053                                    "digest"
5054                                ),
5055                                Ok(1)
5056                            ),
5057                            "{kind}, images={images}"
5058                        );
5059                    } else {
5060                        let segment = RowsSegment {
5061                            table: "tasks".into(),
5062                            schema_version: 1,
5063                            columns: table.wire_columns.clone(),
5064                            blocks: vec![vec![ssp2::segment::SegmentRow {
5065                                server_version: 2,
5066                                values,
5067                            }]],
5068                        };
5069                        assert!(
5070                            matches!(client.apply_segment(0, &segment, false), Ok(1)),
5071                            "{kind}"
5072                        );
5073                    }
5074                    let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
5075                    let actual: String = client
5076                        .conn
5077                        .query_row(
5078                            "SELECT title FROM tasks WHERE id=?1",
5079                            [RowParam::Cell(&key)],
5080                            |row| row.get(0),
5081                        )
5082                        .unwrap();
5083                    assert_eq!(actual, expected, "{kind}, images={images}");
5084                }
5085                assert_eq!(client.overlay_rebuild_count.get(), 0);
5086            }
5087        }
5088    }
5089
5090    fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
5091        let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
5092            "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
5093            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
5094            "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
5095        })))});
5096        let mut client =
5097            SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
5098        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();
5099        client.rebuild_overlay();
5100        client
5101            .mutate(vec![Mutation::Upsert {
5102                table: "tasks".into(),
5103                values: Map::from_iter([
5104                    ("id".into(), json!("offline")),
5105                    ("project_id".into(), json!("p1")),
5106                    ("title".into(), json!("pending edit")),
5107                ]),
5108                base_version: None,
5109            }])
5110            .unwrap();
5111        let image = Connection::open_in_memory().unwrap();
5112        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
5113            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
5114        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();
5115        image
5116            .execute(
5117                "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
5118                [rows],
5119            )
5120            .unwrap();
5121        client.overlay_rebuild_count.set(0);
5122        let table = client.schema.table("tasks").unwrap().clone();
5123        (client, image, table)
5124    }
5125
5126    #[test]
5127    fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
5128        let (mut client, image, table) = unique_import_fixture(1);
5129        image
5130            .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
5131            .unwrap();
5132        client
5133            .conn
5134            .execute_batch("PRAGMA max_page_count = 1")
5135            .unwrap();
5136        let effective = [("project_id".into(), vec!["p1".into()])];
5137        assert!(client
5138            .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
5139            .is_err());
5140        let failure = client.storage_failure.borrow().clone().unwrap();
5141        assert_eq!(failure.code, Some("client.storage_full"));
5142        assert_eq!(failure.sqlite_code, Some(13));
5143        assert!(!failure.message.contains("rollback"));
5144        assert!(client.conn.is_autocommit());
5145        client
5146            .conn
5147            .execute_batch("PRAGMA max_page_count = 1073741823")
5148            .unwrap();
5149        assert!(matches!(
5150            client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
5151            Ok(1)
5152        ));
5153        assert!(client.conn.is_autocommit());
5154    }
5155
5156    #[test]
5157    fn large_unique_import_copies_no_unrelated_tables() {
5158        let (mut client, image, table) = unique_import_fixture(10240);
5159        let pending = client.pending_commit_ids();
5160        client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
5161            CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
5162        assert!(matches!(
5163            client.apply_sqlite_image(
5164                &image,
5165                &table,
5166                true,
5167                &[("project_id".into(), vec!["p1".into()])],
5168                10240,
5169                7,
5170                "digest"
5171            ),
5172            Ok(10240)
5173        ));
5174        assert_eq!(client.overlay_rebuild_count.get(), 0);
5175        assert_eq!(client.pending_commit_ids(), pending);
5176        assert_eq!(
5177            client
5178                .query("SELECT count(*) AS n FROM tasks", &[])
5179                .unwrap()[0]["n"],
5180            10241
5181        );
5182        assert_eq!(
5183            client
5184                .query("SELECT count(*) AS n FROM tasks_fts", &[])
5185                .unwrap()[0]["n"],
5186            10241
5187        );
5188        assert_eq!(
5189            client
5190                .query("SELECT count(*) AS n FROM notes_fts", &[])
5191                .unwrap()[0]["n"],
5192            8192
5193        );
5194    }
5195
5196    #[test]
5197    #[ignore = "manual scaling benchmark; see bench/README.md"]
5198    fn benchmark_unique_image_import() {
5199        let mut samples = Vec::new();
5200        for rows in [10240, 51200, 102400] {
5201            for trial in 0..4 {
5202                let (mut client, image, table) = unique_import_fixture(rows);
5203                let start = std::time::Instant::now();
5204                assert!(
5205                    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)
5206                );
5207                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
5208                assert_eq!(
5209                    client
5210                        .query("SELECT count(*) AS n FROM tasks", &[])
5211                        .unwrap()[0]["n"],
5212                    rows + 1
5213                );
5214                assert_eq!(
5215                    client
5216                        .query("SELECT count(*) AS n FROM tasks_fts", &[])
5217                        .unwrap()[0]["n"],
5218                    rows + 1
5219                );
5220                assert_eq!(
5221                    client
5222                        .query("SELECT count(*) AS n FROM notes_fts", &[])
5223                        .unwrap()[0]["n"],
5224                    8192
5225                );
5226                assert_eq!(client.pending_commit_ids().len(), 1);
5227                if trial > 0 {
5228                    samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
5229                }
5230            }
5231        }
5232        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});
5233        let output = serde_json::to_string_pretty(&artifact).unwrap();
5234        if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
5235            use std::io::Write;
5236            let mut file = std::fs::OpenOptions::new()
5237                .write(true)
5238                .create_new(true)
5239                .open(path)
5240                .unwrap();
5241            file.write_all(output.as_bytes()).unwrap();
5242        } else {
5243            println!("{output}");
5244        }
5245    }
5246
5247    #[test]
5248    fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
5249        let schema = json!({
5250            "version": 1,
5251            "tables": [{
5252                "name": "catalogue_codes",
5253                "primaryKey": "id",
5254                "columns": [
5255                    { "name": "id", "type": "string", "nullable": false },
5256                    { "name": "release_id", "type": "string", "nullable": false },
5257                    { "name": "code", "type": "string", "nullable": false },
5258                    { "name": "title", "type": "string", "nullable": false }
5259                ],
5260                "scopes": [{ "pattern": "release:{release_id}" }],
5261                "ftsIndexes": [{
5262                    "name": "catalogue_codes_fts",
5263                    "columns": ["code", "title"],
5264                    "tokenize": "unicode61 remove_diacritics 2"
5265                }]
5266            }]
5267        });
5268        let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
5269            .expect("FTS5 client");
5270        let insert_trigger: String = client
5271            .conn
5272            .query_row(
5273                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
5274                [],
5275                |row| row.get(0),
5276            )
5277            .expect("insert trigger");
5278        let replace_guard: String = client
5279            .conn
5280            .query_row(
5281                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
5282                [],
5283                |row| row.get(0),
5284            )
5285            .expect("replace guard");
5286        assert!(!insert_trigger.contains("DELETE FROM"));
5287        assert!(replace_guard.contains("BEFORE INSERT"));
5288        assert!(replace_guard.contains("WHEN EXISTS"));
5289        let search = |client: &SyncClient, query: &str| {
5290            client
5291                .query(
5292                    "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",
5293                    &[Value::from(query)],
5294                )
5295                .expect("FTS query")
5296        };
5297
5298        client
5299            .mutate(vec![Mutation::Upsert {
5300                table: "catalogue_codes".to_owned(),
5301                values: Map::from_iter([
5302                    ("id".to_owned(), Value::from("c1")),
5303                    ("release_id".to_owned(), Value::from("r1")),
5304                    ("code".to_owned(), Value::from("A01")),
5305                    ("title".to_owned(), Value::from("Cholera")),
5306                ]),
5307                base_version: None,
5308            }])
5309            .expect("insert code");
5310        assert_eq!(search(&client, "cholera").len(), 1);
5311        let prior = client
5312            .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
5313            .unwrap();
5314        client
5315            .conn
5316            .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
5317            .unwrap();
5318        client.create_synced_tables().unwrap();
5319        assert_eq!(
5320            client
5321                .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
5322                .unwrap(),
5323            prior
5324        );
5325        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();
5326        let rendered = serde_json::to_string(&plan).unwrap();
5327        assert!(rendered.contains("COVERING INDEX"));
5328        assert!(rendered.contains("INDEX 0:="));
5329        client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
5330        assert_eq!(search(&client, "cholera").len(), 1);
5331
5332        client
5333            .mutate(vec![Mutation::Upsert {
5334                table: "catalogue_codes".to_owned(),
5335                values: Map::from_iter([
5336                    ("id".to_owned(), Value::from("c1")),
5337                    ("release_id".to_owned(), Value::from("r1")),
5338                    ("code".to_owned(), Value::from("A01")),
5339                    ("title".to_owned(), Value::from("Enteric infection")),
5340                ]),
5341                base_version: None,
5342            }])
5343            .expect("update code");
5344        assert!(search(&client, "cholera").is_empty());
5345        assert_eq!(search(&client, "enteric").len(), 1);
5346
5347        client
5348            .mutate(vec![Mutation::Delete {
5349                table: "catalogue_codes".to_owned(),
5350                row_id: "c1".to_owned(),
5351                base_version: None,
5352            }])
5353            .expect("delete code");
5354        assert!(search(&client, "enteric").is_empty());
5355    }
5356
5357    #[test]
5358    fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
5359        let schema = json!({
5360            "version": 1,
5361            "tables": [{
5362                "name": "patient_notes",
5363                "primaryKey": "id",
5364                "columns": [
5365                    { "name": "id", "type": "string", "nullable": false },
5366                    { "name": "practice_id", "type": "string", "nullable": false },
5367                    { "name": "encryption_key_id", "type": "string", "nullable": false },
5368                    { "name": "title", "type": "string", "nullable": false }
5369                ],
5370                "scopes": [{ "pattern": "practice:{practice_id}" }],
5371                "ftsIndexes": [{
5372                    "name": "patient_notes_fts",
5373                    "columns": ["title"],
5374                    "tokenize": "unicode61 remove_diacritics 2"
5375                }]
5376            }]
5377        });
5378        let mut client = SyncClient::new(
5379            "local-purge-test".to_owned(),
5380            &schema,
5381            ClientLimits::default(),
5382        )
5383        .expect("local purge client");
5384        client
5385            .conn
5386            .execute(
5387                "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
5388                 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
5389                 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
5390                [],
5391            )
5392            .expect("seed base rows");
5393        client.overlay_dirty.set(true);
5394        client.rebuild_overlay_if_dirty();
5395
5396        let note = |id: &str, key_id: &str, title: &str| {
5397            Map::from_iter([
5398                ("id".to_owned(), Value::from(id)),
5399                ("practice_id".to_owned(), Value::from("practice-1")),
5400                ("encryption_key_id".to_owned(), Value::from(key_id)),
5401                ("title".to_owned(), Value::from(title)),
5402            ])
5403        };
5404        let doomed = client
5405            .mutate(vec![
5406                Mutation::Upsert {
5407                    table: "patient_notes".to_owned(),
5408                    values: note("target", "key-revoked", "Target changed"),
5409                    base_version: None,
5410                },
5411                Mutation::Upsert {
5412                    table: "patient_notes".to_owned(),
5413                    values: note("unrelated", "key-held", "Unrelated changed"),
5414                    base_version: None,
5415                },
5416            ])
5417            .expect("doomed commit");
5418        let kept = client
5419            .mutate(vec![Mutation::Upsert {
5420                table: "patient_notes".to_owned(),
5421                values: note("kept", "key-held", "Kept optimistic"),
5422                base_version: None,
5423            }])
5424            .expect("kept commit");
5425        client.drain_change_batches();
5426
5427        let input = LocalDataPurgeInput {
5428            purge_id: "purge-001".to_owned(),
5429            targets: vec![LocalDataPurgeTarget {
5430                table: "patient_notes".to_owned(),
5431                selectors: BTreeMap::from([(
5432                    "encryption_key_id".to_owned(),
5433                    vec!["key-revoked".to_owned()],
5434                )]),
5435            }],
5436        };
5437        assert_eq!(
5438            client.purge_local_data(&input).expect("apply purge"),
5439            LocalDataPurgeResult {
5440                already_applied: false,
5441                purged_rows: 1,
5442                dropped_commits: 1,
5443            }
5444        );
5445        let rows = client
5446            .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
5447            .expect("visible rows");
5448        assert_eq!(rows.len(), 2);
5449        assert_eq!(rows[0]["id"], "kept");
5450        assert_eq!(rows[1]["id"], "unrelated");
5451        assert_eq!(rows[1]["title"], "Unrelated original");
5452        let fts = client
5453            .query(
5454                "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'",
5455                &[],
5456            )
5457            .expect("fts query");
5458        assert!(fts.is_empty());
5459        assert_eq!(client.outbox.len(), 1);
5460        assert_eq!(client.outbox[0].client_commit_id, kept);
5461        let outcome = client
5462            .commit_outcome(&doomed)
5463            .expect("read doomed outcome")
5464            .expect("doomed outcome");
5465        assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
5466        match &outcome.results[0] {
5467            CommitOperationOutcome::Error { rejection } => {
5468                assert_eq!(rejection.code, "client.local_data_purged");
5469                assert_eq!(rejection.client_commit_id, doomed);
5470            }
5471            other => panic!("expected local purge rejection, got {other:?}"),
5472        }
5473        assert_eq!(client.drain_change_batches().len(), 1);
5474        assert_eq!(
5475            client.purge_local_data(&input).expect("retry purge"),
5476            LocalDataPurgeResult {
5477                already_applied: true,
5478                purged_rows: 0,
5479                dropped_commits: 0,
5480            }
5481        );
5482        let conflicting = LocalDataPurgeInput {
5483            purge_id: input.purge_id.clone(),
5484            targets: vec![LocalDataPurgeTarget {
5485                table: "patient_notes".to_owned(),
5486                selectors: BTreeMap::from([(
5487                    "encryption_key_id".to_owned(),
5488                    vec!["key-held".to_owned()],
5489                )]),
5490            }],
5491        };
5492        assert!(client
5493            .purge_local_data(&conflicting)
5494            .expect_err("id collision must fail")
5495            .contains("already used with a different plan"));
5496    }
5497}
5498
5499impl SubState {
5500    fn name(self) -> &'static str {
5501        match self {
5502            SubState::Active => "active",
5503            SubState::Revoked => "revoked",
5504            SubState::Failed => "failed",
5505        }
5506    }
5507
5508    fn parse(value: &str) -> Self {
5509        match value {
5510            "revoked" => Self::Revoked,
5511            "failed" => Self::Failed,
5512            _ => Self::Active,
5513        }
5514    }
5515}
5516
5517#[derive(Debug, Clone)]
5518struct Subscription {
5519    id: String,
5520    table: String,
5521    requested: Vec<(String, Vec<String>)>,
5522    params: Option<String>,
5523    cursor: i64,
5524    /// §4.7 resume token, round-tripped opaquely.
5525    bootstrap_state: Option<String>,
5526    state: SubState,
5527    reason_code: Option<String>,
5528    /// Last effective scopes echoed while active (§3.3: persisted for the
5529    /// purge contract; each active echo replaces it).
5530    effective: Option<Vec<(String, Vec<String>)>>,
5531    synced_once: bool,
5532}
5533
5534#[derive(Debug, Clone)]
5535struct OutboxOp {
5536    upsert: bool,
5537    table: String,
5538    row_id: String,
5539    base_version: Option<i64>,
5540    /// Schema-agnostic local form (§0): driver JSON values, encoded with
5541    /// the current codec at send time. A key's presence IS the §6.1 sparse
5542    /// presence set — both the overlay and the wire payload derive from it.
5543    values: Option<Map<String, Value>>,
5544}
5545
5546impl From<&OutboxOp> for CommitOperation {
5547    fn from(operation: &OutboxOp) -> Self {
5548        Self {
5549            table: operation.table.clone(),
5550            row_id: operation.row_id.clone(),
5551            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
5552            base_version: operation.base_version,
5553            values: operation.values.clone(),
5554        }
5555    }
5556}
5557
5558#[derive(Debug, Clone)]
5559struct OutboxCommit {
5560    client_commit_id: String,
5561    ops: Vec<OutboxOp>,
5562}
5563
5564/// One commit's `(table, value keys)` per upsert operation — the classification
5565/// shape the §7.4.4 send-time drop and the RFC 0005 D6 audit share, so the two
5566/// can never disagree about which commits cannot re-encode.
5567fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
5568    commit
5569        .ops
5570        .iter()
5571        .filter(|operation| operation.upsert)
5572        .map(|operation| {
5573            (
5574                operation.table.as_str(),
5575                operation
5576                    .values
5577                    .as_ref()
5578                    .map(|values| values.keys().map(String::as_str).collect())
5579                    .unwrap_or_default(),
5580            )
5581        })
5582        .collect()
5583}
5584
5585type FailedCommit = (OutboxCommit, Map<String, Value>);
5586
5587#[derive(Debug, Clone)]
5588struct CompiledLocalDataPurgeTarget {
5589    table: String,
5590    selectors: Vec<(String, Vec<String>)>,
5591}
5592
5593struct StoredCommitOutcomeRow {
5594    sequence: i64,
5595    client_commit_id: String,
5596    status: String,
5597    recorded_at_ms: i64,
5598    results_json: String,
5599    operations_json: Option<String>,
5600    resolution: String,
5601    resolved_at_ms: Option<i64>,
5602    replacement_client_commit_id: Option<String>,
5603}
5604
5605/// Section outcome distinguishing the §5.6 subscription-local fail-closed
5606/// path from a round-aborting failure (§1.4 rule 5).
5607enum SectionError {
5608    FailClosed,
5609    Abort(String, String),
5610}
5611
5612pub(crate) struct RequestMeta {
5613    pushed_ids: Vec<String>,
5614    /// Subscription id → the request carried `cursor < 0` and no resume
5615    /// token (§5.6 first-page detection: a *fresh* bootstrap).
5616    fresh: Vec<(String, bool)>,
5617    pub(crate) accept: u8,
5618    /// §6.1 splitBatch: outbox commits held back from THIS request because
5619    /// the running operation count reached the push cap — the next round
5620    /// pushes them (`sync_needed` stays set while any remain).
5621    deferred_commits: usize,
5622}
5623
5624#[derive(Default)]
5625struct ChangeAccumulator {
5626    /// None means table-wide; Some is the exact scoped domain.
5627    tables: BTreeMap<String, Option<BTreeSet<String>>>,
5628    windows: BTreeMap<(String, String), BTreeSet<String>>,
5629    status: bool,
5630    conflicts: bool,
5631    rejections: bool,
5632    outcomes: bool,
5633}
5634
5635impl ChangeAccumulator {
5636    fn table(&mut self, table: &str) {
5637        self.tables.insert(table.to_owned(), None);
5638    }
5639
5640    fn scope(&mut self, table: &str, key: String) {
5641        match self.tables.get_mut(table) {
5642            Some(None) => {}
5643            Some(Some(keys)) => {
5644                keys.insert(key);
5645            }
5646            None => {
5647                self.tables
5648                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
5649            }
5650        }
5651    }
5652
5653    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
5654        self.windows
5655            .entry((base_key.to_owned(), table.to_owned()))
5656            .or_default()
5657            .insert(unit.to_owned());
5658    }
5659
5660    fn touched(&self) -> bool {
5661        !self.tables.is_empty()
5662            || !self.windows.is_empty()
5663            || self.status
5664            || self.conflicts
5665            || self.rejections
5666            || self.outcomes
5667    }
5668}
5669
5670/// §6.1: the server caps total operations per request (reference default
5671/// 500) and rejects the whole batch with `sync.too_many_operations`; the
5672/// client "splits and retries". Splitting happens at build time: commits are
5673/// included IN ORDER until the operation budget is spent, the rest wait for
5674/// the next round.
5675const PUSH_OPS_PER_REQUEST: usize = 500;
5676const MAX_LOCAL_PURGE_TARGETS: usize = 64;
5677const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
5678const MAX_LOCAL_PURGE_VALUES: usize = 128;
5679const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
5680pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
5681
5682/// Dirty visible tables; `None` means a whole-replica recovery/reset.
5683struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
5684
5685impl OverlayDirty {
5686    fn clean() -> Self {
5687        Self(RefCell::new(Some(BTreeSet::new())))
5688    }
5689    fn get(&self) -> bool {
5690        self.0
5691            .borrow()
5692            .as_ref()
5693            .is_none_or(|tables| !tables.is_empty())
5694    }
5695    fn set(&self, dirty: bool) {
5696        *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
5697    }
5698    fn table(&self, table: &str) {
5699        if let Some(tables) = self.0.borrow_mut().as_mut() {
5700            tables.insert(table.to_owned());
5701        }
5702    }
5703    fn snapshot(&self) -> Option<BTreeSet<String>> {
5704        self.0.borrow().clone()
5705    }
5706    fn restore(&self, tables: Option<BTreeSet<String>>) {
5707        *self.0.borrow_mut() = tables;
5708    }
5709}
5710
5711pub struct SyncClient {
5712    #[cfg(feature = "bench-internals")]
5713    benchmark_phases: Recorder,
5714    conn: Connection,
5715    schema: ClientSchema,
5716    client_id: String,
5717    limits: ClientLimits,
5718    subs: Vec<Subscription>,
5719    outbox: Vec<OutboxCommit>,
5720    failed_commits: Vec<FailedCommit>,
5721    retain_failed_commits: bool,
5722    conflicts: Vec<ConflictRecord>,
5723    rejections: Vec<RejectionRecord>,
5724    schema_floor: Option<SchemaFloor>,
5725    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
5726    lease_state: Option<LeaseState>,
5727    /// §1.6: the schema-floor response stops syncing until an upgrade.
5728    stopped: bool,
5729    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
5730    upgrading: bool,
5731    /// §8.4 coalesced sync-needed signal.
5732    sync_needed: bool,
5733    /// §8.8: how sync rounds treat the realtime binding, and its exact
5734    /// availability state with the evidence behind a lost or refused one.
5735    realtime_policy: RealtimePolicy,
5736    realtime_state: RealtimeState,
5737    realtime_reason_code: Option<String>,
5738    realtime_retry_delay_ms: Option<u64>,
5739    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
5740    presence: HashMap<String, HashMap<String, PresencePeer>>,
5741    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
5742    /// host may pin it (conformance runs on a virtual clock).
5743    now_ms: Option<i64>,
5744    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
5745    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
5746    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
5747    encryption: crate::values::EncryptionConfig,
5748    /// Fail-closed host bootstrap gate. While set, command hosts permit only
5749    /// status/lifecycle inspection and an exact authorized local purge.
5750    security_preflight: bool,
5751    transport_enabled: bool,
5752    /// RFC 0005 D2/D8: the previous-version capture config. `None` (default)
5753    /// means the feature is off; the descriptor write and the container orphan
5754    /// sweep still happen.
5755    previous_version: Option<PreviousVersionContextConfig>,
5756    /// Per-table primary-key upsert SQL, built once per (full table name) —
5757    /// the row write path runs per row during bootstrap (§5.6), so the SQL
5758    /// string (and, via `prepare_cached`, its compiled statement) is reused
5759    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
5760    /// schema reset (the column lists may have changed).
5761    insert_sql: RefCell<HashMap<String, String>>,
5762    /// §7.1: reconcile only tables whose base/outbox changed. Whole-replica
5763    /// reset and restart explicitly invalidate every table.
5764    overlay_dirty: OverlayDirty,
5765    // First SQLite failure of the current operation, before savepoint cleanup.
5766    storage_failure: RefCell<Option<QueryReadFailure>>,
5767    /// Test-only structural performance signal: response processing must not
5768    /// turn a batch of acknowledgements into one full overlay rebuild each.
5769    #[cfg(test)]
5770    overlay_rebuild_count: Cell<usize>,
5771    #[cfg(test)]
5772    acknowledged_replay_count: Cell<usize>,
5773    /// Test-only structural performance signal: outcome retention is enforced
5774    /// once per response rather than once per acknowledgement.
5775    #[cfg(test)]
5776    outcome_prune_count: Cell<usize>,
5777    /// Exact observer-transaction output drained by command/FFI hosts.
5778    progress: ProgressObserver,
5779    change_queue: VecDeque<ClientChangeBatch>,
5780    sync_intent_queue: VecDeque<SyncIntent>,
5781    /// Explicit exponential retry policy for transient transport failures.
5782    retry_delay_ms: u64,
5783    /// §7.6: the background retry the current round scheduled, if any.
5784    round_retry_delay_ms: Option<u64>,
5785    active_round: Option<uuid::Uuid>,
5786    last_round: Option<DiagnosticLastRound>,
5787    last_change: Option<DiagnosticLastChange>,
5788    /// §7.6: latest failed owned snapshot read per owner id, oldest first.
5789    query_failures: Vec<DiagnosticQueryFailure>,
5790}
5791
5792pub(crate) fn quote_ident(name: &str) -> String {
5793    format!("\"{}\"", name.replace('"', "\"\""))
5794}
5795/// §7.4.1 `_syncular_meta` helpers. Free functions so RFC 0005's
5796/// previous-version module reads and writes the SAME records as the client
5797/// instead of growing a parallel meta layer.
5798pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
5799    conn.query_row(
5800        "SELECT value FROM _syncular_meta WHERE key = ?1",
5801        rusqlite::params![key],
5802        |row| row.get::<_, String>(0),
5803    )
5804    .ok()
5805}
5806
5807pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
5808    let _ = conn.execute(
5809        "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
5810        rusqlite::params![key, value],
5811    );
5812}
5813
5814pub(crate) fn meta_delete(conn: &Connection, key: &str) {
5815    let _ = conn.execute(
5816        "DELETE FROM _syncular_meta WHERE key = ?1",
5817        rusqlite::params![key],
5818    );
5819}
5820
5821/// Seek by the existing key while retaining the exact legacy text match.
5822fn row_id_predicate(table: &TableSchema) -> String {
5823    let key = quote_ident(&table.primary_key);
5824    let text_match = format!("CAST({key} AS TEXT) = ?1");
5825    match table.columns[table.pk_index].ty {
5826        ColumnType::String | ColumnType::Json => {
5827            format!("{key} = ?1 AND {text_match}")
5828        }
5829        // Native columns have no declared SQLite affinity. Match the stored
5830        // integer bind type explicitly. Unary + removes expression affinity so
5831        // SQLite can seek the typeless key; the text check preserves exact IDs.
5832        ColumnType::Integer | ColumnType::Boolean => {
5833            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
5834        }
5835        // REAL-to-text conversion can round distinct values to the same text.
5836        // Narrowing those matches by numeric equality would change behavior.
5837        _ => text_match,
5838    }
5839}
5840
5841fn is_local_operation_code_like(value: &str) -> bool {
5842    let bytes = value.as_bytes();
5843    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
5844        && bytes
5845            .iter()
5846            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
5847}
5848
5849fn base_table(name: &str) -> String {
5850    quote_ident(&format!("_syncular_base_{name}"))
5851}
5852
5853/// §4.8: a stable, server-opaque key for a window base — table + variable +
5854/// canonical fixed scopes. Two `set_window` calls with the same base
5855/// address the same registry rows.
5856/// §4.8 deferred eviction record: (sub id, table, effective scope map).
5857type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
5858
5859fn window_base_key(base: &WindowBase) -> String {
5860    format!(
5861        "{}\0{}\0{}",
5862        base.table,
5863        base.variable,
5864        canonical_scope_json(&base.fixed_scopes)
5865    )
5866}
5867
5868/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
5869fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
5870    let mut scopes = base.fixed_scopes.clone();
5871    scopes.retain(|(k, _)| k != &base.variable);
5872    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
5873    scopes
5874}
5875
5876/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
5877/// echoed not interpreted by the server, so the exact hash is client
5878/// convention; SHA-256 matches the SPEC's worked example.
5879fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
5880    let canonical = canonical_scope_json(&unit_scopes(base, unit));
5881    let digest = Sha256::digest(canonical.as_bytes());
5882    let hex = bytes_to_hex(&digest);
5883    format!("w:{}:{}", base.table, &hex[..16])
5884}
5885
5886fn visible_table(name: &str) -> String {
5887    quote_ident(name)
5888}
5889
5890const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
5891
5892/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
5893/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
5894fn is_synced_table_name(name: &str) -> bool {
5895    if name.starts_with("sqlite_") {
5896        return false;
5897    }
5898    if name.starts_with("_syncular_base_") {
5899        return true; // the base half of a synced table pair
5900    }
5901    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
5902    !name.starts_with("_syncular_")
5903}
5904
5905/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
5906fn blob_id_for(bytes: &[u8]) -> String {
5907    let digest = Sha256::digest(bytes);
5908    format!("sha256:{}", bytes_to_hex(&digest))
5909}
5910
5911/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
5912/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
5913/// per row on the §5.6 bootstrap path), scalars bind owned.
5914enum RowParam<'a> {
5915    Cell(&'a Option<ColumnValue>),
5916    Version(i64),
5917}
5918
5919impl rusqlite::ToSql for RowParam<'_> {
5920    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
5921        Ok(match self {
5922            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
5923            RowParam::Cell(cell) => match cell {
5924                None => ToSqlOutput::Owned(SqlValue::Null),
5925                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
5926                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
5927                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
5928                Some(ColumnValue::Boolean(b)) => {
5929                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
5930                }
5931                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
5932                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
5933                }
5934                // §5.10: crdt bytes store as BLOB, like bytes.
5935                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
5936                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
5937                }
5938            },
5939        })
5940    }
5941}
5942
5943// Retain a typed primary key across the current import chunk without copying row payloads.
5944fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
5945    match param.to_sql().map_err(|error| error.to_string())? {
5946        ToSqlOutput::Owned(value) => Ok(value),
5947        ToSqlOutput::Borrowed(value) => {
5948            SqlValue::try_from(value).map_err(|error| error.to_string())
5949        }
5950        _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
5951    }
5952}
5953
5954/// §5.3 image cell → bind parameter, strict per the declared column type
5955/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
5956/// nullable). Mismatches are image-producer violations. Returns the cell
5957/// borrowed when the stored representation already matches what the row
5958/// codec would write, or the normalized scalar (boolean → 0/1, float from
5959/// INTEGER → REAL) otherwise — the local write is byte-identical to the
5960/// old convert-then-insert path without allocating per cell.
5961fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
5962    use ssp2::segment::ColumnType;
5963    let mismatch = || {
5964        Err(format!(
5965            "image column {:?} holds a value of the wrong type",
5966            column.name
5967        ))
5968    };
5969    match value {
5970        ValueRef::Null => {
5971            if !column.nullable {
5972                return Err(format!(
5973                    "image column {:?} is NULL but not nullable",
5974                    column.name
5975                ));
5976            }
5977            Ok(ToSqlOutput::Owned(SqlValue::Null))
5978        }
5979        ValueRef::Integer(i) => match column.ty {
5980            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
5981            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
5982            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
5983            _ => mismatch(),
5984        },
5985        ValueRef::Real(_) => match column.ty {
5986            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
5987            _ => mismatch(),
5988        },
5989        ValueRef::Text(t) => {
5990            std::str::from_utf8(t)
5991                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
5992            match column.ty {
5993                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
5994                    Ok(ToSqlOutput::Borrowed(value))
5995                }
5996                _ => mismatch(),
5997            }
5998        }
5999        ValueRef::Blob(_) => match column.ty {
6000            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
6001            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
6002            _ => mismatch(),
6003        },
6004    }
6005}
6006
6007pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
6008    use rusqlite::types::ValueRef;
6009    match value {
6010        ValueRef::Null => Value::Null,
6011        ValueRef::Integer(i) => match column.ty {
6012            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
6013            ssp2::segment::ColumnType::Float => {
6014                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
6015            }
6016            _ => Value::from(i),
6017        },
6018        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
6019        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
6020        ValueRef::Blob(b) => {
6021            let mut map = Map::new();
6022            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
6023            Value::Object(map)
6024        }
6025    }
6026}
6027
6028/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
6029/// Objects are accepted in lossless `{"$bytes": hex}` and
6030/// `{"$bigint": decimal}` envelope forms.
6031fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
6032    Ok(match value {
6033        Value::Null => SqlValue::Null,
6034        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
6035        Value::Number(n) => {
6036            if let Some(i) = n.as_i64() {
6037                SqlValue::Integer(i)
6038            } else if let Some(f) = n.as_f64() {
6039                SqlValue::Real(f)
6040            } else {
6041                return Err(format!("query param number {n} is out of range"));
6042            }
6043        }
6044        Value::String(s) => SqlValue::Text(s.clone()),
6045        Value::Object(_) => {
6046            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
6047                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
6048            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
6049                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
6050                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
6051                })?)
6052            } else {
6053                return Err(
6054                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
6055                        .to_owned(),
6056                );
6057            }
6058        }
6059        Value::Array(_) => return Err("query array params are not supported".to_owned()),
6060    })
6061}
6062
6063/// Map a rusqlite value with no schema column to consult (arbitrary query
6064/// output): integers/reals/text pass through by stored affinity, blobs ride
6065/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
6066/// schema column type to recover booleans/floats/json.
6067fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
6068    use rusqlite::types::ValueRef;
6069    match value {
6070        ValueRef::Null => Value::Null,
6071        ValueRef::Integer(i) => {
6072            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
6073            // ordinary UI-sized integers ergonomic and envelope only values
6074            // beyond JavaScript's safe range.
6075            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
6076            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
6077                Value::from(i)
6078            } else {
6079                let mut map = Map::new();
6080                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
6081                Value::Object(map)
6082            }
6083        }
6084        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
6085        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
6086        ValueRef::Blob(b) => {
6087            let mut map = Map::new();
6088            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
6089            Value::Object(map)
6090        }
6091    }
6092}
6093
6094/// §7.5: SQLite failures classify by result code into [`QueryReadFailure`].
6095fn query_connection(
6096    conn: &Connection,
6097    sql: &str,
6098    params: &[QueryValue],
6099) -> Result<Vec<QueryRow>, QueryReadFailure> {
6100    crate::query_guard::assert_read_only_query(sql)?;
6101    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
6102    let bound: Vec<SqlValue> = params
6103        .iter()
6104        .map(json_param_to_sql)
6105        .collect::<Result<_, _>>()?;
6106    let mut stmt = conn.prepare(&lowered_sql)?;
6107    let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
6108    let bound_refs: Vec<&dyn rusqlite::ToSql> =
6109        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
6110    let mut sql_rows = stmt.query(bound_refs.as_slice())?;
6111    let mut out = Vec::new();
6112    while let Some(row) = sql_rows.next()? {
6113        let mut record = Map::new();
6114        for (i, name) in column_names.iter().enumerate() {
6115            let value = row.get_ref(i)?;
6116            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
6117        }
6118        out.push(record);
6119    }
6120    Ok(out)
6121}
6122
6123fn persisted_window_state(
6124    conn: &Connection,
6125    base: &WindowBase,
6126) -> Result<WindowState, QueryReadFailure> {
6127    let mut stmt = conn.prepare(
6128        "SELECT windows.unit, subscriptions.state_json
6129           FROM _syncular_windows AS windows
6130           JOIN _syncular_subscriptions AS subscriptions
6131             ON subscriptions.id = windows.sub_id
6132          WHERE windows.base = ?1
6133          ORDER BY windows.unit ASC",
6134    )?;
6135    let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
6136        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6137    })?;
6138    let mut units = Vec::new();
6139    let mut pending = Vec::new();
6140    for row in rows {
6141        let (unit, raw) = row?;
6142        let state: Value = serde_json::from_str(&raw)
6143            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
6144        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
6145            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
6146            || state
6147                .get("bootstrapState")
6148                .is_some_and(|value| !value.is_null());
6149        if is_pending {
6150            pending.push(unit.clone());
6151        }
6152        units.push(unit);
6153    }
6154    Ok(WindowState { units, pending })
6155}
6156
6157fn snapshot_connection(
6158    conn: &Connection,
6159    sql: &str,
6160    params: &[Value],
6161    coverage: &[WindowCoverage],
6162) -> Result<QuerySnapshot, QueryReadFailure> {
6163    conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
6164    let result: Result<QuerySnapshot, QueryReadFailure> = (|| {
6165        let revision = conn
6166            .query_row(
6167                "SELECT value FROM _syncular_meta WHERE key = ?1",
6168                rusqlite::params![LOCAL_REVISION_KEY],
6169                |row| row.get::<_, String>(0),
6170            )
6171            .ok()
6172            .and_then(|value| value.parse::<u64>().ok())
6173            .unwrap_or(0);
6174        let rows = query_connection(conn, sql, params)?;
6175        let mut pending = Vec::new();
6176        let mut missing = Vec::new();
6177        for requested in coverage {
6178            let base_key = window_base_key(&requested.base);
6179            let state = persisted_window_state(conn, &requested.base)?;
6180            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
6181                let reference = WindowUnitRef {
6182                    base_key: base_key.clone(),
6183                    unit: unit.clone(),
6184                };
6185                if !state.units.iter().any(|held| held == &unit) {
6186                    missing.push(reference);
6187                } else if state.pending.iter().any(|held| held == &unit) {
6188                    pending.push(reference);
6189                }
6190            }
6191        }
6192        Ok(QuerySnapshot {
6193            revision: revision.to_string(),
6194            rows,
6195            coverage: CoverageSnapshot {
6196                complete: pending.is_empty() && missing.is_empty(),
6197                pending,
6198                missing,
6199            },
6200        })
6201    })();
6202    match result {
6203        Ok(snapshot) => {
6204            conn.execute_batch("RELEASE syncular_snapshot_read")?;
6205            Ok(snapshot)
6206        }
6207        Err(mut error) => {
6208            if let Err(rollback) = conn
6209                .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
6210            {
6211                error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
6212            }
6213            Err(error)
6214        }
6215    }
6216}
6217
6218/// A long-lived read-only SQLite sidecar for latency-critical native views.
6219/// Network rounds stay serialized on the mutable core owner, while atomic
6220/// query snapshots use this independent connection and therefore never queue
6221/// behind HTTP/WebSocket latency.
6222pub struct FileQuerySnapshotReader {
6223    path: String,
6224    conn: Option<Connection>,
6225}
6226
6227impl FileQuerySnapshotReader {
6228    #[must_use]
6229    pub fn new(path: impl Into<String>) -> Self {
6230        Self {
6231            path: path.into(),
6232            conn: None,
6233        }
6234    }
6235
6236    fn connection(&mut self) -> Result<&Connection, String> {
6237        if self.conn.is_none() {
6238            let conn = Connection::open_with_flags(
6239                &self.path,
6240                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
6241            )
6242            .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
6243            conn.busy_timeout(std::time::Duration::from_millis(250))
6244                .map_err(|error| error.to_string())?;
6245            self.conn = Some(conn);
6246        }
6247        self.conn
6248            .as_ref()
6249            .ok_or_else(|| "read sidecar connection missing".to_owned())
6250    }
6251
6252    /// Hosts report an owned failure, and the first success after it, to the
6253    /// owning core through [`SyncClient::record_query_read`] (§7.6).
6254    pub fn query_snapshot(
6255        &mut self,
6256        sql: &str,
6257        params: &[Value],
6258        coverage: &[WindowCoverage],
6259    ) -> Result<QuerySnapshot, QueryReadFailure> {
6260        snapshot_connection(self.connection()?, sql, params, coverage)
6261    }
6262}
6263
6264impl SyncClient {
6265    pub fn new_with_identity(
6266        client_id: Option<String>,
6267        schema_json: &Value,
6268        limits: ClientLimits,
6269    ) -> Result<Self, String> {
6270        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
6271        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
6272        Self::with_connection(resolved, schema_json, limits, conn)
6273    }
6274
6275    pub fn new(
6276        client_id: String,
6277        schema_json: &Value,
6278        limits: ClientLimits,
6279    ) -> Result<Self, String> {
6280        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
6281        Self::with_connection(client_id, schema_json, limits, conn)
6282    }
6283
6284    /// Build a client backed by an on-disk SQLite database at `path` — the
6285    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
6286    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
6287    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
6288    /// of the command router's dependency set (the router only holds a path).
6289    pub fn open_path(
6290        client_id: String,
6291        schema_json: &Value,
6292        limits: ClientLimits,
6293        path: &str,
6294    ) -> Result<Self, String> {
6295        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
6296        Self::with_connection(client_id, schema_json, limits, conn)
6297    }
6298
6299    pub fn open_path_with_identity(
6300        client_id: Option<String>,
6301        schema_json: &Value,
6302        limits: ClientLimits,
6303        path: &str,
6304    ) -> Result<Self, String> {
6305        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
6306        // File-backed native clients use an independent read connection for
6307        // latency-critical snapshots. WAL is SQLite's intended reader/writer
6308        // concurrency mode: a view read never holds a rollback-journal lock
6309        // that delays the mutable client's next commit, and a short busy
6310        // timeout absorbs the tiny checkpoint/schema-lock windows.
6311        conn.busy_timeout(std::time::Duration::from_millis(250))
6312            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
6313        conn.pragma_update(None, "journal_mode", "WAL")
6314            .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
6315        let persisted = conn
6316            .query_row(
6317                "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
6318                [],
6319                |row| row.get::<_, String>(0),
6320            )
6321            .ok();
6322        let resolved = persisted
6323            .clone()
6324            .or(client_id.clone())
6325            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
6326        if let (Some(existing), Some(requested)) = (persisted, client_id) {
6327            if existing != requested {
6328                return Err(format!(
6329                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
6330                ));
6331            }
6332        }
6333        Self::with_connection(resolved, schema_json, limits, conn)
6334    }
6335
6336    #[must_use]
6337    pub fn client_id(&self) -> &str {
6338        &self.client_id
6339    }
6340
6341    /// Build a client over a caller-supplied rusqlite connection — the seam a
6342    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
6343    /// with an on-disk database (`Connection::open(path)`) rather than the
6344    /// default `:memory:`. The connection MUST be fresh (no pre-existing
6345    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
6346    /// the same file across process restarts reuses the persisted rows.
6347    pub fn with_connection(
6348        client_id: String,
6349        schema_json: &Value,
6350        limits: ClientLimits,
6351        conn: Connection,
6352    ) -> Result<Self, String> {
6353        if limits.outcome_retention_max_entries == Some(0) {
6354            return Err(
6355                "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
6356            );
6357        }
6358        let schema = parse_schema_json(schema_json)?;
6359        // RFC 0005 D8: resolve the previous-version config before the opening
6360        // reset runs — the capture happens inside it. Bad bounds fail loud.
6361        let previous_version = match limits.previous_version_context {
6362            Some(config) => {
6363                config.validate()?;
6364                Some(config)
6365            }
6366            None => None,
6367        };
6368        let mut client = SyncClient {
6369            #[cfg(feature = "bench-internals")]
6370            benchmark_phases: Recorder::default(),
6371            conn,
6372            schema,
6373            client_id,
6374            limits,
6375            subs: Vec::new(),
6376            outbox: Vec::new(),
6377            failed_commits: Vec::new(),
6378            retain_failed_commits: false,
6379            conflicts: Vec::new(),
6380            rejections: Vec::new(),
6381            schema_floor: None,
6382            lease_state: None,
6383            stopped: false,
6384            upgrading: false,
6385            sync_needed: false,
6386            realtime_policy: RealtimePolicy::Optional,
6387            realtime_state: RealtimeState::Disconnected,
6388            realtime_reason_code: None,
6389            realtime_retry_delay_ms: None,
6390            presence: HashMap::new(),
6391            now_ms: None,
6392            encryption: crate::values::EncryptionConfig::default(),
6393            security_preflight: false,
6394            transport_enabled: true,
6395            previous_version,
6396            insert_sql: RefCell::new(HashMap::new()),
6397            overlay_dirty: OverlayDirty::clean(),
6398            storage_failure: RefCell::new(None),
6399            #[cfg(test)]
6400            overlay_rebuild_count: Cell::new(0),
6401            #[cfg(test)]
6402            acknowledged_replay_count: Cell::new(0),
6403            #[cfg(test)]
6404            outcome_prune_count: Cell::new(0),
6405            progress: ProgressObserver::default(),
6406            change_queue: VecDeque::new(),
6407            sync_intent_queue: VecDeque::new(),
6408            retry_delay_ms: 250,
6409            round_retry_delay_ms: None,
6410            active_round: None,
6411            last_round: None,
6412            last_change: None,
6413            query_failures: Vec::new(),
6414        };
6415        // The row write path leans on the prepared-statement cache (two
6416        // insert statements per synced table, plus the bookkeeping
6417        // statements); size it so a multi-table schema never thrashes.
6418        client
6419            .conn
6420            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
6421        // Protected bookkeeping must exist before inspecting the persisted
6422        // schema marker. New app indexes may reference columns that only
6423        // exist after a version-bump reset.
6424        client.create_bookkeeping_tables()?;
6425        match client.get_meta(CLIENT_ID_KEY) {
6426            Some(existing) if existing != client.client_id => {
6427                return Err(format!(
6428                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
6429                    client.client_id
6430                ));
6431            }
6432            None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
6433            _ => {}
6434        }
6435        client.restore_persisted_state()?;
6436        client.failed_commits = client.load_failed_commits()?;
6437        let marker = client
6438            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6439            .and_then(|value| value.parse::<i32>().ok());
6440        match marker {
6441            None => {
6442                client.create_synced_tables()?;
6443                client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
6444                // D1: the descriptor is written beside every marker write.
6445                set_local_schema_descriptor(&client.conn, &client.schema);
6446                client.save_subscription_scope_schema();
6447            }
6448            Some(version) if version == client.schema.version => {
6449                client.create_synced_tables()?;
6450                // RFC 0005 D1: the same-version open backfills the descriptor
6451                // for a database first opened by an unaware binary, so the
6452                // NEXT bump can capture without needing a schema bump first.
6453                set_local_schema_descriptor(&client.conn, &client.schema);
6454                client.save_subscription_scope_schema();
6455            }
6456            Some(_) => client.run_schema_reset()?,
6457        }
6458        // RFC 0005 D9: the reset sweep cannot see a container whose capture
6459        // committed in its OWN file while the replica's savepoint never
6460        // released — a crash at that seam leaves a container beside a matching
6461        // OLD marker, and the next open at that version runs no reset. Discard
6462        // it before anything can read it. No-op when no container exists.
6463        client.reconcile_previous_version_at_boot()?;
6464        client.clear_satisfied_persisted_schema_floor();
6465        client.prune_unknown_subscriptions(false)?;
6466        if marker == Some(client.schema.version)
6467            && (!client.outbox.is_empty()
6468                || !client.failed_commits.is_empty()
6469                || client.has_acknowledged_rows())
6470        {
6471            // Reconstruct the visible optimistic overlay from the durable base
6472            // plus outbox instead of trusting a process-interrupted mirror.
6473            client.overlay_dirty.set(true);
6474            client.rebuild_overlay();
6475        }
6476        // Every persisted active subscription needs one catch-up pull on open:
6477        // realtime only covers changes after connection, while an idempotent
6478        // setWindow correctly creates no fresh command effect. Pending outbox
6479        // work has the same restart requirement. The core owns this intent so
6480        // native hosts never poll or require an application-issued sync().
6481        client.enqueue_startup_sync_if_needed()?;
6482        Ok(client)
6483    }
6484
6485    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
6486    /// against this instead of system time (conformance virtual clock).
6487    pub fn set_now_ms(&mut self, now_ms: i64) {
6488        self.now_ms = Some(now_ms);
6489    }
6490
6491    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
6492    /// The command router parses these from the `create` command's
6493    /// `encryption` config (keys as `{$bytes: hex}`).
6494    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
6495        self.encryption = encryption;
6496    }
6497
6498    #[must_use]
6499    pub fn security_lifecycle(&self) -> &'static str {
6500        if self.security_preflight {
6501            "preflight"
6502        } else {
6503            "active"
6504        }
6505    }
6506
6507    #[must_use]
6508    pub fn security_preflight(&self) -> bool {
6509        self.security_preflight
6510    }
6511
6512    /// Host-owned, non-persisted network gate. Local operations remain available.
6513    pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
6514        if self.transport_enabled != enabled {
6515            self.transport_enabled = enabled;
6516            self.sync_intent_queue.clear();
6517            self.sync_intent_queue
6518                .push_back(if enabled && !self.security_preflight {
6519                    SyncIntent::Interactive
6520                } else {
6521                    SyncIntent::None
6522                });
6523        }
6524        // A captured reply keeps its normal apply/revocation semantics.
6525        if !enabled && self.active_round.is_none() {
6526            self.disconnect_realtime(transport);
6527        }
6528    }
6529
6530    pub fn transport_enabled(&self) -> bool {
6531        self.transport_enabled
6532    }
6533
6534    /// Quarantine this replica: enter the fail-closed gate, release all
6535    /// core-owned key material, and record the gate in the database so
6536    /// reopening re-enters preflight until `activate_security` clears it.
6537    ///
6538    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
6539    /// which must NOT leave that durable mark.
6540    pub fn begin_security_preflight(&mut self) {
6541        self.seal_security_on_teardown();
6542        // Persist the quarantine so it survives handle teardown and restart:
6543        // reopening this replica re-enters preflight until activation clears it.
6544        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
6545    }
6546
6547    /// Release core-owned key material as a host tears the client down,
6548    /// WITHOUT recording a quarantine.
6549    ///
6550    /// Shutting an activated client down is not a quarantine event. Persisting
6551    /// the gate here marks every cleanly closed replica as pending, and the
6552    /// next plain `create` is then refused permanently: the reopen path
6553    /// restores the flag from the marker and the create guard rejects it. The
6554    /// in-memory flag still closes the gate for anything still holding this
6555    /// instance, which is all a teardown barrier needs.
6556    pub fn seal_security_on_teardown(&mut self) {
6557        self.cancel_sync_round();
6558        self.security_preflight = true;
6559        self.encryption = crate::values::EncryptionConfig::default();
6560        self.sync_intent_queue.clear();
6561    }
6562
6563    /// Install the post-authentication keyring and release the host loop.
6564    pub fn activate_security(
6565        &mut self,
6566        encryption: crate::values::EncryptionConfig,
6567    ) -> Result<(), String> {
6568        if !self.security_preflight {
6569            return Err(
6570                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
6571            );
6572        }
6573        self.enqueue_startup_sync_if_needed()?;
6574        self.encryption = encryption;
6575        self.security_preflight = false;
6576        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
6577        Ok(())
6578    }
6579
6580    fn clock_now_ms(&self) -> i64 {
6581        self.now_ms.unwrap_or_else(|| {
6582            std::time::SystemTime::now()
6583                .duration_since(std::time::UNIX_EPOCH)
6584                .map(|d| d.as_millis() as i64)
6585                .unwrap_or(0)
6586        })
6587    }
6588
6589    fn create_bookkeeping_tables(&self) -> Result<(), String> {
6590        // Durable client bookkeeping (outbox + subscription + meta).
6591        self.conn
6592            .execute_batch(
6593                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
6594                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
6595                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
6596                 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
6597                   tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
6598                 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
6599                   commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
6600                   commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
6601                   PRIMARY KEY(commit_id, idx));
6602                 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
6603                   seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
6604                   operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
6605                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
6606                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
6607                   client_commit_id TEXT NOT NULL UNIQUE,
6608                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
6609                   recorded_at_ms INTEGER NOT NULL,
6610                   results_json TEXT NOT NULL,
6611                   operations_json TEXT,
6612                   resolution TEXT NOT NULL DEFAULT 'active'
6613                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
6614                   resolved_at_ms INTEGER,
6615                   replacement_client_commit_id TEXT);
6616                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
6617                   ON _syncular_commit_outcomes(resolution, seq);
6618                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
6619                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
6620                 CREATE TABLE IF NOT EXISTS _syncular_meta (
6621                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
6622                 CREATE TABLE IF NOT EXISTS _syncular_windows (
6623                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
6624                   PRIMARY KEY (base, unit));
6625                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
6626                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
6627                   effective_scopes TEXT NOT NULL);
6628                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
6629                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
6630                   PRIMARY KEY(commit_id, blob_id));
6631                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
6632                   ON _syncular_blob_commit_refs(blob_id);",
6633            )
6634            .map_err(|e| e.to_string())?;
6635        // Migrate an outcome journal created before failed aggregate
6636        // envelopes were retained. Historical rows intentionally stay NULL.
6637        let _ = self
6638            .conn
6639            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
6640        if self.get_meta(LOCAL_REVISION_KEY).is_none() {
6641            self.set_meta(LOCAL_REVISION_KEY, "0");
6642        }
6643        // §5.9.7 blob cache. Mutable upload state stays off the body row so
6644        // completing a large upload does not rewrite its BLOB record.
6645        if self.schema_has_blobs() {
6646            let exists = self
6647                .conn
6648                .query_row(
6649                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
6650                    [],
6651                    |row| row.get::<_, bool>(0),
6652                )
6653                .map_err(|e| e.to_string())?;
6654            if exists {
6655                let mut statement = self
6656                    .conn
6657                    .prepare("PRAGMA table_info(_syncular_blobs)")
6658                    .map_err(|e| e.to_string())?;
6659                let mut columns = statement
6660                    .query_map([], |row| row.get::<_, String>(1))
6661                    .map_err(|e| e.to_string())?
6662                    .collect::<Result<Vec<_>, _>>()
6663                    .map_err(|e| e.to_string())?;
6664                columns.sort();
6665                let mut expected = vec![
6666                    "blob_id",
6667                    "byte_length",
6668                    "bytes",
6669                    "created_at_ms",
6670                    "media_type",
6671                ];
6672                expected.sort_unstable();
6673                if columns != expected {
6674                    return Err(
6675                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
6676                    );
6677                }
6678            } else {
6679                self.conn
6680                    .execute_batch(
6681                        "CREATE TABLE _syncular_blobs(
6682                           blob_id TEXT PRIMARY KEY,
6683                           bytes BLOB NOT NULL,
6684                           byte_length INTEGER NOT NULL,
6685                           media_type TEXT,
6686                           created_at_ms INTEGER NOT NULL);",
6687                    )
6688                    .map_err(|e| e.to_string())?;
6689            }
6690            self.conn
6691                .execute_batch(
6692                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
6693                       blob_id TEXT PRIMARY KEY,
6694                       media_type TEXT,
6695                       created_at_ms INTEGER NOT NULL);",
6696                )
6697                .map_err(|e| e.to_string())?;
6698        }
6699        Ok(())
6700    }
6701
6702    /// True iff any synced table declares a `blob_ref` column (§5.9).
6703    fn schema_has_blobs(&self) -> bool {
6704        self.schema
6705            .tables
6706            .iter()
6707            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
6708    }
6709
6710    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
6711
6712    fn get_meta(&self, key: &str) -> Option<String> {
6713        meta_get(&self.conn, key)
6714    }
6715
6716    fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
6717        self.conn
6718            .query_row(
6719                "SELECT value FROM _syncular_meta WHERE key = ?1",
6720                rusqlite::params![key],
6721                |row| row.get::<_, String>(0),
6722            )
6723            .optional()
6724            .map_err(|_| {
6725                "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
6726            })
6727    }
6728
6729    fn set_meta(&self, key: &str, value: &str) {
6730        meta_set(&self.conn, key, value);
6731    }
6732
6733    fn delete_meta(&self, key: &str) {
6734        meta_delete(&self.conn, key);
6735    }
6736
6737    fn restore_persisted_state(&mut self) -> Result<(), String> {
6738        self.subs = {
6739            let mut stmt = self
6740                .conn
6741                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
6742                .map_err(|error| error.to_string())?;
6743            let rows = stmt
6744                .query_map([], |row| {
6745                    Ok((
6746                        row.get::<_, String>(0)?,
6747                        row.get::<_, String>(1)?,
6748                        row.get::<_, String>(2)?,
6749                    ))
6750                })
6751                .map_err(|error| error.to_string())?;
6752            let mut subscriptions = Vec::new();
6753            for row in rows {
6754                let (id, table, raw) = row.map_err(|error| error.to_string())?;
6755                let state: Value = serde_json::from_str(&raw)
6756                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
6757                let requested = json_to_scope_map(
6758                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
6759                )?;
6760                let effective = state
6761                    .get("effectiveScopes")
6762                    .filter(|value| !value.is_null())
6763                    .map(json_to_scope_map)
6764                    .transpose()?;
6765                subscriptions.push(Subscription {
6766                    id,
6767                    table,
6768                    requested,
6769                    params: state
6770                        .get("params")
6771                        .and_then(Value::as_str)
6772                        .map(str::to_owned),
6773                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
6774                    bootstrap_state: state
6775                        .get("bootstrapState")
6776                        .and_then(Value::as_str)
6777                        .map(str::to_owned),
6778                    state: SubState::parse(
6779                        state
6780                            .get("status")
6781                            .and_then(Value::as_str)
6782                            .unwrap_or("active"),
6783                    ),
6784                    reason_code: state
6785                        .get("reasonCode")
6786                        .and_then(Value::as_str)
6787                        .map(str::to_owned),
6788                    effective,
6789                    synced_once: state
6790                        .get("syncedOnce")
6791                        .and_then(Value::as_bool)
6792                        .unwrap_or(false),
6793                });
6794            }
6795            subscriptions
6796        };
6797
6798        self.outbox = {
6799            let mut stmt = self
6800                .conn
6801                .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
6802                .map_err(|error| error.to_string())?;
6803            let rows = stmt
6804                .query_map([], |row| {
6805                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6806                })
6807                .map_err(|error| error.to_string())?;
6808            let mut commits = Vec::new();
6809            for row in rows {
6810                let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
6811                let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
6812                    format!("invalid persisted outbox {client_commit_id:?}: {error}")
6813                })?;
6814                let mut ops = Vec::with_capacity(entries.len());
6815                for entry in entries {
6816                    let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
6817                    ops.push(OutboxOp {
6818                        upsert: op == "upsert",
6819                        table: entry
6820                            .get("table")
6821                            .and_then(Value::as_str)
6822                            .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
6823                            .to_owned(),
6824                        row_id: entry
6825                            .get("rowId")
6826                            .and_then(Value::as_str)
6827                            .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
6828                            .to_owned(),
6829                        base_version: entry.get("baseVersion").and_then(Value::as_i64),
6830                        // A legacy entry's `changedFields` key is ignored:
6831                        // the values map is the presence set (§6.1).
6832                        values: entry.get("values").and_then(Value::as_object).cloned(),
6833                    });
6834                }
6835                commits.push(OutboxCommit {
6836                    client_commit_id,
6837                    ops,
6838                });
6839            }
6840            commits
6841        };
6842
6843        self.prune_commit_outcomes()?;
6844        let active = self.commit_outcomes(CommitOutcomeQuery {
6845            active_only: true,
6846            ..CommitOutcomeQuery::default()
6847        })?;
6848        self.conflicts = active
6849            .iter()
6850            .flat_map(|outcome| outcome.results.iter())
6851            .filter_map(|result| match result {
6852                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
6853                _ => None,
6854            })
6855            .collect();
6856        self.rejections = active
6857            .iter()
6858            .flat_map(|outcome| outcome.results.iter())
6859            .filter_map(|result| match result {
6860                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
6861                _ => None,
6862            })
6863            .collect();
6864
6865        self.lease_state = self
6866            .get_meta(LEASE_STATE_KEY)
6867            .map(|raw| serde_json::from_str(&raw))
6868            .transpose()
6869            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
6870        self.schema_floor = self
6871            .get_meta(SCHEMA_FLOOR_KEY)
6872            .map(|raw| serde_json::from_str(&raw))
6873            .transpose()
6874            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
6875        self.stopped = self.schema_floor.is_some();
6876        // A replica left in unactivated preflight reopens gated: the quarantine
6877        // decision persists with the data across handle teardown and restart.
6878        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
6879            self.security_preflight = true;
6880        }
6881        Ok(())
6882    }
6883
6884    fn save_subscription_scope_schema(&self) {
6885        let declarations: BTreeMap<_, BTreeMap<_, _>> = self
6886            .schema
6887            .tables
6888            .iter()
6889            .map(|table| {
6890                (
6891                    table.name.clone(),
6892                    table
6893                        .scope_variables
6894                        .iter()
6895                        .map(|scope| {
6896                            (
6897                                scope.variable.clone(),
6898                                [scope.prefix.clone(), scope.column.clone()],
6899                            )
6900                        })
6901                        .collect(),
6902                )
6903            })
6904            .collect();
6905        self.set_meta(
6906            "subscriptionScopeSchema",
6907            &serde_json::to_string(&declarations).expect("scope schema serialization"),
6908        );
6909    }
6910
6911    /// Remove registrations with incompatible table/key/prefix/column meaning.
6912    fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
6913        let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
6914            self.get_meta("subscriptionScopeSchema")
6915                .map(|raw| serde_json::from_str(&raw))
6916                .transpose()
6917                .map_err(|error| format!("invalid persisted scope schema: {error}"))?
6918        } else {
6919            None
6920        };
6921        let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
6922            let Some(table) = self.schema.table(table_name) else {
6923                return false;
6924            };
6925            if requested
6926                .iter()
6927                .any(|(variable, _)| table.scope_column(variable).is_none())
6928            {
6929                return false;
6930            }
6931            if !bump {
6932                return true;
6933            }
6934            let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
6935                return false;
6936            };
6937            let variables: BTreeSet<_> = if requested.is_empty() {
6938                old.keys()
6939                    .cloned()
6940                    .chain(
6941                        table
6942                            .scope_variables
6943                            .iter()
6944                            .map(|scope| scope.variable.clone()),
6945                    )
6946                    .collect()
6947            } else {
6948                requested
6949                    .iter()
6950                    .map(|(variable, _)| variable.clone())
6951                    .collect()
6952            };
6953            variables.iter().all(|variable| {
6954                let current = table
6955                    .scope_variables
6956                    .iter()
6957                    .find(|scope| &scope.variable == variable);
6958                match (old.get(variable), current) {
6959                    (Some(old), Some(current)) => {
6960                        old == &[current.prefix.clone(), current.column.clone()]
6961                    }
6962                    _ => false,
6963                }
6964            })
6965        };
6966        let mut stale_ids: BTreeSet<_> = self
6967            .subs
6968            .iter()
6969            .filter(|sub| !compatible(&sub.table, &sub.requested))
6970            .map(|sub| sub.id.clone())
6971            .collect();
6972        for (id, table, effective) in self.load_pending_evictions()? {
6973            if !compatible(&table, &effective) {
6974                stale_ids.insert(id);
6975            }
6976        }
6977        for id in &stale_ids {
6978            for sql in [
6979                "DELETE FROM _syncular_windows WHERE sub_id = ?1",
6980                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
6981                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
6982            ] {
6983                self.conn
6984                    .execute(sql, rusqlite::params![id])
6985                    .map_err(|error| error.to_string())?;
6986            }
6987        }
6988        self.subs.retain(|sub| !stale_ids.contains(&sub.id));
6989        Ok(())
6990    }
6991
6992    #[must_use]
6993    pub fn local_revision(&self) -> u64 {
6994        self.get_meta(LOCAL_REVISION_KEY)
6995            .and_then(|value| value.parse().ok())
6996            .unwrap_or(0)
6997    }
6998
6999    #[must_use]
7000    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
7001        SyncStatusSnapshot {
7002            current_schema_version: self.schema.version,
7003            outbox: self.outbox.len(),
7004            upgrading: self.upgrading,
7005            lease_state: self.lease_state.clone(),
7006            schema_floor: self.schema_floor.clone(),
7007            sync_needed: self.sync_needed,
7008            previous_version_context: self.previous_version_status(),
7009        }
7010    }
7011
7012    /// RFC 0005 A2: container presence for a host that must act on it (Diego's
7013    /// rollback precondition), read without creating the file.
7014    fn previous_version_status(&self) -> PreviousVersionStatus {
7015        let Some(replica_path) = self.previous_version_replica_path() else {
7016            return PreviousVersionStatus {
7017                present: false,
7018                created_at_ms: None,
7019            };
7020        };
7021        match crate::previous_version::read_previous_version_record(&replica_path) {
7022            Ok(Some(record)) => PreviousVersionStatus {
7023                present: true,
7024                created_at_ms: Some(record.created_at_ms),
7025            },
7026            _ => PreviousVersionStatus {
7027                present: std::path::Path::new(
7028                    &crate::previous_version::previous_version_container_path(&replica_path),
7029                )
7030                .exists(),
7031                created_at_ms: None,
7032            },
7033        }
7034    }
7035
7036    /// RFC 0005 D7: read captured rows from the previous local schema. `state`
7037    /// is always `previousVersion`; the container never makes
7038    /// `query_snapshot()` coverage complete. Lease stop/expiry, scope
7039    /// revocation, coverage completion and the TTL DISCARD the container and
7040    /// close the read with their own reason.
7041    pub fn previous_version_snapshot(
7042        &self,
7043        spec: &PreviousVersionReadSpec,
7044    ) -> Result<PreviousVersionSnapshot, String> {
7045        let now_ms = self.clock_now_ms();
7046        crate::previous_version::previous_version_snapshot(
7047            &self.conn,
7048            self.previous_version_replica_path().as_deref(),
7049            self.previous_version.as_ref(),
7050            PreviousVersionLifecycle {
7051                lease_inactive: self.previous_version_lease_inactive(now_ms),
7052                scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
7053                coverage_complete: self.previous_version_coverage_complete(),
7054            },
7055            now_ms,
7056            self.schema.version,
7057            spec,
7058        )
7059    }
7060
7061    /// RFC 0005 D6: the advisory pre-reset compatibility audit.
7062    #[must_use]
7063    pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
7064        crate::previous_version::stored_previous_version_audit(&self.conn)
7065    }
7066
7067    /// RFC 0005 A2/D9: the executable downgrade step. Drop the container file
7068    /// and both metadata records, and report whether anything was present.
7069    /// Idempotent: absence is a successful no-op.
7070    pub fn previous_version_discard(
7071        &self,
7072    ) -> crate::previous_version::PreviousVersionDiscardOutcome {
7073        self.drop_previous_version()
7074    }
7075
7076    /// The module's discard has no failure path (it removes the file even when
7077    /// the container cannot be opened), so the only error a caller could ever
7078    /// see is a bug; report absence rather than claiming a discard happened.
7079    fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
7080        let absent = crate::previous_version::PreviousVersionDiscardOutcome {
7081            present: false,
7082            discarded: false,
7083        };
7084        let Some(replica_path) = self.previous_version_replica_path() else {
7085            return absent;
7086        };
7087        crate::previous_version::discard_previous_version(&self.conn, &replica_path)
7088            .unwrap_or(absent)
7089    }
7090
7091    /// RFC 0005 D7: `leaseState.errorCode` or an expired lease.
7092    fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
7093        let Some(lease) = &self.lease_state else {
7094            return false;
7095        };
7096        if lease.error_code.is_some() {
7097            return true;
7098        }
7099        lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
7100    }
7101
7102    /// RFC 0005 D7: §7.4.5 replacement coverage — every ACTIVE subscription
7103    /// has `cursor >= 0` and no resume token. An empty active set is not
7104    /// completion (mirrors the TS predicate exactly).
7105    fn previous_version_coverage_complete(&self) -> bool {
7106        let active = self
7107            .subs
7108            .iter()
7109            .filter(|sub| sub.state == SubState::Active)
7110            .collect::<Vec<_>>();
7111        if active.is_empty() {
7112            return false;
7113        }
7114        active
7115            .iter()
7116            .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
7117    }
7118
7119    /// RFC 0005 D7: lifetime triggers evaluated once per sync round, after the
7120    /// apply. A container that no longer has a reason to exist is discarded so
7121    /// the rows do not outlive the window they were kept for.
7122    fn previous_version_lifetime_check(&self) {
7123        let Some(config) = self.previous_version.filter(|config| config.enabled) else {
7124            return;
7125        };
7126        let now_ms = self.clock_now_ms();
7127        if self.previous_version_lease_inactive(now_ms) {
7128            let _ = self.drop_previous_version();
7129            return;
7130        }
7131        let Some(replica_path) = self.previous_version_replica_path() else {
7132            return;
7133        };
7134        let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
7135        else {
7136            return;
7137        };
7138        if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
7139            let _ = self.drop_previous_version();
7140            return;
7141        }
7142        if now_ms - record.created_at_ms > config.max_age_ms {
7143            let _ = self.drop_previous_version();
7144            return;
7145        }
7146        if self.previous_version_coverage_complete() {
7147            let _ = self.drop_previous_version();
7148        }
7149    }
7150
7151    pub fn diagnostics_snapshot(
7152        &self,
7153        request: &ClientDiagnosticsRequest,
7154    ) -> Result<ClientDiagnosticsSnapshot, String> {
7155        if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
7156            return Err(format!(
7157                "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
7158            ));
7159        }
7160        let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
7161        for sub in &self.subs {
7162            let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
7163            let complete =
7164                sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
7165            let state = match sub.state {
7166                SubState::Revoked => "revoked",
7167                SubState::Failed => "failed",
7168                SubState::Active if reset => "reset",
7169                SubState::Active if complete => "complete",
7170                SubState::Active => "bootstrapping",
7171            };
7172            subscriptions.insert(
7173                sub.id.clone(),
7174                DiagnosticSubscription {
7175                    id: sub.id.clone(),
7176                    table: sub.table.clone(),
7177                    state: state.to_owned(),
7178                    complete,
7179                    cursor: Some(sub.cursor),
7180                    reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
7181                },
7182            );
7183        }
7184        for expected in &request.expected_subscriptions {
7185            if expected.id.is_empty() || expected.table.is_empty() {
7186                return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
7187            }
7188            if subscriptions
7189                .get(&expected.id)
7190                .is_some_and(|registered| registered.table != expected.table)
7191            {
7192                subscriptions.insert(
7193                    expected.id.clone(),
7194                    DiagnosticSubscription {
7195                        id: expected.id.clone(),
7196                        table: expected.table.clone(),
7197                        state: "failed".to_owned(),
7198                        complete: false,
7199                        cursor: None,
7200                        reason_code: Some("client.subscription_intent_mismatch".to_owned()),
7201                    },
7202                );
7203            } else {
7204                subscriptions.entry(expected.id.clone()).or_insert_with(|| {
7205                    DiagnosticSubscription {
7206                        id: expected.id.clone(),
7207                        table: expected.table.clone(),
7208                        state: "unregistered".to_owned(),
7209                        complete: false,
7210                        cursor: None,
7211                        reason_code: None,
7212                    }
7213                });
7214            }
7215        }
7216        let mut ordered_subscriptions = Vec::new();
7217        let mut included = BTreeSet::new();
7218        for expected in &request.expected_subscriptions {
7219            if included.insert(expected.id.clone()) {
7220                if let Some(subscription) = subscriptions.get(&expected.id) {
7221                    ordered_subscriptions.push(subscription.clone());
7222                }
7223            }
7224        }
7225        for (id, subscription) in &subscriptions {
7226            if included.insert(id.clone()) {
7227                ordered_subscriptions.push(subscription.clone());
7228            }
7229        }
7230        let subscriptions_truncated =
7231            ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
7232        ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
7233        let captured_at_ms = self.clock_now_ms();
7234        let lease = if let Some(error_code) = self
7235            .lease_state
7236            .as_ref()
7237            .and_then(|state| state.error_code.clone())
7238        {
7239            ClientDiagnosticsLease {
7240                state: "stopped".to_owned(),
7241                expires_at_ms: self
7242                    .lease_state
7243                    .as_ref()
7244                    .and_then(|state| state.expires_at_ms),
7245                error_code: Some(Self::diagnostic_code(&error_code)),
7246            }
7247        } else if let Some(expires_at_ms) = self
7248            .lease_state
7249            .as_ref()
7250            .and_then(|state| state.expires_at_ms)
7251        {
7252            ClientDiagnosticsLease {
7253                state: if expires_at_ms <= captured_at_ms {
7254                    "expired".to_owned()
7255                } else {
7256                    "active".to_owned()
7257                },
7258                expires_at_ms: Some(expires_at_ms),
7259                error_code: None,
7260            }
7261        } else {
7262            ClientDiagnosticsLease {
7263                state: "none".to_owned(),
7264                expires_at_ms: None,
7265                error_code: None,
7266            }
7267        };
7268        let connectivity = match self.last_round.as_ref() {
7269            _ if !self.transport_enabled && self.active_round.is_none() => "offline",
7270            Some(round) if round.status == "succeeded" => "online",
7271            Some(round)
7272                if round.status == "failed"
7273                    && round
7274                        .error_code
7275                        .as_deref()
7276                        .is_some_and(Self::retryable_transport_code) =>
7277            {
7278                "offline"
7279            }
7280            _ => "unknown",
7281        };
7282        Ok(ClientDiagnosticsSnapshot {
7283            version: CLIENT_DIAGNOSTICS_VERSION,
7284            captured_at_ms,
7285            host: ClientDiagnosticsHost {
7286                kind: "direct".to_owned(),
7287                role: "single".to_owned(),
7288                connectivity: connectivity.to_owned(),
7289                realtime: self.realtime_state,
7290                realtime_policy: self.realtime_policy,
7291                realtime_reason_code: self.realtime_reason_code.clone(),
7292                realtime_retry_delay_ms: self.realtime_retry_delay_ms,
7293            },
7294            security_lifecycle: self.security_lifecycle().to_owned(),
7295            schema: ClientDiagnosticsSchema {
7296                current_version: self.schema.version,
7297                upgrading: self.upgrading,
7298                required_version: self
7299                    .schema_floor
7300                    .as_ref()
7301                    .and_then(|floor| floor.required_schema_version),
7302                latest_version: self
7303                    .schema_floor
7304                    .as_ref()
7305                    .and_then(|floor| floor.latest_schema_version),
7306            },
7307            replica: ClientDiagnosticsReplica {
7308                local_revision: self.local_revision().to_string(),
7309                sync_needed: self.sync_needed,
7310                pending_outbox: self.outbox.len(),
7311            },
7312            lease,
7313            subscriptions: ordered_subscriptions,
7314            subscriptions_truncated,
7315            last_round: self.last_round.clone(),
7316            last_change: self.last_change.clone(),
7317            storage: self.diagnostics_storage(),
7318            query_failures: self.query_failures.clone(),
7319        })
7320    }
7321
7322    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
7323        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
7324            let page_count: i64 = self
7325                .conn
7326                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
7327            let page_size: i64 = self
7328                .conn
7329                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
7330            // Reuse compiled aggregates; every invocation still reads current storage.
7331            let outbox_bytes: i64 = self
7332                .conn
7333                .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
7334                .query_row([], |row| row.get(0))?;
7335            let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
7336                "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
7337            )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
7338            let blob_bytes = if self.schema_has_blobs() {
7339                self.conn
7340                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
7341                    .query_row([], |row| row.get(0))?
7342            } else {
7343                0
7344            };
7345            let pressure = self
7346                .limits
7347                .blob_cache_max_bytes
7348                .is_some_and(|limit| blob_bytes > limit);
7349            Ok(ClientDiagnosticsStorage {
7350                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
7351                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
7352                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
7353                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
7354                retained_outcome_entries: Some(outcome_entries.max(0)),
7355                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
7356                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
7357            })
7358        };
7359        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
7360            status: "unreadable".to_owned(),
7361            database_bytes_approx: None,
7362            pending_outbox_bytes_approx: None,
7363            retained_outcome_bytes_approx: None,
7364            retained_outcome_entries: None,
7365            blob_cache_bytes_approx: None,
7366            pressure_reason_code: None,
7367        })
7368    }
7369
7370    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
7371        self.change_queue.drain(..).collect()
7372    }
7373
7374    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
7375        let intents = self.sync_intent_queue.drain(..).collect();
7376        if self.transport_enabled {
7377            intents
7378        } else {
7379            vec![SyncIntent::None]
7380        }
7381    }
7382
7383    fn schedule_background_retry(&mut self) -> u64 {
7384        let delay_ms = self.retry_delay_ms;
7385        self.sync_intent_queue
7386            .push_back(SyncIntent::Background { delay_ms });
7387        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
7388        self.round_retry_delay_ms = Some(delay_ms);
7389        delay_ms
7390    }
7391
7392    fn reset_background_retry(&mut self) {
7393        self.retry_delay_ms = 250;
7394    }
7395
7396    fn retryable_transport_code(code: &str) -> bool {
7397        code == "transport.failed"
7398            || code == "transport.unavailable"
7399            || code == "sync.transport_failed"
7400    }
7401
7402    /// A failed round worth a background retry: a transport failure, or a
7403    /// §10.2 code the catalog marks retryable (clients may hardcode the
7404    /// catalog metadata, §10.1). Matches the TypeScript core, which retries
7405    /// on the server's `retryable` flag.
7406    fn retryable_failure_code(code: &str) -> bool {
7407        Self::retryable_transport_code(code)
7408            || matches!(
7409                code,
7410                "sync.auth_required"
7411                    | "sync.auth_lease_required"
7412                    | "sync.auth_lease_revoked"
7413                    | "sync.internal_error"
7414                    | "sync.idempotency_cache_miss"
7415                    | "sync.schema_not_ready"
7416                    | "sync.segment_expired"
7417                    | "sync.rate_limited"
7418                    | "sync.websocket_connection_limit"
7419            )
7420    }
7421
7422    fn diagnostic_code(code: &str) -> String {
7423        let valid = !code.is_empty()
7424            && code.len() <= 96
7425            && code.contains('.')
7426            && code
7427                .bytes()
7428                .next()
7429                .is_some_and(|byte| byte.is_ascii_lowercase())
7430            && code.bytes().all(|byte| {
7431                byte.is_ascii_lowercase()
7432                    || byte.is_ascii_digit()
7433                    || matches!(byte, b'.' | b'_' | b'-')
7434            });
7435        if valid {
7436            code.to_owned()
7437        } else {
7438            "client.unknown_failure".to_owned()
7439        }
7440    }
7441
7442    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
7443        if self.sync_needed != value {
7444            if self.begin_observation("syncular_status").is_ok() {
7445                self.sync_needed = value;
7446                let batch = ChangeAccumulator {
7447                    status: true,
7448                    ..ChangeAccumulator::default()
7449                };
7450                if self.finish_observation("syncular_status", batch).is_err() {
7451                    self.rollback_observation("syncular_status");
7452                }
7453            } else {
7454                self.sync_needed = value;
7455            }
7456        }
7457        if value && interactive {
7458            self.sync_intent_queue.push_back(SyncIntent::Interactive);
7459        }
7460    }
7461
7462    fn sqlite_failure(
7463        storage_failure: &RefCell<Option<QueryReadFailure>>,
7464        error: rusqlite::Error,
7465    ) -> String {
7466        let message = error.to_string();
7467        let failure = QueryReadFailure::from(error);
7468        let code = failure.code;
7469        if code.is_some() && storage_failure.borrow().is_none() {
7470            *storage_failure.borrow_mut() = Some(failure);
7471        }
7472        match code {
7473            Some(code) => format!("{code}: {message}"),
7474            None => message,
7475        }
7476    }
7477
7478    fn begin_observation(&self, name: &str) -> Result<(), String> {
7479        self.conn
7480            .execute_batch(&format!("SAVEPOINT {name}"))
7481            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7482    }
7483
7484    fn rollback_observation(&self, name: &str) {
7485        if let Err(error) = self
7486            .conn
7487            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
7488        {
7489            if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
7490                if failure.rollback_failure.is_none() {
7491                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
7492                }
7493            }
7494        }
7495    }
7496
7497    fn finish_observation(
7498        &mut self,
7499        name: &str,
7500        mut batch: ChangeAccumulator,
7501    ) -> Result<(), String> {
7502        #[cfg(feature = "bench-internals")]
7503        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
7504        if self.failed_commits.iter().any(|(commit, _)| {
7505            commit
7506                .ops
7507                .iter()
7508                .any(|operation| batch.tables.contains_key(&operation.table))
7509        }) {
7510            batch.outcomes = true;
7511        }
7512        if !batch.touched() {
7513            self.conn
7514                .execute_batch(&format!("RELEASE {name}"))
7515                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7516            return Ok(());
7517        }
7518        let revision = self
7519            .local_revision()
7520            .checked_add(1)
7521            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
7522        self.conn
7523            .execute(
7524                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
7525                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
7526            )
7527            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7528        let status = batch.status.then(|| self.status_snapshot());
7529        let event = ClientChangeBatch {
7530            revision: revision.to_string(),
7531            tables: batch
7532                .tables
7533                .into_iter()
7534                .map(|(table, scope_keys)| TableChange {
7535                    table,
7536                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
7537                })
7538                .collect(),
7539            windows: batch
7540                .windows
7541                .into_iter()
7542                .map(|((base_key, table), units)| WindowChange {
7543                    base_key,
7544                    table,
7545                    units: units.into_iter().collect(),
7546                })
7547                .collect(),
7548            status,
7549            conflicts_changed: batch.conflicts,
7550            rejections_changed: batch.rejections,
7551            outcomes_changed: batch.outcomes,
7552        };
7553        self.conn
7554            .execute_batch(&format!("RELEASE {name}"))
7555            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7556        let mut diagnostic_tables = event
7557            .tables
7558            .iter()
7559            .map(|entry| entry.table.clone())
7560            .collect::<BTreeSet<_>>()
7561            .into_iter()
7562            .collect::<Vec<_>>();
7563        let mut diagnostic_windows = event
7564            .windows
7565            .iter()
7566            .map(|entry| entry.table.clone())
7567            .collect::<BTreeSet<_>>()
7568            .into_iter()
7569            .collect::<Vec<_>>();
7570        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
7571            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
7572        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
7573        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
7574        self.last_change = Some(DiagnosticLastChange {
7575            revision: event.revision.clone(),
7576            recorded_at_ms: self.clock_now_ms(),
7577            tables: diagnostic_tables,
7578            windows: diagnostic_windows,
7579            domains_truncated,
7580            status_changed: event.status.is_some(),
7581            conflicts_changed: event.conflicts_changed,
7582            rejections_changed: event.rejections_changed,
7583            outcomes_changed: event.outcomes_changed,
7584        });
7585        self.change_queue.push_back(event);
7586        Ok(())
7587    }
7588
7589    fn record_scope_map(
7590        &self,
7591        batch: &mut ChangeAccumulator,
7592        table_name: &str,
7593        scopes: &[(String, Vec<String>)],
7594    ) {
7595        let Some(table) = self.schema.table(table_name) else {
7596            return;
7597        };
7598        for (variable, values) in scopes {
7599            let Some(scope) = table
7600                .scope_variables
7601                .iter()
7602                .find(|scope| &scope.variable == variable)
7603            else {
7604                continue;
7605            };
7606            for value in values {
7607                batch.scope(table_name, format!("{}:{value}", scope.prefix));
7608            }
7609        }
7610    }
7611
7612    /// Record a row's current scope keys from the base or visible table.
7613    fn record_row_scopes(
7614        &self,
7615        batch: &mut ChangeAccumulator,
7616        table_name: &str,
7617        row_id: &str,
7618        base: bool,
7619    ) -> bool {
7620        let Some(table) = self.schema.table(table_name) else {
7621            return false;
7622        };
7623        if table.scope_variables.is_empty() {
7624            return false;
7625        }
7626        let columns = table
7627            .scope_variables
7628            .iter()
7629            .map(|scope| quote_ident(&scope.column))
7630            .collect::<Vec<_>>()
7631            .join(", ");
7632        let full_table = if base {
7633            base_table(table_name)
7634        } else {
7635            visible_table(table_name)
7636        };
7637        let sql = format!(
7638            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
7639            row_id_predicate(table)
7640        );
7641        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
7642            return false;
7643        };
7644        let values = stmt.query_row(rusqlite::params![row_id], |row| {
7645            let mut values = Vec::with_capacity(table.scope_variables.len());
7646            for index in 0..table.scope_variables.len() {
7647                values.push(row.get::<_, Option<String>>(index)?);
7648            }
7649            Ok(values)
7650        });
7651        let Ok(values) = values else {
7652            return false;
7653        };
7654        let mut recorded = false;
7655        for (scope, value) in table.scope_variables.iter().zip(values) {
7656            if let Some(value) = value {
7657                batch.scope(table_name, format!("{}:{value}", scope.prefix));
7658                recorded = true;
7659            }
7660        }
7661        recorded
7662    }
7663
7664    fn record_commit_changes(
7665        &self,
7666        batch: &mut ChangeAccumulator,
7667        tables: &[String],
7668        changes: &[ssp2::model::Change],
7669    ) {
7670        #[cfg(feature = "bench-internals")]
7671        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
7672        for change in changes {
7673            let Some(table_name) = tables.get(change.table_index as usize) else {
7674                continue;
7675            };
7676            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
7677            if let Some(table) = self.schema.table(table_name) {
7678                for (variable, value) in &change.scopes {
7679                    if let Some(scope) = table
7680                        .scope_variables
7681                        .iter()
7682                        .find(|scope| &scope.variable == variable)
7683                    {
7684                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
7685                        precise = true;
7686                    }
7687                }
7688            }
7689            if !precise {
7690                batch.table(table_name);
7691            }
7692        }
7693    }
7694
7695    fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
7696        if effective.is_empty() {
7697            return false;
7698        }
7699        let Some(table) = self.schema.table(table_name) else {
7700            return false;
7701        };
7702        let mut clauses = Vec::new();
7703        let mut params = Vec::new();
7704        for (variable, values) in effective {
7705            let Some(column) = table.scope_column(variable) else {
7706                return false;
7707            };
7708            if values.is_empty() {
7709                return false;
7710            }
7711            let placeholders = values
7712                .iter()
7713                .map(|value| {
7714                    params.push(SqlValue::Text(value.clone()));
7715                    "?"
7716                })
7717                .collect::<Vec<_>>()
7718                .join(", ");
7719            clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
7720        }
7721        let sql = format!(
7722            "SELECT 1 FROM {} WHERE {} LIMIT 1",
7723            base_table(table_name),
7724            clauses.join(" AND ")
7725        );
7726        self.conn
7727            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
7728            .is_ok()
7729    }
7730
7731    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
7732    pub fn upgrading(&self) -> bool {
7733        self.upgrading
7734    }
7735
7736    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
7737    /// keeping this client's local database (identity, outbox, tables). The
7738    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
7739    /// version changed. Mirrors the TS client's boot-time detection —
7740    /// the Rust core has no persistent restart, so recreation IS the boot.
7741    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
7742        let new_schema = parse_schema_json(schema_json)?;
7743        let marker: Option<i32> = self
7744            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7745            .and_then(|v| v.parse().ok());
7746        self.schema = new_schema;
7747        self.create_bookkeeping_tables()?;
7748        if marker != Some(self.schema.version) {
7749            self.run_schema_reset()?;
7750        }
7751        self.prune_unknown_subscriptions(false)?;
7752        self.save_subscription_scope_schema();
7753        // The conformance recreate is the in-memory equivalent of reopening a
7754        // durable client. Apply the same startup catch-up contract even when
7755        // the schema itself did not change.
7756        self.enqueue_startup_sync_if_needed()?;
7757        Ok(())
7758    }
7759
7760    fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
7761        let startup_work = !self.stopped
7762            && (!self.outbox.is_empty()
7763                || self.has_acknowledged_rows()
7764                || !self.load_pending_evictions()?.is_empty()
7765                || self.subs.iter().any(|sub| sub.state == SubState::Active));
7766        if startup_work {
7767            self.sync_needed = true;
7768            self.sync_intent_queue.push_back(SyncIntent::Interactive);
7769        }
7770        Ok(())
7771    }
7772
7773    /// A native client persists schema-floor stops across process restarts.
7774    /// Once the running app already satisfies that floor, the persisted stop
7775    /// is only stale evidence from an older server round (for example, the
7776    /// server was restarted after catching up to an app that was ahead).
7777    /// Clear it and let the normal startup pull re-negotiate. If the server is
7778    /// still incompatible it will return the floor again in that first round.
7779    fn clear_satisfied_persisted_schema_floor(&mut self) {
7780        let satisfied = self
7781            .schema_floor
7782            .as_ref()
7783            .and_then(|floor| floor.required_schema_version)
7784            .is_some_and(|required| self.schema.version >= required);
7785        if !satisfied {
7786            return;
7787        }
7788        self.schema_floor = None;
7789        self.stopped = false;
7790        self.delete_meta(SCHEMA_FLOOR_KEY);
7791    }
7792
7793    /// RFC 0005 D1: discard an orphan or stale container at boot. The reset
7794    /// path already swept it; this covers a same-version or fresh-install open,
7795    /// which runs no reset and therefore no sweep — plus the D7 TTL, which only
7796    /// an aware binary can apply.
7797    fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
7798        let Some(replica_path) = self.previous_version_replica_path() else {
7799            return Ok(());
7800        };
7801        let max_age_ms = self
7802            .previous_version
7803            .filter(|config| config.enabled)
7804            .map(|config| config.max_age_ms);
7805        reconcile_previous_version_at_boot(
7806            &self.conn,
7807            &replica_path,
7808            self.schema.version,
7809            max_age_ms,
7810            self.clock_now_ms(),
7811        )
7812    }
7813
7814    /// RFC 0005 D3: the sibling container path, or `None` when the replica has
7815    /// no file — an in-memory replica returns an empty path, and a `file:` URI
7816    /// has no plain sibling path — so the feature resolves as off there.
7817    fn previous_version_replica_path(&self) -> Option<String> {
7818        let path = self.conn.path()?;
7819        if path.is_empty() || path.starts_with("file:") {
7820            return None;
7821        }
7822        Some(path.to_owned())
7823    }
7824
7825    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
7826    /// and leaseState. Drops/recreates every synced table from the new
7827    /// schema, resets subscription sync-state (keeping registrations), clears
7828    /// the schema-floor stop state, rewrites the marker, drops outbox commits
7829    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
7830    fn run_schema_reset(&mut self) -> Result<(), String> {
7831        self.begin_observation("syncular_schema_reset")?;
7832        let prior_subs = self.subs.clone();
7833        let mut batch = ChangeAccumulator::default();
7834        let result = self.run_schema_reset_observed(&mut batch, true, true);
7835        if let Err(error) = result {
7836            self.rollback_observation("syncular_schema_reset");
7837            self.subs = prior_subs;
7838            return Err(error);
7839        }
7840        if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
7841            self.rollback_observation("syncular_schema_reset");
7842            self.subs = prior_subs;
7843            return Err(error);
7844        }
7845        Ok(())
7846    }
7847
7848    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
7849        if self.get_meta(LOG_EPOCH_KEY).is_none() {
7850            self.begin_observation("syncular_epoch_acquisition")?;
7851            let previous_sync_needed = self.sync_needed;
7852            if let Err(error) = self.conn.execute(
7853                "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
7854                rusqlite::params![LOG_EPOCH_KEY, log_epoch],
7855            ) {
7856                self.rollback_observation("syncular_epoch_acquisition");
7857                return Err(Self::sqlite_failure(&self.storage_failure, error));
7858            }
7859            self.sync_needed = true;
7860            let batch = ChangeAccumulator {
7861                status: true,
7862                ..ChangeAccumulator::default()
7863            };
7864            if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
7865                self.rollback_observation("syncular_epoch_acquisition");
7866                self.sync_needed = previous_sync_needed;
7867                return Err(error);
7868            }
7869            self.sync_intent_queue.push_back(SyncIntent::Interactive);
7870            return Ok(Vec::new());
7871        }
7872        let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
7873        self.begin_observation("syncular_log_epoch_reset")?;
7874        let prior_subs = self.subs.clone();
7875        let mut batch = ChangeAccumulator::default();
7876        let result = self.run_schema_reset_observed(&mut batch, false, false);
7877        if let Err(error) = result {
7878            self.rollback_observation("syncular_log_epoch_reset");
7879            self.subs = prior_subs;
7880            return Err(error);
7881        }
7882        self.set_meta(LOG_EPOCH_KEY, log_epoch);
7883        self.sync_needed = true;
7884        batch.status = true;
7885        if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
7886            self.rollback_observation("syncular_log_epoch_reset");
7887            self.subs = prior_subs;
7888            return Err(error);
7889        }
7890        self.sync_intent_queue.push_back(SyncIntent::Interactive);
7891        Ok(resets)
7892    }
7893
7894    fn run_schema_reset_observed(
7895        &mut self,
7896        batch: &mut ChangeAccumulator,
7897        drop_incompatible: bool,
7898        capture: bool,
7899    ) -> Result<(), String> {
7900        self.conn
7901            .execute_batch(
7902                "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
7903            )
7904            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7905        self.cancel_sync_round();
7906        self.upgrading = true;
7907        batch.status = true;
7908        // RFC 0005 D6: classify the pending outbox against the NEW compiled
7909        // schema BEFORE the wipe, and record the advisory audit. It drops
7910        // nothing — the §7.4.4 send-time drop stays the only path that removes
7911        // a commit. The classification is shared with that drop so the two can
7912        // never disagree about what is incompatible.
7913        if self.previous_version.is_some_and(|config| config.enabled) {
7914            let previous_version = self
7915                .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7916                .and_then(|value| value.parse::<i32>().ok())
7917                .unwrap_or(self.schema.version);
7918            let pending = self
7919                .outbox
7920                .iter()
7921                .map(|commit| PendingCommitAudit {
7922                    commit_id: &commit.client_commit_id,
7923                    operations: commit_audit_operations(commit),
7924                })
7925                .collect::<Vec<_>>();
7926            crate::previous_version::write_previous_version_audit(
7927                &self.conn,
7928                &crate::previous_version::build_previous_version_audit(
7929                    &self.schema,
7930                    &pending,
7931                    previous_version,
7932                    self.schema.version,
7933                    self.clock_now_ms(),
7934                ),
7935            );
7936        }
7937        // RFC 0005 D5: the container lives in its own FILE, so the reset can
7938        // never reach it. Sweep first (unconditional — also on the log-epoch
7939        // reset, which captures nothing), then capture BEFORE the wipe drops
7940        // any row. A refused capture records its reason and leaves no file.
7941        if let Some(replica_path) = self.previous_version_replica_path() {
7942            sweep_previous_version_container(&self.conn, &replica_path)?;
7943            if capture {
7944                if let Some(config) = self.previous_version.filter(|config| config.enabled) {
7945                    let previous_version = self
7946                        .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7947                        .and_then(|value| value.parse::<i32>().ok())
7948                        .unwrap_or(self.schema.version);
7949                    capture_previous_version_from_replica(
7950                        &self.conn,
7951                        &replica_path,
7952                        previous_version,
7953                        self.schema.version,
7954                        &config,
7955                        self.clock_now_ms(),
7956                    )?;
7957                }
7958            }
7959        }
7960        for table in &self.schema.tables {
7961            batch.table(&table.name);
7962        }
7963        for (base_key, unit, table) in self.load_registered_window_units() {
7964            batch.window(&base_key, &table, &unit);
7965        }
7966        if capture {
7967            self.prune_unknown_subscriptions(true)?;
7968        }
7969        // The per-table insert SQL is derived from the OLD column lists.
7970        self.insert_sql.borrow_mut().clear();
7971        self.overlay_dirty.set(true);
7972        // Drop every synced table (base + visible) that currently exists —
7973        // discovered from sqlite_master so a bump that adds/removes tables is
7974        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
7975        // and the blob cache) are preserved; base tables are `_syncular_base_*`
7976        // so they are matched explicitly, not by the bookkeeping filter.
7977        // Drop virtual tables first so SQLite removes every FTS shadow table
7978        // atomically instead of the generic discovery tearing it apart.
7979        let virtual_tables: Vec<String> = {
7980            let mut stmt = self
7981                .conn
7982                .prepare(
7983                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
7984                )
7985                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7986            let rows = stmt
7987                .query_map([], |row| row.get::<_, String>(0))
7988                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7989            rows.filter_map(Result::ok)
7990                .filter(|name| is_synced_table_name(name))
7991                .collect()
7992        };
7993        for name in virtual_tables {
7994            self.conn
7995                .execute_batch(&format!(
7996                    "DROP TABLE IF EXISTS {}",
7997                    quote_ident(&format!("_syncular_fts_{name}"))
7998                ))
7999                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8000            self.conn
8001                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
8002                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8003        }
8004        let existing: Vec<String> = {
8005            let mut stmt = self
8006                .conn
8007                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
8008                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8009            let rows = stmt
8010                .query_map([], |row| row.get::<_, String>(0))
8011                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8012            rows.filter_map(Result::ok)
8013                .filter(|name| is_synced_table_name(name))
8014                .collect()
8015        };
8016        for name in &existing {
8017            if let Some(table) = name.strip_prefix("_syncular_base_") {
8018                batch.table(table);
8019            } else if !name.starts_with("_syncular_") {
8020                batch.table(name);
8021            }
8022        }
8023        for name in existing {
8024            self.conn
8025                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
8026                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8027        }
8028        // Recreate the synced tables from the NEW schema.
8029        self.create_synced_tables()?;
8030        // Reset every subscription's sync-state, keeping the registration.
8031        for sub in &mut self.subs {
8032            sub.cursor = -1;
8033            sub.bootstrap_state = None;
8034            sub.effective = None;
8035            sub.state = SubState::Active;
8036            sub.reason_code = None;
8037            sub.synced_once = false;
8038        }
8039        let subs = self.subs.clone();
8040        for sub in &subs {
8041            self.persist_sub(sub)?;
8042        }
8043        // The stop state is over: this client now ships a servable schema.
8044        self.stopped = false;
8045        self.schema_floor = None;
8046        self.delete_meta(SCHEMA_FLOOR_KEY);
8047        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
8048        self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
8049        // D1: the descriptor follows every marker write. A crash in between
8050        // self-heals on the next same-version open, which backfills it.
8051        set_local_schema_descriptor(&self.conn, &self.schema);
8052        self.save_subscription_scope_schema();
8053        // §7.4.4: drop outbox commits that cannot re-encode under the new
8054        // schema (a referenced column/table the bump removed), surfacing each
8055        // as a `sync.outbox_incompatible` rejection.
8056        if drop_incompatible && self.drop_incompatible_outbox()? {
8057            batch.rejections = true;
8058            batch.status = true;
8059            batch.outcomes = true;
8060        }
8061        // Re-apply the surviving outbox optimistically over the empty tables.
8062        self.rebuild_overlay();
8063        Ok(())
8064    }
8065
8066    /// §7.4.4: a persisted upsert whose values reference a column the current
8067    /// schema lacks (or a removed table) cannot be encoded. Drop the commit
8068    /// and raise a client-local `sync.outbox_incompatible` rejection.
8069    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
8070        let incompatible = self
8071            .outbox
8072            .iter()
8073            .filter(|commit| {
8074                first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
8075            })
8076            .cloned()
8077            .collect::<Vec<_>>();
8078        if incompatible.is_empty() {
8079            return Ok(false);
8080        }
8081        let mut rejections = Vec::new();
8082        for commit in &incompatible {
8083            let results = commit
8084                .ops
8085                .iter()
8086                .enumerate()
8087                .map(|(op_index, operation)| {
8088                    let rejection = RejectionRecord {
8089                        client_commit_id: commit.client_commit_id.clone(),
8090                        op_index: op_index as i32,
8091                        code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
8092                        message: "the persisted commit cannot encode under the current schema"
8093                            .to_owned(),
8094                        retryable: false,
8095                        details: None,
8096                        operation: Some(CommitOperation::from(operation)),
8097                    };
8098                    rejections.push(rejection.clone());
8099                    CommitOperationOutcome::Error { rejection }
8100                })
8101                .collect::<Vec<_>>();
8102            self.persist_commit_outcome(
8103                &commit.client_commit_id,
8104                CommitOutcomeStatus::Rejected,
8105                &results,
8106                Some(&commit.ops),
8107            )?;
8108            self.delete_outbox_persisted(&commit.client_commit_id)?;
8109        }
8110        self.prune_commit_outcomes()?;
8111        let incompatible_ids = incompatible
8112            .iter()
8113            .map(|commit| commit.client_commit_id.as_str())
8114            .collect::<BTreeSet<_>>();
8115        self.outbox
8116            .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
8117        self.rejections.extend(rejections);
8118        Ok(true)
8119    }
8120
8121    /// §7.4.3: (re)create the base + visible table pair for every synced
8122    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
8123    fn create_synced_tables(&self) -> Result<(), String> {
8124        for table in &self.schema.tables {
8125            // The base half + the visible half form the synced-table pair. An
8126            // index name is global in SQLite, so the base half's indexes are
8127            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
8128            for (full, index_prefix) in [
8129                (base_table(&table.name), "_syncular_base_"),
8130                (visible_table(&table.name), ""),
8131            ] {
8132                let mut cols: Vec<String> =
8133                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
8134                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
8135                let sql = format!(
8136                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
8137                    cols.join(", "),
8138                    quote_ident(&table.primary_key)
8139                );
8140                self.conn
8141                    .execute(&sql, [])
8142                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8143                // Local secondary indexes (CREATE INDEX subset). Created on
8144                // both halves so mirror reads hit an index on either. Runs on
8145                // both the initial create and the §7.4.3 reset recreate path.
8146                for index in &table.indexes {
8147                    let unique = if index.unique { "UNIQUE " } else { "" };
8148                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
8149                    let cols_sql = index
8150                        .columns
8151                        .iter()
8152                        .map(|c| quote_ident(c))
8153                        .collect::<Vec<_>>()
8154                        .join(", ");
8155                    let index_sql = format!(
8156                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
8157                    );
8158                    self.conn
8159                        .execute(&index_sql, [])
8160                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8161                }
8162            }
8163        }
8164        // FTS exists only on the visible half. It is a local search
8165        // projection, never a synced/base table or a wire-schema member.
8166        for table in &self.schema.tables {
8167            for index in &table.fts_indexes {
8168                self.create_fts_projection(table, index)?;
8169            }
8170        }
8171        Ok(())
8172    }
8173
8174    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
8175        let count: i64 = self
8176            .conn
8177            .query_row(
8178                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
8179                rusqlite::params![index.name],
8180                |row| row.get(0),
8181            )
8182            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8183        Ok(count > 0)
8184    }
8185
8186    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
8187        for suffix in ["ai", "ad", "au"] {
8188            self.conn
8189                .execute(
8190                    &format!(
8191                        "DROP TRIGGER IF EXISTS {}",
8192                        quote_ident(&format!("{}_{suffix}", index.name))
8193                    ),
8194                    [],
8195                )
8196                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8197        }
8198        Ok(())
8199    }
8200
8201    fn create_fts_triggers(
8202        &self,
8203        table: &TableSchema,
8204        index: &FtsIndexSchema,
8205    ) -> Result<(), String> {
8206        let fts = quote_ident(&index.name);
8207        let source = visible_table(&table.name);
8208        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
8209        let pk = quote_ident(&table.primary_key);
8210        let projection_columns = std::iter::once(source_id.clone())
8211            .chain(index.columns.iter().map(|column| quote_ident(column)))
8212            .collect::<Vec<_>>()
8213            .join(", ");
8214        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
8215            .chain(
8216                index
8217                    .columns
8218                    .iter()
8219                    .map(|column| format!("new.{}", quote_ident(column))),
8220            )
8221            .collect::<Vec<_>>()
8222            .join(", ");
8223        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
8224        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)");
8225        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)");
8226        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})");
8227        let bi = quote_ident(&format!("{}_bi", index.name));
8228        let ai = quote_ident(&format!("{}_ai", index.name));
8229        let ad = quote_ident(&format!("{}_ad", index.name));
8230        let au = quote_ident(&format!("{}_au", index.name));
8231        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
8232        let sql = format!(
8233            "DROP TRIGGER IF EXISTS {bi};
8234             DROP TRIGGER IF EXISTS {ai};
8235             DROP TRIGGER IF EXISTS {ad};
8236             DROP TRIGGER IF EXISTS {au};
8237             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
8238             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
8239             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
8240             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
8241        );
8242        self.conn
8243            .execute_batch(&sql)
8244            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8245    }
8246
8247    fn rebuild_fts_projection(
8248        &self,
8249        table: &TableSchema,
8250        index: &FtsIndexSchema,
8251    ) -> Result<(), String> {
8252        let fts = quote_ident(&index.name);
8253        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
8254        let pk = quote_ident(&table.primary_key);
8255        let indexed_columns = index
8256            .columns
8257            .iter()
8258            .map(|column| quote_ident(column))
8259            .collect::<Vec<_>>();
8260        let projection_columns = std::iter::once(source_id.clone())
8261            .chain(indexed_columns.iter().cloned())
8262            .collect::<Vec<_>>()
8263            .join(", ");
8264        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
8265        let sql = format!(
8266            "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};",
8267            columns = indexed_columns.join(", "),
8268            source = visible_table(&table.name),
8269        );
8270        self.conn
8271            .execute_batch(&sql)
8272            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8273    }
8274
8275    fn create_fts_projection(
8276        &self,
8277        table: &TableSchema,
8278        index: &FtsIndexSchema,
8279    ) -> Result<(), String> {
8280        self.begin_observation("syncular_fts_schema")?;
8281        let result = (|| {
8282            let existed = self.fts_projection_exists(index)?;
8283            let tokenizer = index.tokenize.replace('\'', "''");
8284            let columns = index
8285                .columns
8286                .iter()
8287                .map(|column| quote_ident(column))
8288                .collect::<Vec<_>>()
8289                .join(", ");
8290            let sql = format!(
8291            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
8292            fts = quote_ident(&index.name),
8293            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
8294        );
8295            self.conn.execute(&sql, []).map_err(|error| {
8296                format!(
8297                    "cannot create local FTS5 projection {:?}: {error}",
8298                    index.name
8299                )
8300            })?;
8301            let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
8302            let mapped: bool = self
8303                .conn
8304                .query_row(
8305                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
8306                    [format!("_syncular_fts_{}", index.name)],
8307                    |row| row.get(0),
8308                )
8309                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8310            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))?;
8311            if !mapped && existed {
8312                self.conn
8313                    .execute_batch(&format!(
8314                        "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
8315                        quote_ident(FTS_SOURCE_ID_COLUMN),
8316                        quote_ident(&index.name)
8317                    ))
8318                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8319            }
8320            self.create_fts_triggers(table, index)?;
8321            if !existed {
8322                self.rebuild_fts_projection(table, index)?;
8323            }
8324            Ok(())
8325        })();
8326        if let Err(error) = result {
8327            self.rollback_observation("syncular_fts_schema");
8328            return Err(error);
8329        }
8330        if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
8331            self.rollback_observation("syncular_fts_schema");
8332            return Err(error.to_string());
8333        }
8334        Ok(())
8335    }
8336
8337    // -- persistence write-through --------------------------------------------
8338
8339    fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
8340        let state = serde_json::json!({
8341            "requested": scope_map_to_json(&sub.requested),
8342            "params": sub.params,
8343            "cursor": sub.cursor,
8344            "bootstrapState": sub.bootstrap_state,
8345            "status": sub.state.name(),
8346            "reasonCode": sub.reason_code,
8347            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
8348            "syncedOnce": sub.synced_once,
8349        });
8350        self.conn.execute(
8351            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
8352            rusqlite::params![sub.id, sub.table, state.to_string()],
8353        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8354    }
8355
8356    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
8357        let ops: Vec<Value> = commit
8358            .ops
8359            .iter()
8360            .map(|op| {
8361                serde_json::json!({
8362                    "op": if op.upsert { "upsert" } else { "delete" },
8363                    "table": op.table,
8364                    "rowId": op.row_id,
8365                    "baseVersion": op.base_version,
8366                    "values": op.values.clone().map(Value::Object),
8367                })
8368            })
8369            .collect();
8370        self.conn
8371            .execute(
8372                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
8373                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
8374            )
8375            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8376        for operation in &commit.ops {
8377            let Some(values) = &operation.values else {
8378                continue;
8379            };
8380            let Some(table) = self.schema.table(&operation.table) else {
8381                continue;
8382            };
8383            for column in table
8384                .columns
8385                .iter()
8386                .filter(|column| column.ty == ColumnType::BlobRef)
8387            {
8388                let Some(Value::String(raw)) = values.get(&column.name) else {
8389                    continue;
8390                };
8391                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
8392                    continue;
8393                };
8394                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
8395                    continue;
8396                };
8397                self.conn
8398                    .execute(
8399                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
8400                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
8401                        rusqlite::params![commit.client_commit_id, blob_id],
8402                    )
8403                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8404            }
8405        }
8406        Ok(())
8407    }
8408
8409    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
8410        if let Some(commit) = self
8411            .outbox
8412            .iter()
8413            .find(|commit| commit.client_commit_id == client_commit_id)
8414        {
8415            for operation in &commit.ops {
8416                self.overlay_dirty.table(&operation.table);
8417            }
8418        }
8419
8420        self.conn
8421            .execute(
8422                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
8423                rusqlite::params![client_commit_id],
8424            )
8425            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8426        self.conn
8427            .execute(
8428                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
8429                rusqlite::params![client_commit_id],
8430            )
8431            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8432        self.conn.execute("DELETE FROM _syncular_row_deliveries WHERE NOT EXISTS(SELECT 1 FROM _syncular_outbox,json_each(ops_json) WHERE json_extract(json_each.value,'$.table')=tbl AND json_extract(json_each.value,'$.rowId')=id)", [])
8433            .map(|_| ())
8434            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8435    }
8436
8437    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
8438        match status {
8439            CommitOutcomeStatus::Applied => "applied",
8440            CommitOutcomeStatus::Cached => "cached",
8441            CommitOutcomeStatus::Conflict => "conflict",
8442            CommitOutcomeStatus::Rejected => "rejected",
8443        }
8444    }
8445
8446    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
8447        match resolution {
8448            CommitOutcomeResolution::Active => "active",
8449            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
8450            CommitOutcomeResolution::Superseded => "superseded",
8451            CommitOutcomeResolution::Dismissed => "dismissed",
8452        }
8453    }
8454
8455    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
8456        match value {
8457            "applied" => Ok(CommitOutcomeStatus::Applied),
8458            "cached" => Ok(CommitOutcomeStatus::Cached),
8459            "conflict" => Ok(CommitOutcomeStatus::Conflict),
8460            "rejected" => Ok(CommitOutcomeStatus::Rejected),
8461            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
8462        }
8463    }
8464
8465    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
8466        match value {
8467            "active" => Ok(CommitOutcomeResolution::Active),
8468            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
8469            "superseded" => Ok(CommitOutcomeResolution::Superseded),
8470            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
8471            _ => Err(format!(
8472                "invalid persisted commit outcome resolution {value:?}"
8473            )),
8474        }
8475    }
8476
8477    fn persist_commit_outcome(
8478        &self,
8479        client_commit_id: &str,
8480        status: CommitOutcomeStatus,
8481        results: &[CommitOperationOutcome],
8482        operations: Option<&[OutboxOp]>,
8483    ) -> Result<(), String> {
8484        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
8485        let operations_json = operations
8486            .map(|items| {
8487                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
8488            })
8489            .transpose()
8490            .map_err(|error| error.to_string())?;
8491        self.conn
8492            .execute(
8493                "INSERT INTO _syncular_commit_outcomes (
8494                   client_commit_id, status, recorded_at_ms, results_json,
8495                   operations_json, resolution
8496                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
8497                rusqlite::params![
8498                    client_commit_id,
8499                    Self::outcome_status_name(status),
8500                    self.clock_now_ms(),
8501                    results_json,
8502                    operations_json
8503                ],
8504            )
8505            .map(|_| ())
8506            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8507    }
8508
8509    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
8510        let StoredCommitOutcomeRow {
8511            sequence,
8512            client_commit_id,
8513            status,
8514            recorded_at_ms,
8515            results_json,
8516            operations_json,
8517            resolution,
8518            resolved_at_ms,
8519            replacement_client_commit_id,
8520        } = row;
8521        Ok(CommitOutcome {
8522            sequence,
8523            client_commit_id,
8524            status: Self::parse_outcome_status(&status)?,
8525            recorded_at_ms,
8526            results: serde_json::from_str(&results_json)
8527                .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
8528            operations: operations_json
8529                .map(|value| {
8530                    serde_json::from_str(&value).map_err(|error| {
8531                        format!("invalid persisted commit outcome operations: {error}")
8532                    })
8533                })
8534                .transpose()?,
8535            retained_rows: None,
8536            resolution: Self::parse_outcome_resolution(&resolution)?,
8537            resolved_at_ms,
8538            replacement_client_commit_id,
8539        })
8540    }
8541
8542    /// Resolve declared unique keys through SQLite, including affinity and NULL semantics.
8543    fn unique_conflicts(
8544        &self,
8545        table: &TableSchema,
8546        values: &Map<String, Value>,
8547        base: bool,
8548    ) -> Result<Vec<RetainedUniqueConflict>, String> {
8549        let source = if base {
8550            base_table(&table.name)
8551        } else {
8552            visible_table(&table.name)
8553        };
8554        let mut conflicts = Vec::new();
8555        for index in table.indexes.iter().filter(|index| index.unique) {
8556            let mut params = vec![json_param_to_sql(
8557                values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
8558            )?];
8559            for name in &index.columns {
8560                let column = table
8561                    .columns
8562                    .iter()
8563                    .find(|column| column.name == *name)
8564                    .ok_or("sync.local_corrupt")?;
8565                let cell = json_to_column_value(column, values.get(name))?;
8566                params.push(owned_sql_value(RowParam::Cell(&cell))?);
8567            }
8568            let sql = format!(
8569                "SELECT * FROM {source} WHERE {} != ? AND {}",
8570                quote_ident(&table.primary_key),
8571                index
8572                    .columns
8573                    .iter()
8574                    .map(|column| format!("{} = ?", quote_ident(column)))
8575                    .collect::<Vec<_>>()
8576                    .join(" AND ")
8577            );
8578            let mut statement = self
8579                .conn
8580                .prepare_cached(&sql)
8581                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8582            let records = statement
8583                .query_map(rusqlite::params_from_iter(params), |record| {
8584                    let values = table
8585                        .columns
8586                        .iter()
8587                        .enumerate()
8588                        .map(|(index, column)| {
8589                            let value = sql_ref_to_json(column, record.get_ref(index)?);
8590                            json_to_column_value(column, Some(&value)).map_err(|message| {
8591                                rusqlite::Error::ToSqlConversionFailure(message.into())
8592                            })
8593                        })
8594                        .collect::<Result<Row, _>>()?;
8595                    Ok((values, record.get::<_, i64>(table.columns.len())?))
8596                })
8597                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8598            for record in records {
8599                let (row, version) =
8600                    record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8601                let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
8602                conflicts.push(RetainedUniqueConflict {
8603                    index: index.name.clone(),
8604                    columns: index.columns.clone(),
8605                    row_id,
8606                    server_row: table
8607                        .columns
8608                        .iter()
8609                        .zip(&row)
8610                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
8611                        .collect(),
8612                    server_version: version,
8613                });
8614            }
8615        }
8616        Ok(conflicts)
8617    }
8618
8619    fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
8620        if let Some((commit, initial)) = self
8621            .failed_commits
8622            .iter()
8623            .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
8624        {
8625            let mut rows = Vec::new();
8626            for operation in &commit.ops {
8627                let table = self
8628                    .schema
8629                    .table(&operation.table)
8630                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
8631                let key =
8632                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
8633                        .map_err(|_| "sync.local_corrupt".to_owned())?;
8634                let base = self.stored_row(table, &operation.row_id, true);
8635                rows.push(RetainedCommitRow {
8636                    table: operation.table.clone(),
8637                    row_id: operation.row_id.clone(),
8638                    unique_conflicts: if operation.upsert {
8639                        self.unique_conflicts(
8640                            table,
8641                            initial
8642                                .get(&key)
8643                                .and_then(Value::as_object)
8644                                .or(operation.values.as_ref())
8645                                .ok_or("sync.local_corrupt")?,
8646                            true,
8647                        )?
8648                    } else {
8649                        Vec::new()
8650                    },
8651                    local_row: if operation.upsert {
8652                        initial
8653                            .get(&key)
8654                            .and_then(Value::as_object)
8655                            .cloned()
8656                            .or_else(|| operation.values.clone())
8657                    } else {
8658                        None
8659                    },
8660                    server_row: base.as_ref().map(|(values, _)| {
8661                        table
8662                            .columns
8663                            .iter()
8664                            .zip(values)
8665                            .map(|(column, value)| {
8666                                (column.name.clone(), column_value_to_json(value))
8667                            })
8668                            .collect()
8669                    }),
8670                    server_version: base.map(|(_, version)| version),
8671                });
8672            }
8673            outcome.retained_rows = Some(rows);
8674        }
8675        Ok(outcome)
8676    }
8677
8678    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
8679        let row = self
8680            .conn
8681            .query_row(
8682                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
8683                        resolution, resolved_at_ms, replacement_client_commit_id
8684                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
8685                rusqlite::params![client_commit_id],
8686                |row| {
8687                    Ok(StoredCommitOutcomeRow {
8688                        sequence: row.get(0)?,
8689                        client_commit_id: row.get(1)?,
8690                        status: row.get(2)?,
8691                        recorded_at_ms: row.get(3)?,
8692                        results_json: row.get(4)?,
8693                        operations_json: row.get(5)?,
8694                        resolution: row.get(6)?,
8695                        resolved_at_ms: row.get(7)?,
8696                        replacement_client_commit_id: row.get(8)?,
8697                    })
8698                },
8699            )
8700            .optional()
8701            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8702        row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
8703            .transpose()
8704    }
8705
8706    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
8707        if query.limit == Some(0) {
8708            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
8709        }
8710        let mut sql = String::from(
8711            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
8712                    resolution, resolved_at_ms, replacement_client_commit_id
8713               FROM _syncular_commit_outcomes",
8714        );
8715        if query.active_only {
8716            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
8717        }
8718        sql.push_str(" ORDER BY seq DESC");
8719        if let Some(limit) = query.limit {
8720            sql.push_str(&format!(" LIMIT {limit}"));
8721        }
8722        let mut stmt = self
8723            .conn
8724            .prepare(&sql)
8725            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8726        let rows = stmt
8727            .query_map([], |row| {
8728                Ok(StoredCommitOutcomeRow {
8729                    sequence: row.get(0)?,
8730                    client_commit_id: row.get(1)?,
8731                    status: row.get(2)?,
8732                    recorded_at_ms: row.get(3)?,
8733                    results_json: row.get(4)?,
8734                    operations_json: row.get(5)?,
8735                    resolution: row.get(6)?,
8736                    resolved_at_ms: row.get(7)?,
8737                    replacement_client_commit_id: row.get(8)?,
8738                })
8739            })
8740            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8741        let mut outcomes = Vec::new();
8742        for row in rows {
8743            outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
8744                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
8745            )?)?);
8746        }
8747        Ok(outcomes)
8748    }
8749
8750    fn prune_commit_outcomes(&self) -> Result<(), String> {
8751        #[cfg(test)]
8752        self.outcome_prune_count
8753            .set(self.outcome_prune_count.get() + 1);
8754        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
8755        let count = self
8756            .conn
8757            .query_row(
8758                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
8759                [],
8760                |row| row.get::<_, i64>(0),
8761            )
8762            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
8763            as usize;
8764        let excess = count.saturating_sub(max_entries);
8765        if excess == 0 {
8766            return Ok(());
8767        }
8768        let mut stmt = self
8769            .conn
8770            .prepare(
8771                "SELECT seq FROM _syncular_commit_outcomes
8772                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
8773                  ORDER BY seq ASC LIMIT ?1",
8774            )
8775            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8776        let rows = stmt
8777            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
8778            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8779        let sequences = rows
8780            .collect::<Result<Vec<_>, _>>()
8781            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8782        drop(stmt);
8783        for sequence in sequences {
8784            self.conn
8785                .execute(
8786                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
8787                    rusqlite::params![sequence],
8788                )
8789                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8790        }
8791        Ok(())
8792    }
8793
8794    // -- driver surface ---------------------------------------------------------
8795
8796    pub fn subscribe(
8797        &mut self,
8798        id: String,
8799        table: String,
8800        scopes: Vec<(String, Vec<String>)>,
8801        params: Option<String>,
8802    ) -> Result<(), String> {
8803        if self.schema.table(&table).is_none() {
8804            return Err(format!("unknown table {table:?}"));
8805        }
8806        if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
8807            let same_intent = existing.table == table
8808                && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
8809                && existing.params == params;
8810            if same_intent {
8811                return Ok(());
8812            }
8813            return Err(
8814                "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
8815                    .to_owned(),
8816            );
8817        }
8818        let sub = Subscription {
8819            id: id.clone(),
8820            table,
8821            requested: scopes,
8822            params,
8823            cursor: -1,
8824            bootstrap_state: None,
8825            state: SubState::Active,
8826            reason_code: None,
8827            effective: None,
8828            synced_once: false,
8829        };
8830        self.persist_sub(&sub)?;
8831        self.subs.push(sub);
8832        Ok(())
8833    }
8834
8835    pub fn unsubscribe(&mut self, id: &str) {
8836        if self.subs.iter().any(|s| s.id == id) {
8837            self.cancel_sync_round();
8838        }
8839        self.subs.retain(|s| s.id != id);
8840        let _ = self.conn.execute(
8841            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
8842            rusqlite::params![id],
8843        );
8844    }
8845
8846    // -- windowed subscriptions (§4.8) ------------------------------------------
8847
8848    /// §4.8: set the live window units for a base — a value-sharded family
8849    /// of subscriptions, one per unit. Added units get fresh subscriptions
8850    /// (image-lane bootstrap on the next sync); removed units are
8851    /// unsubscribed with their first eviction chunk in one transaction (E1–E4).
8852    /// Idempotent; re-entry cancels any deferred eviction.
8853    pub fn set_window(
8854        &mut self,
8855        base: &WindowBase,
8856        units: &[String],
8857    ) -> Result<CommandEffects, String> {
8858        let table = self
8859            .schema
8860            .table(&base.table)
8861            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
8862        if table.scope_column(&base.variable).is_none() {
8863            return Err(format!(
8864                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
8865                base.table, base.variable
8866            ));
8867        }
8868        let base_key = window_base_key(base);
8869        let wanted: std::collections::HashSet<&String> = units.iter().collect();
8870        let live = self.load_window_units(&base_key);
8871        self.begin_observation("syncular_window")?;
8872        let mut batch = ChangeAccumulator::default();
8873        let mut changed = false;
8874
8875        let prior_subs = self.subs.clone();
8876        let widened = (|| {
8877            // Widen: units wanted but not live → fresh subscription + registry row.
8878            for unit in units {
8879                if live.iter().any(|(u, _)| u == unit) {
8880                    continue;
8881                }
8882                let sub_id = derive_sub_id(base, unit);
8883                self.delete_pending_evict(&sub_id)?;
8884                self.insert_window_unit(&base_key, unit, &sub_id)?;
8885                self.subscribe(
8886                    sub_id,
8887                    base.table.clone(),
8888                    unit_scopes(base, unit),
8889                    base.params.clone(),
8890                )?;
8891                batch.window(&base_key, &base.table, unit);
8892                changed = true;
8893            }
8894
8895            self.finish_observation("syncular_window", batch)
8896        })();
8897        if let Err(error) = widened {
8898            self.rollback_observation("syncular_window");
8899            self.subs = prior_subs;
8900            return Err(error);
8901        }
8902        for (unit, sub_id) in live {
8903            if wanted.contains(&unit) {
8904                continue;
8905            }
8906            self.rebuild_overlay_if_dirty();
8907            let prior_subs = self.subs.clone();
8908            self.begin_observation("syncular_window")?;
8909            let effective = self
8910                .subs
8911                .iter()
8912                .find(|sub| sub.id == sub_id)
8913                .and_then(|sub| sub.effective.clone())
8914                .unwrap_or_else(|| unit_scopes(base, &unit));
8915            let mut batch = ChangeAccumulator::default();
8916            self.record_scope_map(&mut batch, &base.table, &effective);
8917            batch.window(&base_key, &base.table, &unit);
8918            let result = self.evict_unit(&base_key, base, &unit, &sub_id);
8919            let remaining = match result {
8920                Ok(remaining) => remaining,
8921                Err(error) => {
8922                    self.rollback_observation("syncular_window");
8923                    self.subs = prior_subs;
8924                    return Err(error);
8925                }
8926            };
8927            if let Err(error) = self.finish_observation("syncular_window", batch) {
8928                self.rollback_observation("syncular_window");
8929                self.subs = prior_subs;
8930                return Err(error);
8931            }
8932            if remaining {
8933                self.drain_pending_evictions()?;
8934            }
8935            changed = true;
8936        }
8937        Ok(if changed {
8938            CommandEffects::interactive()
8939        } else {
8940            CommandEffects::none()
8941        })
8942    }
8943
8944    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
8945    /// the subset still bootstrap-pending. Registration alone is not
8946    /// completeness — a unit is pending until its subscription completes a
8947    /// bootstrap round (cursor advances past -1 with no resume token held).
8948    pub fn window_state(&self, base: &WindowBase) -> WindowState {
8949        let mut units = Vec::new();
8950        let mut pending = Vec::new();
8951        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
8952            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
8953                Some(sub) => {
8954                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
8955                }
8956                None => true,
8957            };
8958            if is_pending {
8959                pending.push(unit.clone());
8960            }
8961            units.push(unit);
8962        }
8963        WindowState { units, pending }
8964    }
8965
8966    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
8967        let mut stmt = match self
8968            .conn
8969            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
8970        {
8971            Ok(stmt) => stmt,
8972            Err(_) => return Vec::new(),
8973        };
8974        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
8975            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8976        });
8977        match rows {
8978            Ok(rows) => rows.filter_map(Result::ok).collect(),
8979            Err(_) => Vec::new(),
8980        }
8981    }
8982
8983    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
8984        let mut stmt = match self.conn.prepare(
8985            "SELECT windows.base, windows.unit, subscriptions.tbl
8986               FROM _syncular_windows AS windows
8987               JOIN _syncular_subscriptions AS subscriptions
8988                 ON subscriptions.id = windows.sub_id
8989               ORDER BY windows.base, windows.unit",
8990        ) {
8991            Ok(stmt) => stmt,
8992            Err(_) => return Vec::new(),
8993        };
8994        let rows = stmt.query_map([], |row| {
8995            Ok((
8996                row.get::<_, String>(0)?,
8997                row.get::<_, String>(1)?,
8998                row.get::<_, String>(2)?,
8999            ))
9000        });
9001        match rows {
9002            Ok(rows) => rows.filter_map(Result::ok).collect(),
9003            Err(_) => Vec::new(),
9004        }
9005    }
9006
9007    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
9008        self.conn
9009            .query_row(
9010                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
9011                rusqlite::params![sub_id],
9012                |row| Ok((row.get(0)?, row.get(1)?)),
9013            )
9014            .ok()
9015    }
9016
9017    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
9018        self.conn
9019            .execute(
9020                "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
9021                rusqlite::params![base_key, unit, sub_id],
9022            )
9023            .map(|_| ())
9024            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
9025    }
9026
9027    fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
9028        self.conn
9029            .execute(
9030                "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
9031                rusqlite::params![base_key, unit],
9032            )
9033            .map(|_| ())
9034            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
9035    }
9036
9037    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
9038    /// Deletes the unit's rows except those pinned by a pending outbox
9039    /// commit (E1); records a deferred eviction if any pin remains; discards
9040    /// the subscription's cursor/resume/effective-echo (E3) and version
9041    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
9042    /// nothing.
9043    fn evict_unit(
9044        &mut self,
9045        base_key: &str,
9046        base: &WindowBase,
9047        unit: &str,
9048        sub_id: &str,
9049    ) -> Result<bool, String> {
9050        let effective = self
9051            .subs
9052            .iter()
9053            .find(|s| s.id == sub_id)
9054            .and_then(|s| s.effective.clone())
9055            .unwrap_or_else(|| unit_scopes(base, unit));
9056        let pinned = self.pinned_row_ids(&base.table);
9057        let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
9058        self.delete_window_unit(base_key, unit)?;
9059        self.conn
9060            .execute(
9061                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
9062                [sub_id],
9063            )
9064            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9065        self.subs.retain(|sub| sub.id != sub_id);
9066        if deferred {
9067            self.save_pending_evict(sub_id, &base.table, &effective)?;
9068        } else {
9069            self.delete_pending_evict(sub_id)?;
9070        }
9071        Ok(remaining)
9072    }
9073
9074    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
9075    /// primary keys. Returns (more unpinned work, any scoped rows remain).
9076    /// A missing scope-column mapping fails without deleting rows.
9077    fn evict_scope_rows(
9078        &self,
9079        table_name: &str,
9080        effective: &[(String, Vec<String>)],
9081        pinned: &std::collections::HashSet<String>,
9082    ) -> Result<(bool, bool), String> {
9083        if effective.is_empty() {
9084            return Ok((false, false));
9085        }
9086        let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
9087        let mut clauses = Vec::new();
9088        let mut params: Vec<SqlValue> = Vec::new();
9089        for (variable, values) in effective {
9090            let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
9091            if values.is_empty() {
9092                return Ok((false, false));
9093            }
9094            let holes = values
9095                .iter()
9096                .map(|v| {
9097                    params.push(SqlValue::Text(v.clone()));
9098                    "?"
9099                })
9100                .collect::<Vec<_>>()
9101                .join(", ");
9102            clauses.push(format!("{} IN ({holes})", quote_ident(column)));
9103        }
9104        let scope = clauses.join(" AND ");
9105        let scope_params = params.len();
9106        let pk = quote_ident(&table.primary_key);
9107        if !pinned.is_empty() {
9108            let holes = pinned
9109                .iter()
9110                .map(|id| {
9111                    params.push(SqlValue::Text(id.clone()));
9112                    "?"
9113                })
9114                .collect::<Vec<_>>()
9115                .join(", ");
9116            clauses.push(format!("{pk} NOT IN ({holes})"));
9117        }
9118        let predicate = clauses.join(" AND ");
9119        let base = base_table(table_name);
9120        let visible = visible_table(table_name);
9121        let mut ids: Vec<SqlValue> = Vec::new();
9122        for source in [&base, &visible] {
9123            let mut stmt = self
9124                .conn
9125                .prepare_cached(&format!(
9126                    "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
9127                ))
9128                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9129            ids = stmt
9130                .query_map(rusqlite::params_from_iter(&params), |row| row.get(0))
9131                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
9132                .collect::<Result<_, _>>()
9133                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9134            if !ids.is_empty() {
9135                break;
9136            }
9137        }
9138        if !ids.is_empty() {
9139            for id in &ids {
9140                self.conn
9141                    .execute(
9142                        "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
9143                        rusqlite::params![table_name, id],
9144                    )
9145                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9146            }
9147            let holes = vec!["?"; ids.len()].join(", ");
9148            for source in [&base, &visible] {
9149                self.conn
9150                    .execute(
9151                        &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
9152                        rusqlite::params_from_iter(&ids),
9153                    )
9154                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9155            }
9156        }
9157        let mut remaining = false;
9158        let mut deferred = false;
9159        for source in [&base, &visible] {
9160            remaining |= self
9161                .conn
9162                .query_row(
9163                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
9164                    rusqlite::params_from_iter(&params),
9165                    |row| row.get::<_, bool>(0),
9166                )
9167                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9168            deferred |= self
9169                .conn
9170                .query_row(
9171                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
9172                    rusqlite::params_from_iter(&params[..scope_params]),
9173                    |row| row.get::<_, bool>(0),
9174                )
9175                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9176        }
9177        Ok((remaining, deferred))
9178    }
9179
9180    /// Resume committed eviction chunks, retaining outbox pins and their durable marker.
9181    fn drain_pending_evictions(&mut self) -> Result<(), String> {
9182        let pending = self.load_pending_evictions()?;
9183        if pending.is_empty() {
9184            return Ok(());
9185        }
9186        self.rebuild_overlay_if_dirty();
9187        for (sub_id, table_name, effective) in pending {
9188            if self.schema.table(&table_name).is_none() {
9189                self.delete_pending_evict(&sub_id)?;
9190                continue;
9191            }
9192            loop {
9193                std::thread::yield_now();
9194                self.begin_observation("syncular_evict")?;
9195                let pinned = self.pinned_row_ids(&table_name);
9196                let result = self.evict_scope_rows(&table_name, &effective, &pinned);
9197                let (remaining, deferred) = match result {
9198                    Ok(result) => result,
9199                    Err(error) => {
9200                        self.rollback_observation("syncular_evict");
9201                        return Err(error);
9202                    }
9203                };
9204                if !deferred {
9205                    if let Err(error) = self.delete_pending_evict(&sub_id) {
9206                        self.rollback_observation("syncular_evict");
9207                        return Err(error);
9208                    }
9209                }
9210                let mut batch = ChangeAccumulator::default();
9211                self.record_scope_map(&mut batch, &table_name, &effective);
9212                if let Err(error) = self.finish_observation("syncular_evict", batch) {
9213                    self.rollback_observation("syncular_evict");
9214                    return Err(error);
9215                }
9216                if !remaining {
9217                    break;
9218                }
9219            }
9220        }
9221        Ok(())
9222    }
9223
9224    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
9225    /// commit — rows that MUST NOT be evicted until the commit drains.
9226    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
9227        let mut pinned = std::collections::HashSet::new();
9228        for commit in self
9229            .outbox
9230            .iter()
9231            .chain(self.failed_commits.iter().map(|(commit, _)| commit))
9232        {
9233            for op in &commit.ops {
9234                if op.table == table {
9235                    pinned.insert(op.row_id.clone());
9236                }
9237            }
9238        }
9239        pinned
9240    }
9241
9242    fn save_pending_evict(
9243        &self,
9244        sub_id: &str,
9245        table: &str,
9246        effective: &[(String, Vec<String>)],
9247    ) -> Result<(), String> {
9248        self.conn.execute(
9249            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
9250            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
9251        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
9252    }
9253
9254    fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
9255        self.conn
9256            .execute(
9257                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
9258                rusqlite::params![sub_id],
9259            )
9260            .map(|_| ())
9261            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
9262    }
9263
9264    fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
9265        let mut stmt = self
9266            .conn
9267            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
9268            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9269        let rows = stmt
9270            .query_map([], |row| {
9271                Ok((
9272                    row.get::<_, String>(0)?,
9273                    row.get::<_, String>(1)?,
9274                    row.get::<_, String>(2)?,
9275                ))
9276            })
9277            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9278        rows.map(|row| {
9279            let (sub_id, table, json) =
9280                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9281            let value: Value = serde_json::from_str(&json)
9282                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
9283            let effective = json_to_scope_map(&value)
9284                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
9285            Ok((sub_id, table, effective))
9286        })
9287        .collect()
9288    }
9289
9290    /// Select failed-intent retention; reload durable retained aggregates.
9291    pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
9292        self.retain_failed_commits = enabled;
9293        for (commit, _) in &self.failed_commits {
9294            for operation in &commit.ops {
9295                self.overlay_dirty.table(&operation.table);
9296            }
9297        }
9298        self.failed_commits = self.load_failed_commits()?;
9299        for (commit, _) in &self.failed_commits {
9300            for operation in &commit.ops {
9301                self.overlay_dirty.table(&operation.table);
9302            }
9303        }
9304        Ok(())
9305    }
9306
9307    fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
9308        let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
9309            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9310        let rows = statement
9311            .query_map([], |row| {
9312                Ok((
9313                    row.get::<_, String>(0)?,
9314                    row.get::<_, String>(1)?,
9315                    row.get::<_, String>(2)?,
9316                ))
9317            })
9318            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9319        rows.map(|row| {
9320            let (id, operations, initial) =
9321                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9322            let operations: Vec<CommitOperation> =
9323                serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
9324            let initial =
9325                serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
9326            Ok((
9327                OutboxCommit {
9328                    client_commit_id: id,
9329                    ops: operations
9330                        .into_iter()
9331                        .map(|op| OutboxOp {
9332                            upsert: op.op == "upsert",
9333                            table: op.table,
9334                            row_id: op.row_id,
9335                            base_version: op.base_version,
9336                            values: op.values,
9337                        })
9338                        .collect(),
9339                },
9340                initial,
9341            ))
9342        })
9343        .collect()
9344    }
9345
9346    fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
9347        let mut initial = Map::new();
9348        for operation in operations {
9349            let table = self
9350                .schema
9351                .table(&operation.table)
9352                .ok_or_else(|| "sync.unknown_table".to_owned())?;
9353            if let Some((values, _)) = self
9354                .stored_row(table, &operation.row_id, true)
9355                .or_else(|| self.visible_row(table, &operation.row_id))
9356            {
9357                let mut intended: Map<String, Value> = table
9358                    .columns
9359                    .iter()
9360                    .zip(values.iter())
9361                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
9362                    .collect();
9363                if let Some(patch) = &operation.values {
9364                    intended.extend(patch.clone());
9365                }
9366                initial.insert(
9367                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
9368                        .map_err(|_| "sync.local_corrupt".to_owned())?,
9369                    Value::Object(intended),
9370                );
9371            }
9372        }
9373        self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
9374            serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
9375            serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
9376            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9377        self.failed_commits.push((
9378            OutboxCommit {
9379                client_commit_id: id.to_owned(),
9380                ops: operations.to_vec(),
9381            },
9382            initial,
9383        ));
9384        Ok(())
9385    }
9386
9387    fn drop_failed_commits(
9388        &mut self,
9389        matches: impl Fn(&str, &Map<String, Value>) -> bool,
9390    ) -> Result<Vec<OutboxCommit>, String> {
9391        let mut doomed = self.drop_acknowledged_rows(&matches)?;
9392        for (commit, initial) in &self.failed_commits {
9393            for operation in &commit.ops {
9394                let key =
9395                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
9396                        .map_err(|_| "sync.local_corrupt".to_owned())?;
9397                if initial
9398                    .get(&key)
9399                    .and_then(Value::as_object)
9400                    .is_some_and(|values| matches(&operation.table, values))
9401                {
9402                    doomed.push(commit.clone());
9403                    break;
9404                }
9405            }
9406        }
9407        for commit in &doomed {
9408            self.conn
9409                .execute(
9410                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
9411                    rusqlite::params![commit.client_commit_id],
9412                )
9413                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9414        }
9415        for commit in &doomed {
9416            self.conn.execute("UPDATE _syncular_commit_outcomes SET operations_json = NULL, results_json = '[]' WHERE client_commit_id = ?1", rusqlite::params![commit.client_commit_id]).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9417        }
9418        self.conflicts.retain(|record| {
9419            !doomed
9420                .iter()
9421                .any(|commit| commit.client_commit_id == record.client_commit_id)
9422        });
9423        self.rejections.retain(|record| {
9424            !doomed
9425                .iter()
9426                .any(|commit| commit.client_commit_id == record.client_commit_id)
9427        });
9428        self.failed_commits.retain(|(commit, _)| {
9429            !doomed
9430                .iter()
9431                .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
9432        });
9433        for commit in &doomed {
9434            for operation in &commit.ops {
9435                self.overlay_dirty.table(&operation.table);
9436            }
9437        }
9438        Ok(doomed)
9439    }
9440
9441    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
9442        if mutations.is_empty() {
9443            return Err("a commit must contain at least one operation (§6.1)".to_owned());
9444        }
9445        let mut ops = Vec::with_capacity(mutations.len());
9446        for mutation in mutations {
9447            match mutation {
9448                Mutation::Patch {
9449                    table,
9450                    values,
9451                    base_version,
9452                } => {
9453                    let schema_table = self
9454                        .schema
9455                        .table(&table)
9456                        .ok_or_else(|| "sync.unknown_table".to_owned())?;
9457                    let values = normalize_values_casing(schema_table, values)?;
9458                    let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
9459                    ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
9460                }
9461                Mutation::Upsert {
9462                    table,
9463                    values,
9464                    base_version,
9465                } => {
9466                    let schema_table = self
9467                        .schema
9468                        .table(&table)
9469                        .ok_or_else(|| format!("unknown table {table:?}"))?;
9470                    // §5: value keys are accepted in snake_case AND the
9471                    // generated row types' camelCase; normalize to SQL truth
9472                    // before the pk lookup / codec see them.
9473                    let values = normalize_values_casing(schema_table, values)?;
9474                    let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
9475                    // §6.7 `mutate` marks every column present: fill missing
9476                    // nullable columns with NULL before the payload is built.
9477                    let values = full_row_values(schema_table, values)?;
9478                    // §5.11: validate the payload encodes with the current
9479                    // codec. A key-selection or unknown-key failure is a
9480                    // durable rejection at the push seam (§10.3), never an
9481                    // author-time error. Full-row `mutate` presents every
9482                    // column, so no stored fallback is needed.
9483                    self.validate_author_encode(schema_table, &row_id, &values)?;
9484                    ops.push(OutboxOp {
9485                        upsert: true,
9486                        table,
9487                        row_id,
9488                        base_version,
9489                        values: Some(values),
9490                    });
9491                }
9492                Mutation::Delete {
9493                    table,
9494                    row_id,
9495                    base_version,
9496                } => {
9497                    if self.schema.table(&table).is_none() {
9498                        return Err(format!("unknown table {table:?}"));
9499                    }
9500                    ops.push(OutboxOp {
9501                        upsert: false,
9502                        table,
9503                        row_id,
9504                        base_version,
9505                        values: None,
9506                    });
9507                }
9508            }
9509        }
9510        self.record_outbox_commit(ops)
9511    }
9512
9513    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
9514        let commit = OutboxCommit {
9515            client_commit_id: uuid::Uuid::new_v4().to_string(),
9516            ops,
9517        };
9518        self.begin_observation("syncular_mutation")?;
9519        let mut batch = ChangeAccumulator::default();
9520        for op in &commit.ops {
9521            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
9522            if let Some(values) = &op.values {
9523                if let Some(table) = self.schema.table(&op.table) {
9524                    for scope in &table.scope_variables {
9525                        if let Some(Value::String(value)) = values.get(&scope.column) {
9526                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
9527                            precise = true;
9528                        }
9529                    }
9530                }
9531            }
9532            if !precise {
9533                batch.table(&op.table);
9534            }
9535        }
9536        if let Err(error) = self.persist_outbox_insert(&commit) {
9537            self.rollback_observation("syncular_mutation");
9538            return Err(error);
9539        }
9540        let was_dirty = self.overlay_dirty.snapshot();
9541        // A clean overlay contains the FIFO fold of every previous commit, so
9542        // the new commit applies over it. §7.1: an operation that does not
9543        // apply (a secondary unique collision) fails the whole commit; the
9544        // savepoint rollback removes the outbox entry and every visible write.
9545        if self.overlay_dirty.get() {
9546            self.rebuild_overlay();
9547        }
9548        if let Some(error) = commit
9549            .ops
9550            .iter()
9551            .find_map(|op| self.apply_outbox_op(op).err())
9552        {
9553            self.rollback_observation("syncular_mutation");
9554            self.overlay_dirty.restore(was_dirty.clone());
9555            return Err(error);
9556        }
9557        let id = commit.client_commit_id.clone();
9558        self.outbox.push(commit);
9559        batch.status = true;
9560        if let Err(error) = self.finish_observation("syncular_mutation", batch) {
9561            self.rollback_observation("syncular_mutation");
9562            self.outbox.pop();
9563            self.overlay_dirty.restore(was_dirty.clone());
9564            return Err(error);
9565        }
9566        Ok(id)
9567    }
9568
9569    /// Record one §6.1 sparse upsert: the primary key plus the supplied
9570    /// non-scope columns are present; every other column is absent and stays
9571    /// untouched on the server and in the local overlay. An absent local base
9572    /// refuses the whole batch with sync.row_missing before enqueueing (§7.1).
9573    pub fn patch(
9574        &mut self,
9575        table: &str,
9576        row_id: &str,
9577        partial: Map<String, Value>,
9578        base_version: Option<i64>,
9579    ) -> Result<String, String> {
9580        let operation = self.prepare_patch(table, row_id, partial, base_version)?;
9581        self.record_outbox_commit(vec![operation])
9582    }
9583
9584    fn prepare_patch(
9585        &self,
9586        table: &str,
9587        row_id: &str,
9588        partial: Map<String, Value>,
9589        base_version: Option<i64>,
9590    ) -> Result<OutboxOp, String> {
9591        let schema_table = self
9592            .schema
9593            .table(table)
9594            .ok_or_else(|| format!("unknown table {table:?}"))?;
9595        let partial = normalize_values_casing(schema_table, partial)?;
9596        if let Some(pk) = partial.get(&schema_table.primary_key) {
9597            if pk.as_str() != Some(row_id) {
9598                return Err(format!(
9599                    "sync.invalid_request: table {table:?}: patch cannot change the primary key"
9600                ));
9601            }
9602        }
9603        // Existence reads only the key. Locked encrypted values stay opaque;
9604        // decoding the complete row here would reject a valid plain patch.
9605        let exists: bool = self
9606            .conn
9607            .query_row(
9608                &format!(
9609                    "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
9610                    visible_table(table),
9611                    row_id_predicate(schema_table)
9612                ),
9613                rusqlite::params![row_id],
9614                |row| row.get(0),
9615            )
9616            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9617        if !exists {
9618            return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
9619        }
9620        // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
9621        // server accepts a present scope column whose value equals the
9622        // stored row and applies it as a no-op, so a decoded envelope
9623        // round-trips. The local row is the only value the client can prove
9624        // equality against; when the row is absent locally the client cannot
9625        // prove equality against the server's stored row and fails closed.
9626        // A primary key that is also a scope column needs no stored-row
9627        // read: the primary key in a sparse payload is by construction the
9628        // row id being patched (§6.1), so its value is proven equal already.
9629        for scope in &schema_table.scope_variables {
9630            if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
9631                continue;
9632            }
9633            let Some(column) = schema_table
9634                .columns
9635                .iter()
9636                .find(|column| column.name == scope.column)
9637            else {
9638                continue;
9639            };
9640            let stored = self
9641                .conn
9642                .query_row(
9643                    &format!(
9644                        "SELECT {} FROM {} WHERE {}",
9645                        quote_ident(&scope.column),
9646                        visible_table(&schema_table.name),
9647                        row_id_predicate(schema_table)
9648                    ),
9649                    rusqlite::params![row_id],
9650                    |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
9651                )
9652                .ok();
9653            // Decode the supplied value through the same column-type path the
9654            // rest of the patch encoding uses, so a value that only equals
9655            // the stored one in another number representation still compares
9656            // equal.
9657            let incoming = json_to_column_value(column, partial.get(&scope.column))
9658                .ok()
9659                .map(|value| column_value_to_json(&value));
9660            // A stored blob never equals a patch value, matching the server's
9661            // scope-column comparison (§3.4): bytes are unstorable as scope
9662            // values.
9663            let equal = stored.as_ref().is_some_and(|stored| {
9664                stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
9665            });
9666            if !equal {
9667                return Err(format!(
9668                    "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
9669                    scope.column
9670                ));
9671            }
9672        }
9673        let mut values = partial;
9674        // Presence-set semantics: a proven-equal scope column is dropped, so
9675        // the patch leaves the stored value untouched (§6.2).
9676        for scope in &schema_table.scope_variables {
9677            values.remove(&scope.column);
9678        }
9679        values.insert(
9680            schema_table.primary_key.clone(),
9681            Value::from(row_id.to_owned()),
9682        );
9683        // §5.11: structural validation only; an absent key-id selector is
9684        // resolved at the push seam from the stored local row, and a
9685        // key-selection or unknown-key failure becomes a durable
9686        // `client.encrypt_failed` rejection there (§10.3), never an
9687        // author-time error.
9688        self.validate_author_encode(schema_table, row_id, &values)?;
9689        Ok(OutboxOp {
9690            upsert: true,
9691            table: table.to_owned(),
9692            row_id: row_id.to_owned(),
9693            base_version,
9694            values: Some(values),
9695        })
9696    }
9697
9698    /// §5.11 key-id fallback: the stored local row's selector value as a
9699    /// positional row (only the selector slot filled). Returns None when no
9700    /// selector is configured, the row is locally absent, or the stored
9701    /// value is not a usable key id.
9702    fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
9703        let selector = self.encryption.key_id_columns.get(&table.name)?;
9704        let index = table
9705            .columns
9706            .iter()
9707            .position(|column| &column.name == selector)?;
9708        let sql = format!(
9709            "SELECT {} FROM {} WHERE {}",
9710            quote_ident(selector),
9711            visible_table(&table.name),
9712            row_id_predicate(table)
9713        );
9714        let key: Option<String> = self
9715            .conn
9716            .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
9717            .ok()?;
9718        let key = key.filter(|key| !key.is_empty())?;
9719        let mut fallback: Row = vec![None; table.columns.len()];
9720        fallback[index] = Some(ColumnValue::String(key));
9721        Some(fallback)
9722    }
9723
9724    /// §5.11/§10.3 author-time encode check: a structural failure (a
9725    /// non-nullable NULL, a value the codec rejects) is an author-time
9726    /// error; a key-selection or unknown-key failure is deferred to the push
9727    /// seam, where it becomes a durable `client.encrypt_failed` rejection.
9728    fn validate_author_encode(
9729        &self,
9730        table: &TableSchema,
9731        row_id: &str,
9732        values: &Map<String, Value>,
9733    ) -> Result<(), String> {
9734        match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
9735            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
9736            result => result.map(|_| ()),
9737        }
9738    }
9739
9740    /// §5.11/§10.3: drop every pending commit whose push encode fails with
9741    /// `client.encrypt_failed` (an unresolvable key id or an unknown key) and
9742    /// raise one durable rejection per commit. `sync()` never aborts for an
9743    /// encode failure. Mirrors §7.4.4's `drop_incompatible_outbox`, including
9744    /// undoing the dropped commit's purely-optimistic rows.
9745    fn drop_unencodable_outbox(&mut self) -> Result<bool, String> {
9746        if !self
9747            .schema
9748            .tables
9749            .iter()
9750            .any(|table| table.has_encrypted_columns())
9751        {
9752            return Ok(false);
9753        }
9754        let failures = self
9755            .outbox
9756            .iter()
9757            .filter_map(|commit| {
9758                commit
9759                    .ops
9760                    .iter()
9761                    .find_map(|op| {
9762                        let values = op.values.as_ref().filter(|_| op.upsert)?;
9763                        let table = self.schema.table(&op.table)?;
9764                        let fallback = self.stored_key_fallback(table, &op.row_id);
9765                        match encode_sparse_row_json(
9766                            table,
9767                            &op.row_id,
9768                            values,
9769                            &self.encryption,
9770                            fallback.as_ref(),
9771                        ) {
9772                            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Some(error),
9773                            _ => None,
9774                        }
9775                    })
9776                    .map(|error| (commit.clone(), error))
9777            })
9778            .collect::<Vec<_>>();
9779        if failures.is_empty() {
9780            return Ok(false);
9781        }
9782        for (commit, message) in failures {
9783            if self.retain_failed_commits {
9784                self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
9785            }
9786            let rejection = RejectionRecord {
9787                client_commit_id: commit.client_commit_id.clone(),
9788                op_index: 0,
9789                code: ENCRYPT_FAILED_CODE.to_owned(),
9790                message,
9791                retryable: false,
9792                details: None,
9793                operation: commit.ops.first().map(CommitOperation::from),
9794            };
9795            self.persist_commit_outcome(
9796                &commit.client_commit_id,
9797                CommitOutcomeStatus::Rejected,
9798                &[CommitOperationOutcome::Error {
9799                    rejection: rejection.clone(),
9800                }],
9801                Some(&commit.ops),
9802            )?;
9803            self.delete_outbox_persisted(&commit.client_commit_id)?;
9804            self.outbox
9805                .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
9806            self.rejections.push(rejection);
9807        }
9808        self.prune_commit_outcomes()?;
9809        self.rebuild_overlay_if_dirty();
9810        Ok(true)
9811    }
9812
9813    pub fn pending_commit_ids(&self) -> Vec<String> {
9814        self.outbox
9815            .iter()
9816            .map(|c| c.client_commit_id.clone())
9817            .collect()
9818    }
9819
9820    /// §6.1: the sparse push payload bytes of every pending upsert operation,
9821    /// FIFO — exactly what the next push round would put on the wire. Used by
9822    /// conformance B.21(h) to pin cross-core byte identity.
9823    pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
9824        let mut payloads = Vec::new();
9825        for commit in &self.outbox {
9826            for op in &commit.ops {
9827                let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
9828                    continue;
9829                };
9830                let Some(table) = self.schema.table(&op.table) else {
9831                    continue;
9832                };
9833                let fallback = self.stored_key_fallback(table, &op.row_id);
9834                if let Ok(payload) = encode_sparse_row_json(
9835                    table,
9836                    &op.row_id,
9837                    values,
9838                    &self.encryption,
9839                    fallback.as_ref(),
9840                ) {
9841                    payloads.push(payload);
9842                }
9843            }
9844        }
9845        payloads
9846    }
9847
9848    pub fn conflicts(&self) -> &[ConflictRecord] {
9849        &self.conflicts
9850    }
9851
9852    pub fn rejections(&self) -> &[RejectionRecord] {
9853        &self.rejections
9854    }
9855
9856    pub fn resolve_commit_outcome(
9857        &mut self,
9858        input: ResolveCommitOutcomeInput,
9859    ) -> Result<CommitOutcome, String> {
9860        let current = self
9861            .commit_outcome(&input.client_commit_id)?
9862            .ok_or_else(|| {
9863                format!(
9864                    "sync.outcome_not_found: no durable outcome exists for {:?}",
9865                    input.client_commit_id
9866                )
9867            })?;
9868        if current.resolution != CommitOutcomeResolution::Active {
9869            return Ok(current);
9870        }
9871        if input.resolution == CommitOutcomeResolution::Active {
9872            return Err("sync.invalid_request: resolution must leave active state".to_owned());
9873        }
9874        match input.resolution {
9875            CommitOutcomeResolution::Superseded => {
9876                let replacement = input
9877                    .replacement_client_commit_id
9878                    .as_deref()
9879                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
9880                    .ok_or_else(|| {
9881                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
9882                            .to_owned()
9883                    })?;
9884                let _ = replacement;
9885            }
9886            _ if input.replacement_client_commit_id.is_some() => {
9887                return Err(
9888                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
9889                        .to_owned(),
9890                );
9891            }
9892            _ => {}
9893        }
9894        let allowed = match current.status {
9895            CommitOutcomeStatus::Conflict => matches!(
9896                input.resolution,
9897                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
9898            ),
9899            CommitOutcomeStatus::Rejected => {
9900                matches!(
9901                    input.resolution,
9902                    CommitOutcomeResolution::Superseded
9903                        | CommitOutcomeResolution::ResolvedKeepServer
9904                )
9905            }
9906            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
9907                input.resolution == CommitOutcomeResolution::Dismissed
9908            }
9909        };
9910        if !allowed {
9911            return Err(format!(
9912                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
9913                input.resolution, current.status
9914            ));
9915        }
9916
9917        self.begin_observation("syncular_outcome_resolution")?;
9918        let previous_failed = self.failed_commits.clone();
9919        let previous_conflicts = self.conflicts.clone();
9920        let previous_rejections = self.rejections.clone();
9921        let result = (|| {
9922            self.conn
9923                .execute(
9924                    "UPDATE _syncular_commit_outcomes
9925                        SET resolution = ?1, resolved_at_ms = ?2,
9926                            replacement_client_commit_id = ?3
9927                      WHERE client_commit_id = ?4 AND resolution = 'active'",
9928                    rusqlite::params![
9929                        Self::outcome_resolution_name(input.resolution),
9930                        self.clock_now_ms(),
9931                        input.replacement_client_commit_id,
9932                        input.client_commit_id
9933                    ],
9934                )
9935                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9936            let resolved = self
9937                .commit_outcome(&current.client_commit_id)?
9938                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
9939            self.conn
9940                .execute(
9941                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
9942                    rusqlite::params![input.client_commit_id],
9943                )
9944                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9945            for (commit, _) in &self.failed_commits {
9946                if commit.client_commit_id == input.client_commit_id {
9947                    for operation in &commit.ops {
9948                        self.overlay_dirty.table(&operation.table);
9949                    }
9950                }
9951            }
9952            self.failed_commits
9953                .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
9954            self.prune_commit_outcomes()?;
9955            Ok(resolved)
9956        })();
9957        let resolved = match result {
9958            Ok(outcome) => outcome,
9959            Err(error) => {
9960                self.rollback_observation("syncular_outcome_resolution");
9961                self.failed_commits = previous_failed;
9962                self.conflicts = previous_conflicts;
9963                self.rejections = previous_rejections;
9964                return Err(error);
9965            }
9966        };
9967        self.conflicts
9968            .retain(|record| record.client_commit_id != current.client_commit_id);
9969        self.rejections
9970            .retain(|record| record.client_commit_id != current.client_commit_id);
9971        let mut batch = ChangeAccumulator {
9972            conflicts: current.status == CommitOutcomeStatus::Conflict,
9973            rejections: current.status == CommitOutcomeStatus::Rejected,
9974            outcomes: true,
9975            ..ChangeAccumulator::default()
9976        };
9977        if let Some(operations) = &current.operations {
9978            for operation in operations {
9979                batch.table(&operation.table);
9980            }
9981        }
9982        self.rebuild_overlay_if_dirty();
9983        if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
9984            self.rollback_observation("syncular_outcome_resolution");
9985            self.failed_commits = previous_failed;
9986            self.conflicts = previous_conflicts;
9987            self.rejections = previous_rejections;
9988            return Err(error);
9989        }
9990        Ok(resolved)
9991    }
9992
9993    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
9994        self.schema_floor.as_ref()
9995    }
9996
9997    /// §7.3.5: the client's opaque auth-lease state, if any.
9998    pub fn lease_state(&self) -> Option<&LeaseState> {
9999        self.lease_state.as_ref()
10000    }
10001
10002    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
10003    /// the two lease codes set it; other errors leave leaseState untouched.
10004    fn record_lease_error(&mut self, code: &str) {
10005        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
10006            return;
10007        }
10008        let mut next = self.lease_state.clone().unwrap_or_default();
10009        next.error_code = Some(code.to_owned());
10010        self.set_lease_state(Some(next));
10011    }
10012
10013    fn set_lease_state(&mut self, next: Option<LeaseState>) {
10014        if self.lease_state == next {
10015            return;
10016        }
10017        if self.begin_observation("syncular_lease").is_err() {
10018            return;
10019        }
10020        self.lease_state = next;
10021        if let Some(lease) = &self.lease_state {
10022            if let Ok(json) = serde_json::to_string(lease) {
10023                self.set_meta(LEASE_STATE_KEY, &json);
10024            }
10025        } else {
10026            self.delete_meta(LEASE_STATE_KEY);
10027        }
10028        let batch = ChangeAccumulator {
10029            status: true,
10030            ..ChangeAccumulator::default()
10031        };
10032        if self.finish_observation("syncular_lease", batch).is_err() {
10033            self.rollback_observation("syncular_lease");
10034        }
10035    }
10036
10037    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
10038        if self.schema_floor == next {
10039            return;
10040        }
10041        if self.begin_observation("syncular_schema_floor").is_err() {
10042            return;
10043        }
10044        self.schema_floor = next;
10045        self.stopped = self.schema_floor.is_some();
10046        if let Some(floor) = &self.schema_floor {
10047            if let Ok(json) = serde_json::to_string(floor) {
10048                self.set_meta(SCHEMA_FLOOR_KEY, &json);
10049            }
10050        } else {
10051            self.delete_meta(SCHEMA_FLOOR_KEY);
10052        }
10053        let batch = ChangeAccumulator {
10054            status: true,
10055            ..ChangeAccumulator::default()
10056        };
10057        if self
10058            .finish_observation("syncular_schema_floor", batch)
10059            .is_err()
10060        {
10061            self.rollback_observation("syncular_schema_floor");
10062        }
10063    }
10064
10065    fn set_upgrading(&mut self, value: bool) {
10066        if self.upgrading == value {
10067            return;
10068        }
10069        if self.begin_observation("syncular_upgrading").is_err() {
10070            return;
10071        }
10072        self.upgrading = value;
10073        let batch = ChangeAccumulator {
10074            status: true,
10075            ..ChangeAccumulator::default()
10076        };
10077        if self
10078            .finish_observation("syncular_upgrading", batch)
10079            .is_err()
10080        {
10081            self.rollback_observation("syncular_upgrading");
10082        }
10083    }
10084
10085    pub fn sync_needed(&self) -> bool {
10086        self.sync_needed
10087    }
10088
10089    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
10090        let sub = self.subs.iter().find(|s| s.id == id)?;
10091        Some(SubscriptionStateView {
10092            id: sub.id.clone(),
10093            table: sub.table.clone(),
10094            status: sub.state.name().to_owned(),
10095            cursor: sub.cursor,
10096            has_resume_token: sub.bootstrap_state.is_some(),
10097            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
10098            reason_code: sub.reason_code.clone(),
10099        })
10100    }
10101
10102    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
10103        let schema_table = self
10104            .schema
10105            .table(table)
10106            .ok_or_else(|| format!("unknown table {table:?}"))?;
10107        let sql = format!(
10108            "SELECT * FROM {} ORDER BY {} ASC",
10109            visible_table(table),
10110            quote_ident(&schema_table.primary_key)
10111        );
10112        let mut stmt = self
10113            .conn
10114            .prepare(&sql)
10115            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10116        let mut rows = stmt
10117            .query([])
10118            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10119        let mut out = Vec::new();
10120        while let Some(row) = rows
10121            .next()
10122            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
10123        {
10124            let mut values = Map::new();
10125            for (i, column) in schema_table.columns.iter().enumerate() {
10126                let value = row
10127                    .get_ref(i)
10128                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10129                values.insert(column.name.clone(), sql_ref_to_json(column, value));
10130            }
10131            let version: i64 = row
10132                .get(schema_table.columns.len())
10133                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10134            let row_id = match values.get(&schema_table.primary_key) {
10135                Some(Value::String(s)) => s.clone(),
10136                Some(Value::Number(n)) => n.to_string(),
10137                Some(Value::Bool(b)) => b.to_string(),
10138                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
10139            };
10140            out.push(RowState {
10141                row_id,
10142                version,
10143                values,
10144            });
10145        }
10146        Ok(out)
10147    }
10148
10149    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
10150    //
10151    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
10152    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
10153    // the op with `yrs`, re-encodes the whole doc state, and records a
10154    // baseVersion-less sparse upsert carrying only the primary key and the
10155    // crdt column (§5.10.3, §6.6). No local merge — merging is server-side;
10156    // the overlay re-materializes the edit immediately (optimistic apply,
10157    // §7.1) and the server-merged bytes arrive on the next pull, idempotently.
10158    // Byte-compatible with `@syncular/crdt-yjs`.
10159
10160    /// The current stored value of a `crdt` column for one row — the visible
10161    /// (optimistic) bytes, or `None` when the row is absent or the column is
10162    /// NULL (the empty document, §5.10.1). Errors if the column is not a
10163    /// `crdt` column (guards the app against a typo'd column name).
10164    #[cfg(feature = "crdt-yjs")]
10165    fn crdt_column_bytes(
10166        &self,
10167        table: &str,
10168        row_id: &str,
10169        column: &str,
10170    ) -> Result<Option<Vec<u8>>, String> {
10171        let schema_table = self
10172            .schema
10173            .table(table)
10174            .ok_or_else(|| format!("unknown table {table:?}"))?;
10175        let col = schema_table
10176            .columns
10177            .iter()
10178            .find(|c| c.name == column)
10179            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
10180        if col.ty != ColumnType::Crdt {
10181            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
10182        }
10183        let sql = format!(
10184            "SELECT {} FROM {} WHERE {}",
10185            quote_ident(column),
10186            visible_table(table),
10187            row_id_predicate(schema_table)
10188        );
10189        let bytes: Option<Vec<u8>> = self
10190            .conn
10191            .prepare_cached(&sql)
10192            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
10193            .query_row(rusqlite::params![row_id], |row| {
10194                row.get::<_, Option<Vec<u8>>>(0)
10195            })
10196            .map_err(|e| match e {
10197                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
10198                other => other.to_string(),
10199            })?;
10200        Ok(bytes)
10201    }
10202
10203    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
10204    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
10205    /// An absent row / NULL column is the empty document (empty string).
10206    #[cfg(feature = "crdt-yjs")]
10207    pub fn crdt_text(
10208        &self,
10209        table: &str,
10210        row_id: &str,
10211        column: &str,
10212        name: &str,
10213    ) -> Result<String, String> {
10214        let bytes = self
10215            .crdt_column_bytes(table, row_id, column)?
10216            .unwrap_or_default();
10217        crate::crdt::text(&bytes, name)
10218    }
10219
10220    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and
10221    /// record the resulting crdt-only sparse operation. Returns the enqueued
10222    /// `clientCommitId`.
10223    #[cfg(feature = "crdt-yjs")]
10224    pub fn crdt_insert_text(
10225        &mut self,
10226        table: &str,
10227        row_id: &str,
10228        column: &str,
10229        name: &str,
10230        index: u32,
10231        value: &str,
10232    ) -> Result<String, String> {
10233        let current = self
10234            .crdt_column_bytes(table, row_id, column)?
10235            .unwrap_or_default();
10236        let update = crate::crdt::insert_text(&current, name, index, value)?;
10237        self.crdt_push_update(table, row_id, column, &update)
10238    }
10239
10240    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and
10241    /// record the resulting crdt-only sparse operation. Returns the enqueued
10242    /// `clientCommitId`.
10243    #[cfg(feature = "crdt-yjs")]
10244    pub fn crdt_delete_text(
10245        &mut self,
10246        table: &str,
10247        row_id: &str,
10248        column: &str,
10249        name: &str,
10250        index: u32,
10251        len: u32,
10252    ) -> Result<String, String> {
10253        let current = self
10254            .crdt_column_bytes(table, row_id, column)?
10255            .unwrap_or_default();
10256        let update = crate::crdt::delete_text(&current, name, index, len)?;
10257        self.crdt_push_update(table, row_id, column, &update)
10258    }
10259
10260    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
10261    /// `crdt` column's current state and record the resulting crdt-only sparse
10262    /// operation. The app authored the update with its own `yrs` model.
10263    /// Returns the enqueued `clientCommitId`.
10264    #[cfg(feature = "crdt-yjs")]
10265    pub fn crdt_apply_update(
10266        &mut self,
10267        table: &str,
10268        row_id: &str,
10269        column: &str,
10270        update: &[u8],
10271    ) -> Result<String, String> {
10272        let current = self
10273            .crdt_column_bytes(table, row_id, column)?
10274            .unwrap_or_default();
10275        let next = crate::crdt::apply_update(&current, update)?;
10276        self.crdt_push_update(table, row_id, column, &next)
10277    }
10278
10279    /// Shared tail of the crdt edit methods: record a §6.1 sparse crdt-only
10280    /// upsert — the primary key plus the crdt column, no `baseVersion` — and
10281    /// enqueue it. The server merges the crdt column and leaves every other
10282    /// column untouched (§5.10.3), so a baseVersion-less crdt edit cannot
10283    /// clobber a concurrent edit to another column. A locally absent row
10284    /// records the same partial operation; the server answers per §6.2.
10285    #[cfg(feature = "crdt-yjs")]
10286    fn crdt_push_update(
10287        &mut self,
10288        table: &str,
10289        row_id: &str,
10290        column: &str,
10291        crdt_bytes: &[u8],
10292    ) -> Result<String, String> {
10293        let mut bytes_obj = Map::new();
10294        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
10295        let mut partial = Map::new();
10296        partial.insert(column.to_owned(), Value::Object(bytes_obj));
10297        self.patch(table, row_id, partial, None)
10298    }
10299
10300    /// Run an arbitrary read-only SQL query against the local database and
10301    /// return each row as a `column-name → JSON value` map. This is the seam
10302    /// the React `useSyncQuery` live-query API needs (it takes app-authored
10303    /// SQL over the visible tables/views, not a fixed table read like
10304    /// [`read_rows`]).
10305    ///
10306    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
10307    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
10308    /// same envelope the command surface uses everywhere else. Output BLOB
10309    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
10310    ///
10311    /// The result column typing is dynamic (SQLite's stored affinity), because
10312    /// arbitrary SQL can alias, join, and compute — there is no schema column
10313    /// to consult per output cell, unlike [`read_rows`].
10314    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
10315        query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
10316    }
10317
10318    /// Repository benchmark and conformance access, present only with `bench-internals`.
10319    #[cfg(feature = "bench-internals")]
10320    #[doc(hidden)]
10321    pub fn benchmark_connection(&mut self) -> &mut Connection {
10322        &mut self.conn
10323    }
10324
10325    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
10326    #[cfg(feature = "bench-internals")]
10327    #[doc(hidden)]
10328    pub fn benchmark_phases(
10329        &self,
10330        enabled: Option<bool>,
10331        reset: bool,
10332    ) -> Result<Option<Value>, String> {
10333        self.benchmark_phases.configure(enabled, reset)?;
10334        Ok(self.benchmark_phases.snapshot())
10335    }
10336
10337    /// Rows, coverage, and local revision from one SQLite read snapshot.
10338    /// §7.5 atomic snapshot read. An `owner` records a failure in
10339    /// `diagnostics_snapshot().query_failures` until its next successful read.
10340    pub fn query_snapshot(
10341        &mut self,
10342        sql: &str,
10343        params: &[QueryValue],
10344        coverage: &[WindowCoverage],
10345        owner: Option<QueryOwner<'_>>,
10346    ) -> Result<QuerySnapshot, String> {
10347        let result = snapshot_connection(&self.conn, sql, params, coverage);
10348        if let Some(owner) = owner {
10349            self.record_query_read(owner, result.as_ref().err());
10350        }
10351        result.map_err(|failure| failure.to_string())
10352    }
10353
10354    /// §7.6: record the outcome of one owned snapshot read, including reads a
10355    /// host ran on a separate connection. A success removes the owner's entry;
10356    /// an identical failure leaves it unchanged; a changed failure moves last.
10357    pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
10358        let position = self
10359            .query_failures
10360            .iter()
10361            .position(|entry| entry.id == owner.id);
10362        let Some(failure) = failure else {
10363            if let Some(index) = position {
10364                self.query_failures.remove(index);
10365            }
10366            return;
10367        };
10368        let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
10369            .into_iter()
10370            .map(str::to_owned)
10371            .collect();
10372        let code = failure.code.unwrap_or("client.query_failed").to_owned();
10373        if position.is_some_and(|index| {
10374            let entry = &self.query_failures[index];
10375            entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
10376        }) {
10377            return;
10378        }
10379        if let Some(index) = position {
10380            self.query_failures.remove(index);
10381        }
10382        self.query_failures.push(DiagnosticQueryFailure {
10383            id: owner.id.to_owned(),
10384            tables,
10385            code,
10386            sqlite_code: failure.sqlite_code,
10387            at_ms: self.clock_now_ms(),
10388        });
10389        if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
10390            self.query_failures.remove(0);
10391        }
10392    }
10393
10394    // -- request building ---------------------------------------------------------
10395
10396    fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
10397        #[cfg(feature = "bench-internals")]
10398        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
10399        let log_epoch = self.get_meta(LOG_EPOCH_KEY);
10400        let mut frames = vec![Frame::ReqHeader {
10401            client_id: self.client_id.clone(),
10402            schema_version: self.schema.version,
10403            log_epoch: log_epoch.clone(),
10404        }];
10405        let mut pushed_ids = Vec::new();
10406        let mut ops_in_request = 0usize;
10407        let mut deferred_commits = 0usize;
10408        #[cfg(feature = "bench-internals")]
10409        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
10410        for (index, commit) in self
10411            .outbox
10412            .iter()
10413            .take(if log_epoch.is_some() {
10414                self.outbox.len()
10415            } else {
10416                0
10417            })
10418            .enumerate()
10419        {
10420            // §6.1 splitBatch: stop at the operation cap — commits apply in
10421            // order, so everything from the first non-fitting commit on is
10422            // deferred to the next round. A single over-cap commit still goes
10423            // alone (commits are atomic and cannot be split).
10424            if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
10425                deferred_commits = self.outbox.len() - index;
10426                break;
10427            }
10428            ops_in_request += commit.ops.len();
10429            let operations = commit
10430                .ops
10431                .iter()
10432                .map(|op| {
10433                    let payload = op.values.as_ref().and_then(|values| {
10434                        let table = self.schema.table(&op.table)?;
10435                        // §0: outbox entries encode at send time with the
10436                        // current codec (validated at mutate()). §6.1: the
10437                        // values map's keys are the sparse presence set.
10438                        // §5.11: encrypted columns are encrypted here, with
10439                        // an absent key-id selector read from the stored row.
10440                        // `sync_inner` runs `drop_unencodable_outbox` first,
10441                        // so an encrypt failure cannot reach this encode; the
10442                        // author seam validated every other failure.
10443                        let fallback = self.stored_key_fallback(table, &op.row_id);
10444                        encode_sparse_row_json(
10445                            table,
10446                            &op.row_id,
10447                            values,
10448                            &self.encryption,
10449                            fallback.as_ref(),
10450                        )
10451                        .ok()
10452                    });
10453                    ssp2::model::Operation {
10454                        table: op.table.clone(),
10455                        row_id: op.row_id.clone(),
10456                        op: if op.upsert { Op::Upsert } else { Op::Delete },
10457                        base_version: op.base_version,
10458                        payload,
10459                    }
10460                })
10461                .collect();
10462            frames.push(Frame::PushCommit {
10463                client_commit_id: commit.client_commit_id.clone(),
10464                operations,
10465            });
10466            pushed_ids.push(commit.client_commit_id.clone());
10467        }
10468        #[cfg(feature = "bench-internals")]
10469        drop(outbox_phase);
10470        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
10471        // bare URL — capability negotiation, decided per transport.
10472        let accept = self.limits.accept.unwrap_or(if url_capable {
10473            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
10474        } else {
10475            DEFAULT_ACCEPT
10476        });
10477        frames.push(Frame::PullHeader {
10478            limit_commits: self.limits.limit_commits.unwrap_or(0),
10479            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
10480            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
10481            accept,
10482        });
10483        let mut fresh = Vec::new();
10484        for sub in &self.subs {
10485            if sub.state != SubState::Active {
10486                continue;
10487            }
10488            let mut scopes = sub.requested.clone();
10489            sort_scope_map(&mut scopes);
10490            frames.push(Frame::Subscription {
10491                id: sub.id.clone(),
10492                table: sub.table.clone(),
10493                scopes,
10494                params: sub.params.clone().map(RawJson),
10495                cursor: sub.cursor,
10496                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
10497            });
10498            fresh.push((
10499                sub.id.clone(),
10500                sub.cursor < 0 && sub.bootstrap_state.is_none(),
10501            ));
10502        }
10503        let message = Message {
10504            wire_version: WIRE_VERSION,
10505            msg_kind: MsgKind::Request,
10506            frames,
10507        };
10508        (
10509            message,
10510            RequestMeta {
10511                pushed_ids,
10512                fresh,
10513                accept,
10514                deferred_commits,
10515            },
10516        )
10517    }
10518
10519    // -- sync -------------------------------------------------------------------
10520
10521    /// Cloneable observation handle; no client lock is needed to read it.
10522    pub fn progress(&self) -> ProgressObserver {
10523        self.progress.clone()
10524    }
10525
10526    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
10527        let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
10528            Ok(prepared) => prepared,
10529            Err(outcome) => return *outcome,
10530        };
10531        let mut next = prepared;
10532        loop {
10533            match self.apply_sync_round(next.exchange(transport)) {
10534                crate::AppliedSyncRound::Continue(prepared) => next = prepared,
10535                crate::AppliedSyncRound::Complete {
10536                    outcome, controls, ..
10537                } => {
10538                    if self.transport_enabled {
10539                        for text in controls {
10540                            let _ = transport.realtime_send(&text);
10541                        }
10542                    } else {
10543                        self.disconnect_realtime(transport);
10544                    }
10545                    return outcome;
10546                }
10547            }
10548        }
10549    }
10550
10551    fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
10552        if let SyncOutcome::Failed {
10553            error_code,
10554            message,
10555            details,
10556        } = &mut outcome
10557        {
10558            if let Some(failure) = self.storage_failure.borrow().as_ref() {
10559                let code = failure
10560                    .code
10561                    .expect("retained storage failures are classified");
10562                *error_code = code.to_owned();
10563                *message = match code {
10564                    "client.storage_full" => "local SQLite storage is full",
10565                    "client.storage_io" => "local SQLite storage I/O failed",
10566                    "client.storage_corrupt" => "local SQLite storage is corrupt",
10567                    _ => unreachable!("unrecognized classified storage failure"),
10568                }
10569                .to_owned();
10570                *details = failure.details();
10571            }
10572        }
10573        let retry_delay_ms = self.round_retry_delay_ms;
10574        self.progress.update(|p| match &outcome {
10575            SyncOutcome::Ok(report) if report.failed.is_empty() => {
10576                p.state = ProgressState::Complete
10577            }
10578            SyncOutcome::Ok(_) => {
10579                p.state = ProgressState::Failed;
10580                p.error_code = Some("sync.scope_revoked".into());
10581            }
10582            SyncOutcome::Failed { error_code, .. } => {
10583                p.state = ProgressState::Failed;
10584                p.error_code = Some(Self::diagnostic_code(error_code));
10585                p.retry_delay_ms = retry_delay_ms;
10586            }
10587            SyncOutcome::RealtimeUnavailable { .. } => {
10588                p.state = ProgressState::Failed;
10589                p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
10590                p.retry_delay_ms = retry_delay_ms;
10591            }
10592        });
10593        let completed_at_ms = self.clock_now_ms();
10594        self.last_round = Some(match &outcome {
10595            SyncOutcome::Ok(report) => DiagnosticLastRound {
10596                status: "succeeded".to_owned(),
10597                started_at_ms,
10598                completed_at_ms,
10599                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
10600                counters: Some(DiagnosticRoundCounters {
10601                    pushed: report.pushed,
10602                    applied: report.applied.len(),
10603                    rejected: report.rejected.len(),
10604                    retryable: report.retryable.len(),
10605                    conflicts: report.conflicts,
10606                    commits_applied: report.commits_applied,
10607                    segment_rows_applied: report.segment_rows_applied,
10608                    bootstrapping: report.bootstrapping.len(),
10609                    resets: report.resets.len(),
10610                    revoked: report.revoked.len(),
10611                    failed: report.failed.len(),
10612                    deferred_commits: report.deferred_commits,
10613                }),
10614                error_code: None,
10615            },
10616            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
10617                status: "failed".to_owned(),
10618                started_at_ms,
10619                completed_at_ms,
10620                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
10621                counters: None,
10622                error_code: Some(Self::diagnostic_code(error_code)),
10623            },
10624            SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
10625                status: "failed".to_owned(),
10626                started_at_ms,
10627                completed_at_ms,
10628                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
10629                counters: None,
10630                error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
10631            },
10632        });
10633        outcome
10634    }
10635
10636    /// Capture one request on the client owner. Mutations made after this
10637    /// call remain in the outbox for a later round; no network I/O occurs here.
10638    pub fn prepare_sync_round(
10639        &mut self,
10640        url_capable: bool,
10641    ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
10642        if !self.transport_enabled {
10643            return Err(Box::new(SyncOutcome::Failed {
10644                error_code: "sync.offline".into(),
10645                message: "transport gate is closed".into(),
10646                details: None,
10647            }));
10648        }
10649        if self.active_round.is_some() {
10650            return Err(Box::new(SyncOutcome::Failed {
10651                error_code: "client.round_in_progress".into(),
10652                message: "a sync round is already in flight".into(),
10653                details: None,
10654            }));
10655        }
10656        self.storage_failure.borrow_mut().take();
10657        self.progress.start();
10658        self.round_retry_delay_ms = None;
10659        let started_at_ms = self.clock_now_ms();
10660        let prepared = (|| {
10661            if self.stopped {
10662                return Err(Box::new(SyncOutcome::Ok(SyncReport {
10663                    schema_floor: self.schema_floor.clone(),
10664                    ..SyncReport::default()
10665                })));
10666            }
10667            if self.security_preflight {
10668                return Err(Box::new(SyncOutcome::Failed {
10669                    error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
10670                    message: "security preflight must complete before syncing".into(),
10671                    details: None,
10672                }));
10673            }
10674            self.set_sync_needed(false, false);
10675            let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
10676                self.pending_blob_uploads().map_err(|e| {
10677                    Box::new(SyncOutcome::Failed {
10678                        error_code: e.code,
10679                        message: e.message,
10680                        details: e.details,
10681                    })
10682                })?
10683            } else {
10684                Vec::new()
10685            };
10686            self.drain_pending_evictions()
10687                .and_then(|_| self.drop_unencodable_outbox())
10688                .map_err(|message| {
10689                    Box::new(SyncOutcome::Failed {
10690                        error_code: "storage.failed".into(),
10691                        message,
10692                        details: None,
10693                    })
10694                })?;
10695            if self.realtime_state != RealtimeState::Connected
10696                && self.realtime_policy == RealtimePolicy::Required
10697            {
10698                let retry_delay_ms = self.schedule_background_retry();
10699                self.realtime_retry_delay_ms = Some(retry_delay_ms);
10700                return Err(Box::new(SyncOutcome::RealtimeUnavailable {
10701                    state: self.realtime_state,
10702                    reason_code: self.realtime_reason_code.clone(),
10703                    retry_delay_ms,
10704                }));
10705            }
10706            let (message, meta) = self.build_request(url_capable);
10707            let id = uuid::Uuid::new_v4();
10708            self.active_round = Some(id);
10709            Ok(crate::PreparedSyncRound {
10710                id,
10711                started_at_ms,
10712                message,
10713                meta,
10714                uploads: uploads.into(),
10715                realtime: self.realtime_state == RealtimeState::Connected,
10716                scopes: self
10717                    .subs
10718                    .iter()
10719                    .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
10720                    .collect(),
10721                #[cfg(feature = "bench-internals")]
10722                benchmark_phases: self.benchmark_phases.clone(),
10723                progress: self.progress.clone(),
10724                fixed_now: self.now_ms,
10725            })
10726        })();
10727        prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
10728    }
10729
10730    /// Discard the reply of an old authorization/subscription context. Its
10731    /// submitted commits stay durable and can be retried with the same ids.
10732    pub fn cancel_sync_round(&mut self) {
10733        self.active_round = None;
10734    }
10735
10736    /// Apply captured network results on the owner, preserving all normal
10737    /// version, schema, content-address and cursor checks. No network I/O.
10738    pub fn apply_sync_round(
10739        &mut self,
10740        completed: crate::CompletedSyncRound,
10741    ) -> crate::AppliedSyncRound {
10742        let prepared = completed.prepared;
10743        if self.active_round != Some(prepared.id) || self.security_preflight {
10744            return crate::AppliedSyncRound::Complete {
10745                outcome: SyncOutcome::Failed {
10746                    error_code: "client.round_cancelled".into(),
10747                    message: "sync round belongs to a released client context".into(),
10748                    details: None,
10749                },
10750                controls: Vec::new(),
10751                more: false,
10752                bootstrap_advanced: false,
10753            };
10754        }
10755        self.storage_failure.borrow_mut().take();
10756        let (response, mut downloads, transport_failed) = match completed.exchange {
10757            crate::round::ExchangeResult::Upload { id, result } => {
10758                let result = result.and_then(|()| {
10759                    self.conn
10760                        .execute(
10761                            "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
10762                            rusqlite::params![id],
10763                        )
10764                        .map(|_| ())
10765                        .map_err(|error| {
10766                            TransportError::new(
10767                                "client.failed",
10768                                Self::sqlite_failure(&self.storage_failure, error),
10769                            )
10770                        })
10771                });
10772                match result {
10773                    Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
10774                    Err(TransportError {
10775                        code,
10776                        message,
10777                        details,
10778                    }) => {
10779                        self.active_round = None;
10780                        if Self::retryable_failure_code(&code) {
10781                            self.schedule_background_retry();
10782                        }
10783                        let outcome = self.finish_sync_round(
10784                            prepared.started_at_ms,
10785                            SyncOutcome::Failed {
10786                                error_code: code,
10787                                message,
10788                                details,
10789                            },
10790                        );
10791                        return crate::AppliedSyncRound::Complete {
10792                            outcome,
10793                            controls: Vec::new(),
10794                            more: false,
10795                            bootstrap_advanced: false,
10796                        };
10797                    }
10798                }
10799            }
10800            crate::round::ExchangeResult::Reply {
10801                response,
10802                downloads,
10803                transport_failed,
10804            } => (response, downloads, transport_failed),
10805        };
10806        self.active_round = None;
10807        let before = self
10808            .subs
10809            .iter()
10810            .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
10811            .collect::<Vec<_>>();
10812        let outcome = (|| {
10813            let response = match response {
10814                Ok(response) => response,
10815                Err(TransportError {
10816                    code,
10817                    message,
10818                    details,
10819                }) => {
10820                    if transport_failed {
10821                        if prepared.realtime {
10822                            self.set_realtime_state(RealtimeState::Lost, Some(&code));
10823                        }
10824                        self.record_lease_error(&code);
10825                    }
10826                    if Self::retryable_failure_code(&code) {
10827                        let delay = self.schedule_background_retry();
10828                        if prepared.realtime && transport_failed {
10829                            self.realtime_retry_delay_ms = Some(delay);
10830                        }
10831                    }
10832                    return SyncOutcome::Failed {
10833                        error_code: code,
10834                        message,
10835                        details,
10836                    };
10837                }
10838            };
10839            let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
10840            if let SyncOutcome::Ok(report) = &mut outcome {
10841                report.deferred_commits = prepared.meta.deferred_commits;
10842            }
10843            match &outcome {
10844                SyncOutcome::Ok(_) => self.reset_background_retry(),
10845                SyncOutcome::Failed { error_code, .. }
10846                    if Self::retryable_failure_code(error_code) =>
10847                {
10848                    self.schedule_background_retry();
10849                }
10850                _ => {}
10851            }
10852            if prepared.meta.deferred_commits > 0 {
10853                self.set_sync_needed(true, true);
10854            }
10855            self.previous_version_lifetime_check();
10856            outcome
10857        })();
10858        let more = self.transport_enabled
10859            && matches!(&outcome, SyncOutcome::Ok(report) if !report.bootstrapping.is_empty() || report.commits_applied > 0 || report.segment_rows_applied > 0 || !report.resets.is_empty() || self.sync_needed);
10860        let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
10861            && before
10862                != self
10863                    .subs
10864                    .iter()
10865                    .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
10866                    .collect::<Vec<_>>();
10867        crate::AppliedSyncRound::Complete {
10868            outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
10869            controls: if self.transport_enabled {
10870                downloads.controls
10871            } else {
10872                Vec::new()
10873            },
10874            more,
10875            bootstrap_advanced,
10876        }
10877    }
10878
10879    pub fn sync_until_idle(
10880        &mut self,
10881        transport: &mut dyn Transport,
10882        max_rounds: Option<u32>,
10883    ) -> SyncOutcome {
10884        let rounds = max_rounds.unwrap_or(20).max(1);
10885        let mut aggregate = SyncReport::default();
10886        let mut spent = 0;
10887        while spent < rounds {
10888            spent += 1;
10889            let before = self
10890                .subs
10891                .iter()
10892                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
10893                .collect::<Vec<_>>();
10894            let outcome = self.sync(transport);
10895            match outcome {
10896                SyncOutcome::Failed {
10897                    error_code,
10898                    message,
10899                    details,
10900                } => {
10901                    return SyncOutcome::Failed {
10902                        details,
10903                        error_code,
10904                        message,
10905                    };
10906                }
10907                SyncOutcome::RealtimeUnavailable { .. } => return outcome,
10908                SyncOutcome::Ok(report) => {
10909                    if max_rounds.is_none()
10910                        && report.segment_rows_applied > 0
10911                        && !report.bootstrapping.is_empty()
10912                        && before
10913                            != self
10914                                .subs
10915                                .iter()
10916                                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
10917                                .collect::<Vec<_>>()
10918                    {
10919                        spent = 0;
10920                    }
10921                    aggregate.merge(&report);
10922                    // §4.5: pull again whenever the response contained
10923                    // commits or segments; resets re-bootstrap; a pending
10924                    // resume token continues paging (§4.7); a raised
10925                    // sync-needed signal covers §6.1 splitBatch remainders
10926                    // (deferred outbox commits push on the next round).
10927                    let more = !report.bootstrapping.is_empty()
10928                        || report.commits_applied > 0
10929                        || report.segment_rows_applied > 0
10930                        || !report.resets.is_empty()
10931                        || self.sync_needed;
10932                    if !more {
10933                        return SyncOutcome::Ok(aggregate);
10934                    }
10935                }
10936            }
10937        }
10938        SyncOutcome::Failed {
10939            details: None,
10940            error_code: "sync.invalid_request".into(),
10941            message: "sync did not reach idle within the round budget".into(),
10942        }
10943    }
10944
10945    fn process_response(
10946        &mut self,
10947        transport: &mut dyn Transport,
10948        response: Message,
10949        meta: &RequestMeta,
10950    ) -> SyncOutcome {
10951        #[cfg(feature = "bench-internals")]
10952        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
10953        let mut report = SyncReport {
10954            pushed: meta.pushed_ids.len() as u32,
10955            ..SyncReport::default()
10956        };
10957        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
10958            HashMap::new();
10959        let pushed_ids = meta
10960            .pushed_ids
10961            .iter()
10962            .map(String::as_str)
10963            .collect::<HashSet<_>>();
10964        let mut last_final_push_result_id: Option<String> = None;
10965        for frame in &response.frames {
10966            match frame {
10967                Frame::PushResultDetails {
10968                    client_commit_id,
10969                    entries,
10970                } => {
10971                    let details = rejection_details_by_commit
10972                        .entry(client_commit_id.clone())
10973                        .or_default();
10974                    for entry in entries {
10975                        let parsed = match RejectionDetails::parse(&entry.details.0) {
10976                            Ok(value) => value,
10977                            Err(message) => {
10978                                return SyncOutcome::Failed {
10979                                    details: None,
10980                                    error_code: "sync.invalid_request".to_owned(),
10981                                    message,
10982                                };
10983                            }
10984                        };
10985                        details.insert(entry.op_index, parsed);
10986                    }
10987                }
10988                Frame::PushResult {
10989                    client_commit_id,
10990                    status,
10991                    results,
10992                    ..
10993                } if pushed_ids.contains(client_commit_id.as_str())
10994                    && Self::push_result_is_final(*status, results) =>
10995                {
10996                    last_final_push_result_id = Some(client_commit_id.clone());
10997                }
10998                _ => {}
10999            }
11000        }
11001        let mut frames = response.frames.into_iter().peekable();
11002        if response.wire_version < 2 {
11003            return SyncOutcome::Failed {
11004                details: None,
11005                error_code: "client.invalid_host_response".to_owned(),
11006                message: "server response does not carry wire version 2 log-epoch state".to_owned(),
11007            };
11008        }
11009        match frames.next() {
11010            Some(Frame::RespHeader {
11011                required_schema_version,
11012                latest_schema_version,
11013                log_epoch,
11014                reset_required,
11015            }) => {
11016                let Some(log_epoch) = log_epoch else {
11017                    return SyncOutcome::Failed {
11018                        details: None,
11019                        error_code: "client.invalid_host_response".to_owned(),
11020                        message: "response header omits logEpoch".to_owned(),
11021                    };
11022                };
11023                let Some(reset_required) = reset_required else {
11024                    return SyncOutcome::Failed {
11025                        details: None,
11026                        error_code: "client.invalid_host_response".to_owned(),
11027                        message: "response header omits resetRequired".to_owned(),
11028                    };
11029                };
11030                if let Some(required) = required_schema_version {
11031                    // §1.6 schema-floor response: nothing else is processed;
11032                    // stop syncing and surface the upgrade requirement.
11033                    let floor = SchemaFloor {
11034                        required_schema_version: Some(required),
11035                        latest_schema_version,
11036                    };
11037                    self.set_schema_floor(Some(floor.clone()));
11038                    report.schema_floor = Some(floor);
11039                    return SyncOutcome::Ok(report);
11040                }
11041                if reset_required {
11042                    if frames.next().is_some() {
11043                        return SyncOutcome::Failed {
11044                            details: None,
11045                            error_code: "client.invalid_host_response".to_owned(),
11046                            message: "log-epoch reset response contains body frames".to_owned(),
11047                        };
11048                    }
11049                    match self.run_log_epoch_reset(&log_epoch) {
11050                        Ok(resets) => {
11051                            report.resets = resets;
11052                            return SyncOutcome::Ok(report);
11053                        }
11054                        Err(message) => {
11055                            return SyncOutcome::Failed {
11056                                details: None,
11057                                error_code: "sync.local_corrupt".to_owned(),
11058                                message,
11059                            };
11060                        }
11061                    }
11062                }
11063                if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
11064                    return SyncOutcome::Failed {
11065                        details: None,
11066                        error_code: "client.invalid_host_response".to_owned(),
11067                        message: "server changed logEpoch without requiring a reset".to_owned(),
11068                    };
11069                }
11070            }
11071            _ => {
11072                return SyncOutcome::Failed {
11073                    details: None,
11074                    error_code: "sync.invalid_request".to_owned(),
11075                    message: "response does not start with RESP_HEADER".to_owned(),
11076                };
11077            }
11078        }
11079
11080        let mut failure: Option<(String, String)> = None;
11081        while let Some(frame) = frames.next() {
11082            match frame {
11083                frame @ Frame::PushResult { .. } => {
11084                    let successful = matches!(
11085                        &frame,
11086                        Frame::PushResult {
11087                            status: PushStatus::Applied | PushStatus::Cached,
11088                            ..
11089                        }
11090                    );
11091                    let mut results = vec![frame];
11092                    if successful {
11093                        while matches!(
11094                            frames.peek(),
11095                            Some(Frame::PushResult {
11096                                status: PushStatus::Applied | PushStatus::Cached,
11097                                ..
11098                            })
11099                        ) {
11100                            if let Some(next) = frames.next() {
11101                                results.push(next);
11102                            }
11103                        }
11104                    }
11105                    if self
11106                        .handle_push_results(
11107                            &results,
11108                            &pushed_ids,
11109                            &rejection_details_by_commit,
11110                            &mut report,
11111                            last_final_push_result_id.as_deref(),
11112                        )
11113                        .is_err()
11114                    {
11115                        failure = Some((
11116                            "client.outcome_persistence_failed".to_owned(),
11117                            "local commit outcome could not be persisted".to_owned(),
11118                        ));
11119                        break;
11120                    }
11121                }
11122                Frame::PushResultDetails { .. } => {}
11123                Frame::SubStart {
11124                    id,
11125                    status,
11126                    reason_code,
11127                    effective_scopes,
11128                    bootstrap: _,
11129                } => {
11130                    let mut body = Vec::new();
11131                    let mut sub_end: Option<(i64, Option<String>)> = None;
11132                    for inner in frames.by_ref() {
11133                        match inner {
11134                            Frame::SubEnd {
11135                                next_cursor,
11136                                bootstrap_state,
11137                            } => {
11138                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
11139                                break;
11140                            }
11141                            Frame::Unknown { .. } => {}
11142                            other => body.push(other),
11143                        }
11144                    }
11145                    if let Err(SectionError::Abort(code, message)) = self.process_section(
11146                        transport,
11147                        &id,
11148                        status,
11149                        &reason_code,
11150                        effective_scopes,
11151                        body,
11152                        sub_end,
11153                        meta,
11154                        &mut report,
11155                    ) {
11156                        failure = Some((code, message));
11157                        break;
11158                    }
11159                }
11160                Frame::Lease {
11161                    lease_id,
11162                    expires_at_ms,
11163                } => {
11164                    // §7.3.5: persist the opaque lease; a fresh lease clears
11165                    // any prior lease error (the outage/revocation is over).
11166                    self.set_lease_state(Some(LeaseState {
11167                        lease_id: Some(lease_id),
11168                        expires_at_ms: Some(expires_at_ms),
11169                        error_code: None,
11170                    }));
11171                }
11172                Frame::Error { code, message, .. } => {
11173                    // §1.6: the whole request failed; already-completed
11174                    // subscriptions keep their applied data and cursors.
11175                    failure = Some((code, message));
11176                    break;
11177                }
11178                Frame::Unknown { .. } => {}
11179                _ => {
11180                    failure = Some((
11181                        "sync.invalid_request".to_owned(),
11182                        "unexpected frame in response".to_owned(),
11183                    ));
11184                    break;
11185                }
11186            }
11187        }
11188
11189        // §7.1: reconcile the visible overlay once at the response boundary
11190        // after all outbox acknowledgements and server data applied so a
11191        // batch of PUSH_RESULT frames cannot trigger repeated full-table
11192        // rebuilds. This also covers a round that aborted mid-way.
11193        if self.overlay_dirty.get() {
11194            self.rebuild_overlay();
11195        }
11196        if let Some((error_code, message)) = failure {
11197            return SyncOutcome::Failed {
11198                details: None,
11199                error_code,
11200                message,
11201            };
11202        }
11203        // §4.8 E1: the push half may have drained commits that pinned rows of
11204        // a shrunk window unit — retry any deferred evictions now.
11205        if let Err(message) = self.drain_pending_evictions() {
11206            return SyncOutcome::Failed {
11207                details: None,
11208                error_code: "storage.failed".into(),
11209                message,
11210            };
11211        }
11212        self.ack_after_pull(transport);
11213        // §7.4.5: the reset is over once the first post-reset pull round
11214        // leaves no subscription mid-bootstrap — the tables are rebuilt.
11215        if self.upgrading && report.bootstrapping.is_empty() {
11216            self.set_upgrading(false);
11217        }
11218        SyncOutcome::Ok(report)
11219    }
11220
11221    // -- push results (§6.3, §7.2) ------------------------------------------------
11222
11223    fn handle_push_results(
11224        &mut self,
11225        frames: &[Frame],
11226        pushed_ids: &HashSet<&str>,
11227        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
11228        report: &mut SyncReport,
11229        last_final_push_result_id: Option<&str>,
11230    ) -> Result<(), String> {
11231        self.begin_observation("syncular_push_results")?;
11232        let previous_failed = self.failed_commits.clone();
11233        let previous_sync_needed = self.sync_needed;
11234        let conflict_count = self.conflicts.len();
11235        let rejection_count = self.rejections.len();
11236        let applied_count = report.applied.len();
11237        let rejected_count = report.rejected.len();
11238        let retryable_count = report.retryable.len();
11239        let was_dirty = self.overlay_dirty.snapshot();
11240        let mut removed = Vec::new();
11241        let mut batch = ChangeAccumulator::default();
11242        let persisted = (|| {
11243            for frame in frames {
11244                let Frame::PushResult {
11245                    client_commit_id,
11246                    status,
11247                    results,
11248                    commit_seq,
11249                } = frame
11250                else {
11251                    return Err("unexpected acknowledgement frame".to_owned());
11252                };
11253                if !pushed_ids.contains(client_commit_id.as_str()) {
11254                    continue;
11255                }
11256                let Some(index) = self
11257                    .outbox
11258                    .iter()
11259                    .position(|commit| commit.client_commit_id == *client_commit_id)
11260                else {
11261                    continue;
11262                };
11263                let status = *status;
11264                let rejection_details = rejection_details_by_commit.get(client_commit_id);
11265                let operations = self.outbox[index].ops.clone();
11266                match status {
11267                    PushStatus::Applied | PushStatus::Cached => {
11268                        // §7.2: a lost ack replays as `cached` — proceed as if the
11269                        // ack had arrived.
11270                        let sequence =
11271                            commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
11272                        let affected: HashSet<_> = operations
11273                            .iter()
11274                            .map(|op| (op.table.as_str(), op.row_id.as_str()))
11275                            .collect();
11276                        let prior = self.acknowledged_operations(Some(&affected), None)?;
11277                        for (op_index, operation) in operations.iter().enumerate() {
11278                            let delivered: bool = self.conn.query_row("SELECT EXISTS(SELECT 1 FROM _syncular_row_deliveries WHERE tbl=?1 AND id=?2 AND commit_seq>=?3)", rusqlite::params![operation.table, operation.row_id, sequence], |row| row.get(0))
11279                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11280                            if delivered {
11281                                continue;
11282                            }
11283                            let table = self
11284                                .schema
11285                                .table(&operation.table)
11286                                .ok_or_else(|| "sync.unknown_table".to_owned())?;
11287                            let mut intended: Map<String, Value> = self
11288                                .stored_row(table, &operation.row_id, true)
11289                                .map(|(row, _)| {
11290                                    table
11291                                        .columns
11292                                        .iter()
11293                                        .zip(&row)
11294                                        .map(|(column, value)| {
11295                                            (column.name.clone(), column_value_to_json(value))
11296                                        })
11297                                        .collect()
11298                                })
11299                                .unwrap_or_default();
11300                            let mut scope_image = intended.clone();
11301                            for op in prior
11302                                .iter()
11303                                .chain(
11304                                    self.failed_commits
11305                                        .iter()
11306                                        .flat_map(|(commit, _)| &commit.ops),
11307                                )
11308                                .chain(
11309                                    self.outbox
11310                                        .iter()
11311                                        .take(index)
11312                                        .flat_map(|commit| &commit.ops),
11313                                )
11314                                .chain(operations.iter().take(op_index + 1))
11315                            {
11316                                if op.table != operation.table || op.row_id != operation.row_id {
11317                                    continue;
11318                                }
11319                                if let Some(values) = &op.values {
11320                                    intended.extend(values.clone());
11321                                } else {
11322                                    scope_image = intended.clone();
11323                                    intended.clear();
11324                                }
11325                            }
11326                            self.conn.execute("INSERT INTO _syncular_acked_rows(commit_id,idx,tbl,id,commit_seq,op_json,intent_json) VALUES (?1,?2,?3,?4,?5,?6,?7)",
11327                                rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
11328                                    serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
11329                                    serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
11330                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11331                        }
11332                        let journal_results = results
11333                            .iter()
11334                            .map(|result| {
11335                                let op_index = match result {
11336                                    OpResult::Applied { op_index }
11337                                    | OpResult::Conflict { op_index, .. }
11338                                    | OpResult::Error { op_index, .. } => *op_index,
11339                                };
11340                                CommitOperationOutcome::Applied { op_index }
11341                            })
11342                            .collect::<Vec<_>>();
11343                        let outcome_status = if status == PushStatus::Applied {
11344                            CommitOutcomeStatus::Applied
11345                        } else {
11346                            CommitOutcomeStatus::Cached
11347                        };
11348                        let persisted = self
11349                            .persist_commit_outcome(
11350                                client_commit_id,
11351                                outcome_status,
11352                                &journal_results,
11353                                None,
11354                            )
11355                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
11356                        persisted?;
11357                        for operation in &operations {
11358                            self.overlay_dirty.table(&operation.table);
11359                        }
11360                        self.sync_needed = true;
11361                        batch.status = true;
11362                        batch.outcomes = true;
11363                    }
11364                    PushStatus::Rejected => {
11365                        if results.iter().any(|result| {
11366                            matches!(
11367                                result,
11368                                OpResult::Error {
11369                                    code,
11370                                    retryable: true,
11371                                    ..
11372                                } if code == "sync.idempotency_cache_miss"
11373                            )
11374                        }) {
11375                            // §6.3/§7.2: a serving failure, not an outcome — keep the
11376                            // exact commit queued for an identical retry.
11377                            report.retryable.push(client_commit_id.to_owned());
11378                            continue;
11379                        }
11380
11381                        let mut journal_results = Vec::with_capacity(results.len());
11382                        let mut conflicts = Vec::new();
11383                        let mut rejections = Vec::new();
11384                        for result in results {
11385                            match result {
11386                                OpResult::Applied { op_index } => {
11387                                    journal_results.push(CommitOperationOutcome::Applied {
11388                                        op_index: *op_index,
11389                                    });
11390                                }
11391                                OpResult::Conflict {
11392                                    op_index,
11393                                    code,
11394                                    message,
11395                                    server_version,
11396                                    server_row,
11397                                    conflict_columns: conflict_columns_bitmap,
11398                                } => {
11399                                    let operation = operations
11400                                        .get(*op_index as usize)
11401                                        .map(CommitOperation::from);
11402                                    let (table, row_id) = operation
11403                                        .as_ref()
11404                                        .map(|op| (op.table.clone(), op.row_id.clone()))
11405                                        .unwrap_or_default();
11406                                    let server_row_json = self
11407                                        .schema
11408                                        .table(&table)
11409                                        .and_then(|t| {
11410                                            decode_row_bytes(t, server_row, &self.encryption)
11411                                                .ok()
11412                                                .map(|row| (t, row))
11413                                        })
11414                                        .map(|(t, row)| {
11415                                            let mut map = Map::new();
11416                                            for (i, column) in t.columns.iter().enumerate() {
11417                                                map.insert(
11418                                                    column.name.clone(),
11419                                                    column_value_to_json(
11420                                                        row.get(i).unwrap_or(&None),
11421                                                    ),
11422                                                );
11423                                            }
11424                                            map
11425                                        })
11426                                        .unwrap_or_default();
11427                                    // §6.3: decode the presence-layout bitmap
11428                                    // into the marked column names.
11429                                    let conflict_columns = self
11430                                        .schema
11431                                        .table(&table)
11432                                        .map(|t| {
11433                                            t.columns
11434                                                .iter()
11435                                                .enumerate()
11436                                                .filter(|(index, _)| {
11437                                                    conflict_columns_bitmap
11438                                                        .get(index / 8)
11439                                                        .is_some_and(|byte| {
11440                                                            byte & (1 << (index % 8)) != 0
11441                                                        })
11442                                                })
11443                                                .map(|(_, column)| column.name.clone())
11444                                                .collect::<Vec<_>>()
11445                                        })
11446                                        .unwrap_or_default();
11447                                    let conflict = ConflictRecord {
11448                                        client_commit_id: client_commit_id.to_owned(),
11449                                        op_index: *op_index,
11450                                        table,
11451                                        row_id,
11452                                        code: code.clone(),
11453                                        message: message.clone(),
11454                                        server_version: *server_version,
11455                                        server_row: server_row_json,
11456                                        conflict_columns,
11457                                        operation,
11458                                    };
11459                                    journal_results.push(CommitOperationOutcome::Conflict {
11460                                        conflict: conflict.clone(),
11461                                    });
11462                                    conflicts.push(conflict);
11463                                }
11464                                OpResult::Error {
11465                                    op_index,
11466                                    code,
11467                                    message,
11468                                    retryable,
11469                                } => {
11470                                    let rejection = RejectionRecord {
11471                                        client_commit_id: client_commit_id.to_owned(),
11472                                        op_index: *op_index,
11473                                        code: code.clone(),
11474                                        message: message.clone(),
11475                                        retryable: *retryable,
11476                                        details: rejection_details
11477                                            .and_then(|details| details.get(op_index))
11478                                            .cloned(),
11479                                        operation: operations
11480                                            .get(*op_index as usize)
11481                                            .map(CommitOperation::from),
11482                                    };
11483                                    journal_results.push(CommitOperationOutcome::Error {
11484                                        rejection: rejection.clone(),
11485                                    });
11486                                    rejections.push(rejection);
11487                                }
11488                            }
11489                        }
11490                        if self.retain_failed_commits {
11491                            self.retain_failed_commit(client_commit_id, &operations)?;
11492                        }
11493                        let outcome_status = if conflicts.is_empty() {
11494                            CommitOutcomeStatus::Rejected
11495                        } else {
11496                            CommitOutcomeStatus::Conflict
11497                        };
11498                        let persisted = self
11499                            .persist_commit_outcome(
11500                                client_commit_id,
11501                                outcome_status,
11502                                &journal_results,
11503                                Some(&operations),
11504                            )
11505                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
11506                        persisted?;
11507                        batch.conflicts = !conflicts.is_empty();
11508                        batch.rejections = !rejections.is_empty();
11509                        batch.status = true;
11510                        batch.outcomes = true;
11511                        self.conflicts.extend(conflicts);
11512                        self.rejections.extend(rejections);
11513                        for operation in &operations {
11514                            self.overlay_dirty.table(&operation.table);
11515                        }
11516                    }
11517                }
11518                removed.push((index, self.outbox.remove(index)));
11519                if batch.status {
11520                    for operation in &operations {
11521                        if !self.record_row_scopes(
11522                            &mut batch,
11523                            &operation.table,
11524                            &operation.row_id,
11525                            false,
11526                        ) {
11527                            batch.table(&operation.table);
11528                        }
11529                    }
11530                }
11531                match status {
11532                    PushStatus::Applied | PushStatus::Cached => {
11533                        report.applied.push(client_commit_id.to_owned())
11534                    }
11535                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
11536                }
11537            }
11538            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)) {
11539                self.prune_commit_outcomes()?;
11540            }
11541            self.finish_observation("syncular_push_results", batch)
11542        })();
11543        if let Err(error) = persisted {
11544            self.rollback_observation("syncular_push_results");
11545            self.failed_commits = previous_failed;
11546            self.sync_needed = previous_sync_needed;
11547            for (index, commit) in removed.into_iter().rev() {
11548                self.outbox.insert(index, commit);
11549            }
11550            self.conflicts.truncate(conflict_count);
11551            self.rejections.truncate(rejection_count);
11552            self.overlay_dirty.restore(was_dirty.clone());
11553            report.applied.truncate(applied_count);
11554            report.rejected.truncate(rejected_count);
11555            report.retryable.truncate(retryable_count);
11556            return Err(error);
11557        }
11558        if !previous_sync_needed && self.sync_needed {
11559            self.sync_intent_queue.push_back(SyncIntent::Interactive);
11560        }
11561        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
11562        Ok(())
11563    }
11564
11565    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
11566        status != PushStatus::Rejected
11567            || !results.iter().any(|result| {
11568                matches!(
11569                    result,
11570                    OpResult::Error {
11571                        code,
11572                        retryable: true,
11573                        ..
11574                    } if code == "sync.idempotency_cache_miss"
11575                )
11576            })
11577    }
11578
11579    // -- subscription sections ------------------------------------------------------
11580
11581    #[allow(clippy::too_many_arguments)]
11582    fn process_section(
11583        &mut self,
11584        transport: &mut dyn Transport,
11585        id: &str,
11586        status: SubStatus,
11587        reason_code: &str,
11588        effective_scopes: Vec<(String, Vec<String>)>,
11589        body: Vec<Frame>,
11590        sub_end: Option<(i64, Option<String>)>,
11591        meta: &RequestMeta,
11592        report: &mut SyncReport,
11593    ) -> Result<(), SectionError> {
11594        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
11595            return Ok(()); // unknown echo: ignore
11596        };
11597        if status != SubStatus::Active && sub_end.is_none() {
11598            return Err(SectionError::Abort(
11599                "sync.invalid_request".into(),
11600                "subscription section without SUB_END".into(),
11601            ));
11602        }
11603        match status {
11604            SubStatus::Revoked => {
11605                self.begin_observation("syncular_revocation")
11606                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
11607                let previous = self.subs[sub_index].clone();
11608                let was_dirty = self.overlay_dirty.snapshot();
11609                let previous_outbox = self.outbox.clone();
11610                let previous_failed = self.failed_commits.clone();
11611                let previous_rejections = self.rejections.clone();
11612                let previous_conflicts = self.conflicts.clone();
11613                let revoked_count = report.revoked.len();
11614                let failed_count = report.failed.len();
11615                let result =
11616                    (|| {
11617                        let mut batch = ChangeAccumulator::default();
11618                        let registered = self.window_unit_by_sub_id(id);
11619                        // §3.3: stop pulling, purge exactly the last effective grant.
11620                        let (table, effective) = {
11621                            let sub = &self.subs[sub_index];
11622                            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
11623                        };
11624                        let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
11625                            SectionError::Abort(
11626                                "sync.unknown_table".to_owned(),
11627                                "revoked scope references an unknown table".to_owned(),
11628                            )
11629                        })?;
11630                        let dropped = self
11631                            .drop_failed_commits(|name, values| {
11632                                name == table
11633                                    && !effective.is_empty()
11634                                    && effective.iter().all(|(variable, allowed)| {
11635                                        scoped_table
11636                                            .scope_column(variable)
11637                                            .and_then(|column| values.get(column))
11638                                            .and_then(Value::as_str)
11639                                            .is_some_and(|value| {
11640                                                allowed.iter().any(|allowed| allowed == value)
11641                                            })
11642                                    })
11643                            })
11644                            .map_err(|message| {
11645                                SectionError::Abort("storage.failed".to_owned(), message)
11646                            })?;
11647                        for commit in dropped {
11648                            batch.conflicts = true;
11649                            batch.rejections = true;
11650                            batch.outcomes = true;
11651                            for operation in commit.ops {
11652                                batch.table(&operation.table);
11653                            }
11654                        }
11655                        let purged = self.purge_scope_rows(&table, &effective);
11656                        match purged {
11657                            Ok(()) => {
11658                                self.record_scope_map(&mut batch, &table, &effective);
11659                                let sub = &mut self.subs[sub_index];
11660                                sub.state = SubState::Revoked;
11661                                sub.reason_code = Some(if reason_code.is_empty() {
11662                                    "sync.scope_revoked".to_owned()
11663                                } else {
11664                                    reason_code.to_owned()
11665                                });
11666                                report.revoked.push(id.to_owned());
11667                                let doomed_effective = effective;
11668                                let sub_table = table;
11669                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
11670                                    |message| SectionError::Abort("storage.failed".into(), message),
11671                                )?;
11672                                let dropped = self
11673                                    .drop_doomed_outbox(&sub_table, &doomed_effective)
11674                                    .map_err(|message| {
11675                                        SectionError::Abort("storage.failed".to_owned(), message)
11676                                    })?;
11677                                if dropped {
11678                                    batch.status = true;
11679                                    batch.rejections = true;
11680                                    batch.outcomes = true;
11681                                }
11682                                // §5.9.7 B2: revocation deletes now-unauthorized blob
11683                                // bodies (evicted ≠ revoked).
11684                                self.delete_unreferenced_cached_blobs();
11685                            }
11686                            Err(()) => {
11687                                // §3.3 fail closed: no local mapping — never clear by
11688                                // approximation; fatal configuration error.
11689                                let sub = &mut self.subs[sub_index];
11690                                sub.state = SubState::Failed;
11691                                sub.reason_code = Some("sync.scope_revoked".to_owned());
11692                                report.failed.push(id.to_owned());
11693                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
11694                                    |message| SectionError::Abort("storage.failed".into(), message),
11695                                )?;
11696                            }
11697                        }
11698                        if let Some((base_key, unit)) = registered {
11699                            batch.window(&base_key, &self.subs[sub_index].table, &unit);
11700                        }
11701                        self.rebuild_overlay_if_dirty();
11702                        self.finish_observation("syncular_revocation", batch)
11703                            .map_err(|message| {
11704                                SectionError::Abort("storage.failed".to_owned(), message)
11705                            })?;
11706                        Ok(())
11707                    })();
11708                if result.is_err() {
11709                    self.rollback_observation("syncular_revocation");
11710                    self.subs[sub_index] = previous;
11711                    self.overlay_dirty.restore(was_dirty.clone());
11712                    self.outbox = previous_outbox;
11713                    self.failed_commits = previous_failed;
11714                    self.rejections = previous_rejections;
11715                    self.conflicts = previous_conflicts;
11716                    report.revoked.truncate(revoked_count);
11717                    report.failed.truncate(failed_count);
11718                }
11719                result
11720            }
11721            SubStatus::Reset => {
11722                self.begin_observation("syncular_reset")
11723                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
11724                let previous = self.subs[sub_index].clone();
11725                let was_dirty = self.overlay_dirty.snapshot();
11726                let reset_count = report.resets.len();
11727                let result = (|| {
11728                    let mut batch = ChangeAccumulator::default();
11729                    let registered = self.window_unit_by_sub_id(id);
11730                    // §4.6: discard cursor + bootstrap state, keep local rows —
11731                    // reset is a staleness signal, not a purge signal.
11732                    let sub = &mut self.subs[sub_index];
11733                    sub.cursor = -1;
11734                    sub.bootstrap_state = None;
11735                    report.resets.push(id.to_owned());
11736                    self.persist_sub(&self.subs[sub_index].clone())
11737                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11738                    if let Some((base_key, unit)) = registered {
11739                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
11740                    }
11741                    self.finish_observation("syncular_reset", batch)
11742                        .map_err(|message| {
11743                            SectionError::Abort("storage.failed".to_owned(), message)
11744                        })?;
11745                    Ok(())
11746                })();
11747                if result.is_err() {
11748                    self.rollback_observation("syncular_reset");
11749                    self.subs[sub_index] = previous;
11750                    self.overlay_dirty.restore(was_dirty.clone());
11751                    report.resets.truncate(reset_count);
11752                }
11753                result
11754            }
11755            SubStatus::Active => {
11756                let fresh = meta
11757                    .fresh
11758                    .iter()
11759                    .find(|(fid, _)| fid == id)
11760                    .map(|(_, fresh)| *fresh)
11761                    .unwrap_or(false);
11762                let previous = self.subs[sub_index].clone();
11763                self.subs[sub_index].effective = Some(effective_scopes);
11764                let outcome = self
11765                    .apply_section_body(transport, sub_index, body, fresh, meta, report)
11766                    .and_then(|()| {
11767                        let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
11768                            SectionError::Abort(
11769                                "sync.invalid_request".into(),
11770                                "subscription section without SUB_END".into(),
11771                            )
11772                        })?;
11773                        self.apply_sub_end(sub_index, cursor, bootstrap_state)
11774                            .map_err(|message| {
11775                                SectionError::Abort("storage.failed".into(), message)
11776                            })
11777                    });
11778                match outcome {
11779                    Ok(()) => {
11780                        if self.subs[sub_index].bootstrap_state.is_some() {
11781                            report.bootstrapping.push(id.to_owned());
11782                        }
11783                        Ok(())
11784                    }
11785                    Err(SectionError::FailClosed) => {
11786                        self.subs[sub_index] = previous.clone();
11787                        self.begin_observation("syncular_section_failure")
11788                            .map_err(|message| {
11789                                SectionError::Abort("storage.failed".into(), message)
11790                            })?;
11791                        let persisted = (|| {
11792                            let mut batch = ChangeAccumulator::default();
11793                            if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
11794                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
11795                            }
11796                            self.subs[sub_index].state = SubState::Failed;
11797                            self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
11798                            self.persist_sub(&self.subs[sub_index])?;
11799                            self.finish_observation("syncular_section_failure", batch)
11800                        })();
11801                        if let Err(message) = persisted {
11802                            self.rollback_observation("syncular_section_failure");
11803                            self.subs[sub_index] = previous;
11804                            return Err(SectionError::Abort("storage.failed".into(), message));
11805                        }
11806                        report.failed.push(id.to_owned());
11807                        Ok(())
11808                    }
11809                    Err(error) => {
11810                        // Frame/block transactions already completed. Preserve their
11811                        // rows and revisions, but leave this subscription cursor unchanged.
11812                        self.subs[sub_index] = previous;
11813                        Err(error)
11814                    }
11815                }
11816            }
11817        }
11818    }
11819
11820    fn apply_sub_end(
11821        &mut self,
11822        sub_index: usize,
11823        cursor: i64,
11824        bootstrap_state: Option<String>,
11825    ) -> Result<(), String> {
11826        #[cfg(feature = "bench-internals")]
11827        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
11828        let previous = self.subs[sub_index].clone();
11829        self.begin_observation("syncular_sub_end")?;
11830        let persisted = (|| {
11831            let mut batch = ChangeAccumulator::default();
11832            let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
11833                && cursor >= 0
11834                && bootstrap_state.is_none();
11835            if completed {
11836                let table = self
11837                    .schema
11838                    .table(&previous.table)
11839                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
11840                let effective = previous.effective.as_deref().unwrap_or(&[]);
11841                let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
11842                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11843                let rows = statement
11844                    .query_map(rusqlite::params![previous.table, cursor], |row| {
11845                        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11846                    })
11847                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11848                let mut covered = HashSet::new();
11849                for row in rows {
11850                    let (id, intent) =
11851                        row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11852                    let values: Map<String, Value> = serde_json::from_str(&intent)
11853                        .map_err(|_| "sync.local_corrupt".to_owned())?;
11854                    if !effective.is_empty()
11855                        && effective.iter().all(|(variable, allowed)| {
11856                            table
11857                                .scope_column(variable)
11858                                .and_then(|column| values.get(column))
11859                                .is_some_and(|value| {
11860                                    allowed.contains(
11861                                        &value
11862                                            .as_str()
11863                                            .map_or_else(|| value.to_string(), str::to_owned),
11864                                    )
11865                                })
11866                        })
11867                    {
11868                        covered.insert(id);
11869                    }
11870                }
11871                drop(statement);
11872                for id in covered {
11873                    self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
11874                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11875                    self.overlay_dirty.table(&previous.table);
11876                    batch.table(&previous.table);
11877                }
11878                self.rebuild_overlay_if_dirty();
11879                if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
11880                    batch.window(&base_key, &previous.table, &unit);
11881                }
11882            }
11883            let sub = &mut self.subs[sub_index];
11884            sub.cursor = cursor;
11885            sub.bootstrap_state = bootstrap_state;
11886            sub.synced_once = true;
11887            self.persist_sub(&self.subs[sub_index])?;
11888            self.finish_observation("syncular_sub_end", batch)
11889        })();
11890        if persisted.is_err() {
11891            self.rollback_observation("syncular_sub_end");
11892            self.subs[sub_index] = previous;
11893        }
11894        persisted
11895    }
11896
11897    fn apply_section_body(
11898        &mut self,
11899        transport: &mut dyn Transport,
11900        sub_index: usize,
11901        body: Vec<Frame>,
11902        fresh: bool,
11903        meta: &RequestMeta,
11904        report: &mut SyncReport,
11905    ) -> Result<(), SectionError> {
11906        let mut saw_segment = false;
11907        for frame in body {
11908            match frame {
11909                Frame::Commit {
11910                    tables,
11911                    changes,
11912                    commit_seq,
11913                    ..
11914                } => {
11915                    self.apply_commit_frame(&tables, &changes, Some(commit_seq))
11916                        .map_err(|(c, m)| SectionError::Abort(c, m))?;
11917                    report.commits_applied += 1;
11918                }
11919                Frame::SegmentInline { payload } => {
11920                    let segment = decode_rows_segment(&payload)
11921                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
11922                    self.progress.update(|p| {
11923                        p.phase = ProgressPhase::Import;
11924                        p.subscription_id = Some(self.subs[sub_index].id.clone());
11925                        p.table = Some(self.subs[sub_index].table.clone());
11926                        p.segment_id = None;
11927                        p.bytes_received = payload.len() as u64;
11928                        p.bytes_total = Some(payload.len() as u64);
11929                        p.rows_processed = 0;
11930                        p.rows_total =
11931                            Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
11932                    });
11933                    let first = !saw_segment;
11934                    saw_segment = true;
11935                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
11936                    report.segment_rows_applied += applied;
11937                }
11938                Frame::SegmentRef {
11939                    segment_id,
11940                    byte_length,
11941                    media_type,
11942                    table,
11943                    row_count,
11944                    as_of_commit_seq,
11945                    scope_digest,
11946                    row_cursor,
11947                    next_row_cursor,
11948                    url,
11949                    url_expires_at_ms,
11950                    ..
11951                } => {
11952                    // §4.2: reject a descriptor whose mediaType was not
11953                    // advertised — never skip or guess.
11954                    let advertised = match media_type {
11955                        MediaType::Rows => {
11956                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
11957                                || meta.accept & ACCEPT_INLINE_ROWS != 0
11958                        }
11959                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
11960                    };
11961                    if !advertised {
11962                        return Err(SectionError::Abort(
11963                            "sync.invalid_request".to_owned(),
11964                            format!(
11965                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
11966                                media_type.name()
11967                            ),
11968                        ));
11969                    }
11970                    self.progress.update(|p| {
11971                        p.phase = ProgressPhase::Download;
11972                        p.subscription_id = Some(self.subs[sub_index].id.clone());
11973                        p.table = Some(table.clone());
11974                        p.segment_id = Some(segment_id.clone());
11975                        p.bytes_received = 0;
11976                        p.bytes_total = u64::try_from(byte_length).ok();
11977                        p.rows_processed = 0;
11978                        p.rows_total = u64::try_from(row_count).ok();
11979                    });
11980                    let observer = self.progress.clone();
11981                    let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
11982                    let bytes = if let Some(url) = url {
11983                        // §5.4: a url-carrying descriptor MUST be fetched
11984                        // from that URL; failure invalidates the whole
11985                        // descriptor (no fall-through to §5.5 — re-pull
11986                        // recovers, §1.4 rule 5).
11987                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
11988                            return Err(SectionError::Abort(
11989                                "sync.invalid_request".to_owned(),
11990                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
11991                                    .to_owned(),
11992                            ));
11993                        }
11994                        // §5.4: MUST NOT start a fetch at/past expiry.
11995                        if url_expires_at_ms.is_some_and(|exp| {
11996                            exp <= transport
11997                                .segment_fetch_started_at(&url)
11998                                .unwrap_or_else(|| self.clock_now_ms())
11999                        }) {
12000                            return Err(SectionError::Abort(
12001                                "sync.segment_expired".to_owned(),
12002                                format!(
12003                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
12004                                ),
12005                            ));
12006                        }
12007                        transport
12008                            .fetch_url(&url, &mut on_progress)
12009                            .map_err(|e| SectionError::Abort(e.code, e.message))?
12010                    } else {
12011                        let requested_scopes_json =
12012                            canonical_scope_json(&self.subs[sub_index].requested);
12013                        transport
12014                            .download_segment(
12015                                &SegmentRequest {
12016                                    segment_id: segment_id.clone(),
12017                                    table,
12018                                    requested_scopes_json,
12019                                },
12020                                &mut on_progress,
12021                            )
12022                            .map_err(|e| SectionError::Abort(e.code, e.message))?
12023                    };
12024                    // §5.1: verify the content address before applying.
12025                    on_progress(bytes.len() as u64);
12026                    let digest = Sha256::digest(&bytes);
12027                    let expected = segment_id
12028                        .strip_prefix("sha256:")
12029                        .unwrap_or(segment_id.as_str());
12030                    if bytes_to_hex(&digest) != expected {
12031                        // §5.1: the client discards the segment and re-pulls.
12032                        self.schedule_background_retry();
12033                        return Err(SectionError::Abort(
12034                            "sync.invalid_request".to_owned(),
12035                            "segment bytes do not match the content address (§5.1)".to_owned(),
12036                        ));
12037                    }
12038                    self.progress.update(|p| p.phase = ProgressPhase::Import);
12039                    if media_type == MediaType::Sqlite {
12040                        // §5.3: images are whole-table — a paged sqlite
12041                        // descriptor is invalid.
12042                        if row_cursor.is_some() || next_row_cursor.is_some() {
12043                            return Err(SectionError::Abort(
12044                                "sync.invalid_request".to_owned(),
12045                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
12046                                    .to_owned(),
12047                            ));
12048                        }
12049                        let first = !saw_segment;
12050                        saw_segment = true;
12051                        let applied = self.apply_sqlite_segment(
12052                            sub_index,
12053                            &bytes,
12054                            fresh && first,
12055                            row_count,
12056                            as_of_commit_seq,
12057                            &scope_digest,
12058                        )?;
12059                        report.segment_rows_applied += applied;
12060                    } else {
12061                        let segment = decode_rows_segment(&bytes).map_err(|e| {
12062                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
12063                        })?;
12064                        let first = row_cursor.is_none();
12065                        saw_segment = true;
12066                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
12067                        report.segment_rows_applied += applied;
12068                    }
12069                }
12070                Frame::Unknown { .. } => {}
12071                Frame::Error { code, message, .. } => {
12072                    return Err(SectionError::Abort(code, message))
12073                }
12074                _ => {
12075                    return Err(SectionError::Abort(
12076                        "sync.invalid_request".to_owned(),
12077                        "unexpected frame inside a subscription section".to_owned(),
12078                    ));
12079                }
12080            }
12081        }
12082        Ok(())
12083    }
12084
12085    // Both pull and realtime frames use the same durable observation boundary.
12086    fn apply_commit_frame(
12087        &mut self,
12088        tables: &[String],
12089        changes: &[ssp2::model::Change],
12090        commit_seq: Option<i64>,
12091    ) -> Result<(), (String, String)> {
12092        #[cfg(feature = "bench-internals")]
12093        let _phase = self.benchmark_phases.start(Phase::CommitApply);
12094        let was_dirty = self.overlay_dirty.snapshot();
12095        self.begin_observation("syncular_commit")
12096            .map_err(|message| ("storage.failed".into(), message))?;
12097        let applied = (|| {
12098            let mut batch = ChangeAccumulator::default();
12099            self.record_commit_changes(&mut batch, tables, changes);
12100            if let Some(sequence) = commit_seq {
12101                for change in changes {
12102                    let table = tables.get(change.table_index as usize).ok_or_else(|| {
12103                        (
12104                            "sync.invalid_request".to_owned(),
12105                            "change tableIndex out of range".to_owned(),
12106                        )
12107                    })?;
12108                    if self
12109                        .outbox
12110                        .iter()
12111                        .flat_map(|commit| &commit.ops)
12112                        .any(|op| op.table == *table && op.row_id == change.row_id)
12113                    {
12114                        self.conn.execute("INSERT INTO _syncular_row_deliveries(tbl,id,commit_seq) VALUES (?1,?2,?3) ON CONFLICT(tbl,id) DO UPDATE SET commit_seq=MAX(commit_seq,excluded.commit_seq)", rusqlite::params![table, change.row_id, sequence])
12115                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
12116                    }
12117                    self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![table, change.row_id, sequence])
12118                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
12119                }
12120            }
12121            self.apply_commit_changes(tables, changes)?;
12122            self.rebuild_overlay_if_dirty();
12123            self.finish_observation("syncular_commit", batch)
12124                .map_err(|message| ("storage.failed".into(), message))
12125        })();
12126        if applied.is_err() {
12127            self.rollback_observation("syncular_commit");
12128            self.overlay_dirty.restore(was_dirty.clone());
12129        }
12130        applied
12131    }
12132
12133    fn apply_commit_changes(
12134        &mut self,
12135        tables: &[String],
12136        changes: &[ssp2::model::Change],
12137    ) -> Result<(), (String, String)> {
12138        // A clean overlay can reconcile changed primary keys independently
12139        // when their tables have no secondary unique constraints. Such a
12140        // constraint can change whether another row's pending write succeeds,
12141        // so those frames still require the complete FIFO replay below.
12142        let mirror_visible = !self.overlay_dirty.get()
12143            && self.failed_commits.is_empty()
12144            && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
12145                || changes.iter().all(|change| {
12146                    tables
12147                        .get(change.table_index as usize)
12148                        .and_then(|name| self.schema.table(name))
12149                        .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
12150                }));
12151        for change in changes {
12152            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
12153                (
12154                    "sync.invalid_request".to_owned(),
12155                    "change tableIndex out of range".to_owned(),
12156                )
12157            })?;
12158            let table = self.schema.table(table_name).ok_or_else(|| {
12159                (
12160                    "sync.schema_mismatch".to_owned(),
12161                    format!("change targets unknown table {table_name:?}"),
12162                )
12163            })?;
12164            match change.op {
12165                Op::Upsert => {
12166                    let payload = change.row.as_ref().ok_or_else(|| {
12167                        (
12168                            "sync.invalid_request".to_owned(),
12169                            "upsert change without row payload".to_owned(),
12170                        )
12171                    })?;
12172                    // §5.11: decrypt encrypted columns on apply.
12173                    let row = {
12174                        #[cfg(feature = "bench-internals")]
12175                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
12176                        decode_row_bytes(table, payload, &self.encryption)
12177                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?
12178                    };
12179                    let version = change.row_version.unwrap_or(0);
12180                    let table_name = table.name.clone();
12181                    self.write_base_row(&table_name, &row, version)
12182                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
12183                    if mirror_visible {
12184                        self.write_row(&visible_table(&table_name), &table_name, &row, version)
12185                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
12186                    }
12187                }
12188                Op::Delete => {
12189                    self.delete_base_row(table_name, &change.row_id)
12190                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
12191                    if mirror_visible {
12192                        let sql = format!(
12193                            "DELETE FROM {} WHERE {}",
12194                            visible_table(table_name),
12195                            row_id_predicate(table)
12196                        );
12197                        self.conn
12198                            .prepare_cached(&sql)
12199                            .and_then(|mut statement| {
12200                                statement.execute(rusqlite::params![change.row_id])
12201                            })
12202                            .map_err(|error| {
12203                                ("sync.invalid_request".to_owned(), error.to_string())
12204                            })?;
12205                    }
12206                }
12207            }
12208        }
12209        if mirror_visible {
12210            {
12211                let changed_rows: HashSet<(&str, &str)> = changes
12212                    .iter()
12213                    .map(|change| {
12214                        (
12215                            tables[change.table_index as usize].as_str(),
12216                            change.row_id.as_str(),
12217                        )
12218                    })
12219                    .collect();
12220                let acknowledged = self
12221                    .acknowledged_operations(Some(&changed_rows), None)
12222                    .map_err(|error| ("storage.failed".to_owned(), error))?;
12223                self.apply_outbox_ops(&acknowledged);
12224                self.apply_outbox_ops(
12225                    self.outbox
12226                        .iter()
12227                        .flat_map(|commit| &commit.ops)
12228                        .filter(|op| {
12229                            changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
12230                        }),
12231                );
12232            }
12233            self.overlay_dirty.set(false);
12234        }
12235        Ok(())
12236    }
12237
12238    /// §5.6 segment application: validate against the generated schema,
12239    /// clear the grant on a fresh bootstrap's first page (fail closed
12240    /// without a mapping), then replace-or-upsert each row with its
12241    /// segment-carried server version (§5.2).
12242    fn apply_segment(
12243        &mut self,
12244        sub_index: usize,
12245        segment: &RowsSegment,
12246        first_fresh_page: bool,
12247    ) -> Result<u32, SectionError> {
12248        let (sub_table, effective) = {
12249            let sub = &self.subs[sub_index];
12250            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
12251        };
12252        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
12253            SectionError::Abort(
12254                "sync.schema_mismatch".to_owned(),
12255                format!("subscription table {sub_table:?} is not in the client schema"),
12256            )
12257        })?;
12258        // §5.2: the column table validates against the generated schema —
12259        // order, names, types, nullability; mismatch is fatal. §5.11: the
12260        // server sends the WIRE types (bytes for an encrypted column), so
12261        // validate against wire_columns.
12262        let matches = segment.table == table.name
12263            && segment.schema_version == self.schema.version
12264            && segment.columns.len() == table.wire_columns.len()
12265            && segment
12266                .columns
12267                .iter()
12268                .zip(table.wire_columns.iter())
12269                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
12270        if !matches {
12271            return Err(SectionError::Abort(
12272                "sync.schema_mismatch".to_owned(),
12273                "segment column table does not match the generated schema (§5.2)".to_owned(),
12274            ));
12275        }
12276        let mut applied = 0u32;
12277        let empty = Vec::new();
12278        let blocks = if segment.blocks.is_empty() {
12279            std::slice::from_ref(&empty)
12280        } else {
12281            &segment.blocks
12282        };
12283        for (index, block) in blocks.iter().enumerate() {
12284            let clear = first_fresh_page && index == 0;
12285            if !clear && block.is_empty() {
12286                continue;
12287            }
12288            let was_dirty = self.overlay_dirty.snapshot();
12289            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
12290                && self
12291                    .outbox
12292                    .iter()
12293                    .flat_map(|commit| &commit.ops)
12294                    .any(|op| op.table == table.name);
12295            let mirror_visible = !reconcile_pending
12296                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
12297            self.begin_observation("syncular_segment_block")
12298                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12299            let outcome = (|| {
12300                self.rebuild_overlay_if_dirty();
12301                let mut changed_keys = Vec::new();
12302                let mut batch = ChangeAccumulator::default();
12303                if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
12304                    batch.table(&table.name);
12305                }
12306                if clear {
12307                    self.purge_scope_rows(&table.name, &effective)
12308                        .map_err(|()| SectionError::FailClosed)?;
12309                }
12310                for (row_index, row) in block.iter().enumerate() {
12311                    let decrypted;
12312                    let values = if table.has_encrypted_columns() {
12313                        let mut values = row.values.clone();
12314                        crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
12315                            .map_err(|message| {
12316                            SectionError::Abort("client.decrypt_failed".into(), message)
12317                        })?;
12318                        decrypted = values;
12319                        &decrypted
12320                    } else {
12321                        &row.values
12322                    };
12323                    self.write_base_row(&table.name, values, row.server_version)
12324                        .map_err(|message| {
12325                            SectionError::Abort("sync.invalid_request".into(), message)
12326                        })?;
12327                    if reconcile_pending {
12328                        changed_keys.push(
12329                            owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
12330                                |message| SectionError::Abort("storage.failed".into(), message),
12331                            )?,
12332                        );
12333                    }
12334                    if mirror_visible {
12335                        self.write_row(
12336                            &visible_table(&table.name),
12337                            &table.name,
12338                            values,
12339                            row.server_version,
12340                        )
12341                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12342                    }
12343                    let processed = u64::from(applied) + row_index as u64 + 1;
12344                    if processed.is_multiple_of(1024) {
12345                        self.progress.update(|p| p.rows_processed = processed);
12346                    }
12347                }
12348                if reconcile_pending {
12349                    self.reconcile_imported_rows(&table, &mut changed_keys)
12350                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12351                }
12352                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
12353                    self.apply_outbox_ops(
12354                        self.outbox
12355                            .iter()
12356                            .flat_map(|commit| &commit.ops)
12357                            .filter(|op| op.table == table.name),
12358                    );
12359                    self.overlay_dirty.set(false);
12360                } else {
12361                    self.rebuild_overlay_if_dirty();
12362                }
12363                self.finish_observation("syncular_segment_block", batch)
12364                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
12365            })();
12366            if let Err(error) = outcome {
12367                self.rollback_observation("syncular_segment_block");
12368                self.overlay_dirty.restore(was_dirty.clone());
12369                return Err(error);
12370            }
12371            applied += block.len() as u32;
12372            self.progress
12373                .update(|p| p.rows_processed = u64::from(applied));
12374            std::thread::yield_now();
12375        }
12376        self.progress
12377            .update(|p| p.rows_processed = u64::from(applied));
12378        Ok(applied)
12379    }
12380
12381    /// §5.3 sqlite-image application: validate the in-file metadata
12382    /// against the descriptor, validate column names/order against the
12383    /// generated schema, run the §5.6 first-page clear when fresh, then
12384    /// replace-or-upsert every image row with its `_syncular_version`.
12385    /// Mechanics: the image lands in a temp file read through a second
12386    /// rusqlite connection, equivalent to ATTACH + INSERT…SELECT.
12387    /// Each image chunk commits its writes and revision before readers continue.
12388    fn apply_sqlite_segment(
12389        &mut self,
12390        sub_index: usize,
12391        bytes: &[u8],
12392        first_fresh_page: bool,
12393        row_count: i64,
12394        as_of_commit_seq: i64,
12395        scope_digest: &str,
12396    ) -> Result<u32, SectionError> {
12397        let invalid = |detail: &str| {
12398            SectionError::Abort(
12399                "sync.invalid_request".to_owned(),
12400                format!("sqlite segment rejected: {detail} (§5.3)"),
12401            )
12402        };
12403        let (sub_table, effective) = {
12404            let sub = &self.subs[sub_index];
12405            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
12406        };
12407        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
12408            SectionError::Abort(
12409                "sync.schema_mismatch".to_owned(),
12410                format!("subscription table {sub_table:?} is not in the client schema"),
12411            )
12412        })?;
12413
12414        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
12415        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
12416        let img = match rusqlite::Connection::open_with_flags(
12417            &path,
12418            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
12419        ) {
12420            Ok(conn) => conn,
12421            Err(_) => {
12422                let _ = std::fs::remove_file(&path);
12423                return Err(invalid("bytes do not open as a SQLite database"));
12424            }
12425        };
12426        let outcome = self.apply_sqlite_image(
12427            &img,
12428            &table,
12429            first_fresh_page,
12430            &effective,
12431            row_count,
12432            as_of_commit_seq,
12433            scope_digest,
12434        );
12435        drop(img);
12436        let _ = std::fs::remove_file(&path);
12437        outcome
12438    }
12439
12440    #[allow(clippy::too_many_arguments)]
12441    fn apply_sqlite_image(
12442        &mut self,
12443        img: &rusqlite::Connection,
12444        table: &crate::schema::TableSchema,
12445        first_fresh_page: bool,
12446        effective: &[(String, Vec<String>)],
12447        row_count: i64,
12448        as_of_commit_seq: i64,
12449        scope_digest: &str,
12450    ) -> Result<u32, SectionError> {
12451        let invalid = |detail: String| {
12452            SectionError::Abort(
12453                "sync.invalid_request".to_owned(),
12454                format!("sqlite segment rejected: {detail} (§5.3)"),
12455            )
12456        };
12457
12458        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
12459        //    one row).
12460        type MetaRow = (i64, String, i64, i64, String, i64, i64);
12461        let meta: MetaRow = img
12462            .query_row(
12463                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
12464                        \"scopeDigest\", \"rowCount\",
12465                        (SELECT count(*) FROM _syncular_segment)
12466                 FROM _syncular_segment",
12467                [],
12468                |row| {
12469                    Ok((
12470                        row.get(0)?,
12471                        row.get(1)?,
12472                        row.get(2)?,
12473                        row.get(3)?,
12474                        row.get(4)?,
12475                        row.get(5)?,
12476                        row.get(6)?,
12477                    ))
12478                },
12479            )
12480            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
12481        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
12482        if meta_count != 1 {
12483            return Err(invalid(format!(
12484                "_syncular_segment must contain exactly one row, found {meta_count}"
12485            )));
12486        }
12487        if format != 1 {
12488            return Err(invalid(format!("format {format}")));
12489        }
12490        if meta_table != table.name {
12491            return Err(invalid(format!("image table {meta_table:?}")));
12492        }
12493        if schema_version != i64::from(self.schema.version) {
12494            return Err(invalid(format!("schemaVersion {schema_version}")));
12495        }
12496        if pin != as_of_commit_seq {
12497            return Err(invalid(format!("asOfCommitSeq {pin}")));
12498        }
12499        if digest != scope_digest {
12500            return Err(invalid("scopeDigest mismatch".to_owned()));
12501        }
12502        if meta_rows != row_count {
12503            return Err(invalid(format!("rowCount {meta_rows}")));
12504        }
12505
12506        // 2. Column names and order vs the generated schema (§5.3 rule 3).
12507        let mut names: Vec<String> = Vec::new();
12508        let mut primary_keys: Vec<String> = Vec::new();
12509        {
12510            let mut stmt = img
12511                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
12512                .map_err(|_| invalid("image data table missing".to_owned()))?;
12513            let mut rows = stmt
12514                .query([])
12515                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
12516            while let Some(row) = rows
12517                .next()
12518                .map_err(|_| invalid("image data table unreadable".to_owned()))?
12519            {
12520                if row
12521                    .get::<_, i64>(5)
12522                    .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
12523                    > 0
12524                {
12525                    primary_keys.push(row.get(1).map_err(|error| {
12526                        invalid(Self::sqlite_failure(&self.storage_failure, error))
12527                    })?);
12528                }
12529                names.push(
12530                    row.get::<_, String>(1)
12531                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
12532                );
12533            }
12534        }
12535        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
12536        expected.push("_syncular_version");
12537        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
12538            return Err(SectionError::Abort(
12539                "sync.schema_mismatch".to_owned(),
12540                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
12541            ));
12542        }
12543
12544        if primary_keys != vec![table.primary_key.clone()] {
12545            return Err(invalid(
12546                "image primary key does not match generated schema".into(),
12547            ));
12548        }
12549        let actual_count: i64 = img
12550            .query_row(
12551                &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
12552                [],
12553                |row| row.get(0),
12554            )
12555            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
12556        if actual_count != row_count {
12557            return Err(invalid("image row count does not match descriptor".into()));
12558        }
12559        let insert = self.insert_row_sql(&base_table(&table.name), table);
12560        let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
12561        let column_list = names
12562            .iter()
12563            .map(|n| quote_ident(n))
12564            .collect::<Vec<_>>()
12565            .join(", ");
12566        let mut stmt = img
12567            .prepare(&format!(
12568                "SELECT {column_list} FROM {} ORDER BY {}",
12569                quote_ident(&table.name),
12570                quote_ident(&table.primary_key)
12571            ))
12572            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
12573        let mut rows = stmt
12574            .query([])
12575            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
12576        let mut applied = 0u32;
12577        loop {
12578            let clear = applied == 0 && first_fresh_page;
12579            let was_dirty = self.overlay_dirty.snapshot();
12580            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
12581                && self
12582                    .outbox
12583                    .iter()
12584                    .flat_map(|commit| &commit.ops)
12585                    .any(|op| op.table == table.name);
12586            let mirror_visible = !reconcile_pending
12587                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
12588            self.begin_observation("syncular_image_chunk")
12589                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12590            let result = (|| {
12591                self.rebuild_overlay_if_dirty();
12592                let mut changed_keys = Vec::new();
12593                let mut batch = ChangeAccumulator::default();
12594                if clear {
12595                    if self.scoped_rows_exist(&table.name, effective) {
12596                        batch.table(&table.name);
12597                    }
12598                    self.purge_scope_rows(&table.name, effective)
12599                        .map_err(|()| SectionError::FailClosed)?;
12600                }
12601                let mut processed = 0u32;
12602                {
12603                    let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
12604                        invalid(Self::sqlite_failure(&self.storage_failure, error))
12605                    })?;
12606                    let mut mirror = if mirror_visible {
12607                        Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
12608                            invalid(Self::sqlite_failure(&self.storage_failure, error))
12609                        })?)
12610                    } else {
12611                        None
12612                    };
12613                    for _ in 0..1024 {
12614                        let Some(row) = rows.next().map_err(|error| {
12615                            invalid(Self::sqlite_failure(&self.storage_failure, error))
12616                        })?
12617                        else {
12618                            break;
12619                        };
12620                        for (i, column) in table.columns.iter().enumerate() {
12621                            let cell = row.get_ref(i).map_err(|error| {
12622                                invalid(Self::sqlite_failure(&self.storage_failure, error))
12623                            })?;
12624                            let param = image_cell_param(column, cell).map_err(invalid)?;
12625                            ins.raw_bind_parameter(i + 1, &param).map_err(|error| {
12626                                invalid(Self::sqlite_failure(&self.storage_failure, error))
12627                            })?;
12628                            if let Some(mirror) = &mut mirror {
12629                                mirror.raw_bind_parameter(i + 1, &param).map_err(|error| {
12630                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
12631                                })?;
12632                            }
12633                        }
12634                        let version_index = table.columns.len();
12635                        let version: i64 = row.get(version_index).map_err(|error| {
12636                            invalid(Self::sqlite_failure(&self.storage_failure, error))
12637                        })?;
12638                        if version < 1 {
12639                            return Err(invalid("image row version must be positive".into()));
12640                        }
12641                        ins.raw_bind_parameter(version_index + 1, version)
12642                            .map_err(|error| {
12643                                invalid(Self::sqlite_failure(&self.storage_failure, error))
12644                            })?;
12645                        ins.raw_execute().map_err(|error| {
12646                            invalid(Self::sqlite_failure(&self.storage_failure, error))
12647                        })?;
12648                        if let Some(mirror) = &mut mirror {
12649                            mirror
12650                                .raw_bind_parameter(version_index + 1, version)
12651                                .map_err(|error| {
12652                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
12653                                })?;
12654                            mirror.raw_execute().map_err(|error| {
12655                                invalid(Self::sqlite_failure(&self.storage_failure, error))
12656                            })?;
12657                        }
12658                        if reconcile_pending {
12659                            changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
12660                                |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
12661                            )?);
12662                        }
12663                        processed += 1;
12664                    }
12665                }
12666                if processed > 0 {
12667                    batch.table(&table.name);
12668                    self.overlay_dirty.table(&table.name);
12669                }
12670                if reconcile_pending {
12671                    self.reconcile_imported_rows(table, &mut changed_keys)
12672                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12673                }
12674                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
12675                    self.apply_outbox_ops(
12676                        self.outbox
12677                            .iter()
12678                            .flat_map(|commit| &commit.ops)
12679                            .filter(|op| op.table == table.name),
12680                    );
12681                    self.overlay_dirty.set(false);
12682                } else {
12683                    self.rebuild_overlay_if_dirty();
12684                }
12685                self.finish_observation("syncular_image_chunk", batch)
12686                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12687                Ok(processed)
12688            })();
12689            let processed = match result {
12690                Ok(processed) => processed,
12691                Err(error) => {
12692                    self.rollback_observation("syncular_image_chunk");
12693                    self.overlay_dirty.restore(was_dirty.clone());
12694                    return Err(error);
12695                }
12696            };
12697            applied += processed;
12698            self.progress
12699                .update(|p| p.rows_processed = u64::from(applied));
12700            std::thread::yield_now();
12701            if i64::from(applied) == actual_count {
12702                break;
12703            }
12704        }
12705        Ok(applied)
12706    }
12707
12708    /// Restore only imported and pending row identities before FIFO replay. All pending
12709    /// identities participate: a server row can free a unique value for a different edit.
12710    /// The caller owns the chunk transaction and publishes its revision after replay.
12711    fn reconcile_imported_rows(
12712        &self,
12713        table: &TableSchema,
12714        keys: &mut Vec<SqlValue>,
12715    ) -> Result<(), String> {
12716        let visible = visible_table(&table.name);
12717        let base = base_table(&table.name);
12718        let pk = quote_ident(&table.primary_key);
12719        let predicate = row_id_predicate(table);
12720        let mut lookup = self.conn.prepare_cached(&format!(
12721            "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
12722        )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12723        let mut deleted = HashSet::new();
12724        for op in self
12725            .outbox
12726            .iter()
12727            .flat_map(|commit| &commit.ops)
12728            .filter(|op| op.table == table.name)
12729        {
12730            if op.upsert {
12731                if let Some(values) = &op.values {
12732                    let key = json_to_column_value(
12733                        &table.columns[table.pk_index],
12734                        values.get(&table.primary_key),
12735                    )?;
12736                    keys.push(owned_sql_value(RowParam::Cell(&key))?);
12737                }
12738            } else if deleted.insert(&op.row_id) {
12739                keys.extend(
12740                    lookup
12741                        .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
12742                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
12743                        .collect::<Result<Vec<_>, _>>()
12744                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
12745                );
12746            }
12747        }
12748        // Delete the complete affected set before restoring any base value, so
12749        // old optimistic unique values cannot collide with the new server rows.
12750        for chunk in keys.chunks(400) {
12751            let holes = vec!["?"; chunk.len()].join(",");
12752            self.conn
12753                .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
12754                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
12755                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12756        }
12757        let updates = table
12758            .columns
12759            .iter()
12760            .map(|column| column.name.as_str())
12761            .chain(std::iter::once("_syncular_version"))
12762            .filter(|name| *name != table.primary_key)
12763            .map(|name| {
12764                let name = quote_ident(name);
12765                format!("{name}=excluded.{name}")
12766            })
12767            .collect::<Vec<_>>()
12768            .join(",");
12769        for chunk in keys.chunks(400) {
12770            let holes = vec!["?"; chunk.len()].join(",");
12771            self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
12772                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
12773                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12774        }
12775        Ok(())
12776    }
12777
12778    // -- application-authorized local purge ---------------------------------------
12779
12780    fn compile_local_data_purge(
12781        &self,
12782        input: &LocalDataPurgeInput,
12783    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
12784        let invalid = |message: String| format!("sync.invalid_request: {message}");
12785        if input.purge_id.is_empty()
12786            || input.purge_id.len() > 128
12787            || !is_local_operation_code_like(&input.purge_id)
12788        {
12789            return Err(invalid(
12790                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
12791            ));
12792        }
12793        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
12794            return Err(invalid(format!(
12795                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
12796            )));
12797        }
12798
12799        let mut deduplicated: BTreeMap<
12800            String,
12801            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
12802        > = BTreeMap::new();
12803        for target in &input.targets {
12804            let table = self.schema.table(&target.table).ok_or_else(|| {
12805                invalid(format!(
12806                    "local purge names unknown table {:?}",
12807                    target.table
12808                ))
12809            })?;
12810            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
12811                return Err(invalid(format!(
12812                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
12813                    target.table
12814                )));
12815            }
12816            let mut selectors = Vec::with_capacity(target.selectors.len());
12817            let mut canonical_selectors = BTreeMap::new();
12818            for (column_name, raw_values) in &target.selectors {
12819                let Some((column_index, column)) = table
12820                    .columns
12821                    .iter()
12822                    .enumerate()
12823                    .find(|(_, column)| column.name == *column_name)
12824                else {
12825                    return Err(invalid(format!(
12826                        "local purge target {:?} names unknown column {:?}",
12827                        target.table, column_name
12828                    )));
12829                };
12830                let encrypted = table
12831                    .encrypted_columns
12832                    .iter()
12833                    .any(|candidate| candidate.index == column_index);
12834                if column.ty != ColumnType::String || encrypted {
12835                    return Err(invalid(format!(
12836                        "local purge selector {:?}.{:?} must be a plaintext string column",
12837                        target.table, column_name
12838                    )));
12839                }
12840                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
12841                    return Err(invalid(format!(
12842                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
12843                        target.table, column_name
12844                    )));
12845                }
12846                let mut values = raw_values.clone();
12847                values.sort();
12848                values.dedup();
12849                if values.iter().any(|value| {
12850                    value.is_empty()
12851                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
12852                        || !is_local_operation_code_like(value)
12853                }) {
12854                    return Err(invalid(format!(
12855                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
12856                    )));
12857                }
12858                selectors.push((column_name.clone(), values.clone()));
12859                canonical_selectors.insert(column_name.clone(), values);
12860            }
12861            let canonical = LocalDataPurgeTarget {
12862                table: target.table.clone(),
12863                selectors: canonical_selectors,
12864            };
12865            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
12866            deduplicated.insert(
12867                key,
12868                (
12869                    CompiledLocalDataPurgeTarget {
12870                        table: target.table.clone(),
12871                        selectors,
12872                    },
12873                    canonical,
12874                ),
12875            );
12876        }
12877        let targets = deduplicated
12878            .values()
12879            .map(|(compiled, _)| compiled.clone())
12880            .collect::<Vec<_>>();
12881        let canonical = deduplicated
12882            .values()
12883            .map(|(_, target)| target.clone())
12884            .collect::<Vec<_>>();
12885        let canonical_plan =
12886            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
12887        Ok((targets, canonical_plan))
12888    }
12889
12890    fn local_purge_base_row_ids(
12891        &self,
12892        targets: &[CompiledLocalDataPurgeTarget],
12893    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
12894        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12895        for target in targets {
12896            let table = self
12897                .schema
12898                .table(&target.table)
12899                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
12900            let mut clauses = Vec::with_capacity(target.selectors.len());
12901            let mut params = Vec::new();
12902            for (column, values) in &target.selectors {
12903                clauses.push(format!(
12904                    "{} IN ({})",
12905                    quote_ident(column),
12906                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
12907                ));
12908                params.extend(values.iter().cloned().map(SqlValue::Text));
12909            }
12910            let sql = format!(
12911                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
12912                quote_ident(&table.primary_key),
12913                base_table(&target.table),
12914                clauses.join(" AND ")
12915            );
12916            let mut statement = self
12917                .conn
12918                .prepare(&sql)
12919                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12920            let rows = statement
12921                .query_map(rusqlite::params_from_iter(params), |row| {
12922                    let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
12923                    render_row_id_json(Some(&value))
12924                        .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
12925                })
12926                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12927            let ids = by_table.entry(target.table.clone()).or_default();
12928            for row in rows {
12929                ids.insert(
12930                    row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
12931                );
12932            }
12933        }
12934        Ok(by_table)
12935    }
12936
12937    fn local_purge_values_match(
12938        target: &CompiledLocalDataPurgeTarget,
12939        values: &Map<String, Value>,
12940    ) -> bool {
12941        target.selectors.iter().all(|(column, allowed)| {
12942            values
12943                .get(column)
12944                .and_then(Value::as_str)
12945                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
12946        })
12947    }
12948
12949    /// Apply one host-authorized local security purge. The host MUST gate the
12950    /// corresponding subscriptions first; this primitive owns local SQLite
12951    /// cleanup and never grants/revokes server authority by itself.
12952    pub fn purge_local_data(
12953        &mut self,
12954        input: &LocalDataPurgeInput,
12955    ) -> Result<LocalDataPurgeResult, String> {
12956        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
12957        self.cancel_sync_round();
12958        let meta_key = format!("localPurge:{}", input.purge_id);
12959        let applied_plan = self
12960            .conn
12961            .query_row(
12962                "SELECT value FROM _syncular_meta WHERE key = ?1",
12963                rusqlite::params![meta_key],
12964                |row| row.get::<_, String>(0),
12965            )
12966            .optional()
12967            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12968        if let Some(applied_plan) = applied_plan {
12969            if applied_plan != canonical_plan {
12970                return Err(format!(
12971                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
12972                    input.purge_id
12973                ));
12974            }
12975            return Ok(LocalDataPurgeResult {
12976                already_applied: true,
12977                purged_rows: 0,
12978                dropped_commits: 0,
12979            });
12980        }
12981
12982        // RFC 0005 D7: the container file cannot be a purge target (its name is
12983        // not in the running schema), so purge drops it as a fixed step, before
12984        // any mirror row is touched and unconditionally.
12985        let _ = self.drop_previous_version();
12986
12987        let prior_outbox = self.outbox.clone();
12988        let prior_failed = self.failed_commits.clone();
12989        let prior_rejections = self.rejections.clone();
12990        let prior_conflicts = self.conflicts.clone();
12991        let prior_overlay_dirty = self.overlay_dirty.snapshot();
12992        self.begin_observation("syncular_local_purge")?;
12993        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
12994            let row_ids = self.local_purge_base_row_ids(&targets)?;
12995            let retained_dropped = self.drop_failed_commits(|table, values| {
12996                targets.iter().any(|target| {
12997                    target.table == table && Self::local_purge_values_match(target, values)
12998                })
12999            })?;
13000            let doomed = self
13001                .outbox
13002                .iter()
13003                .filter(|commit| {
13004                    commit.ops.iter().any(|operation| {
13005                        let matching_targets = targets
13006                            .iter()
13007                            .filter(|target| target.table == operation.table)
13008                            .collect::<Vec<_>>();
13009                        if matching_targets.is_empty() {
13010                            return false;
13011                        }
13012                        if row_ids
13013                            .get(&operation.table)
13014                            .is_some_and(|ids| ids.contains(&operation.row_id))
13015                        {
13016                            return true;
13017                        }
13018                        operation.values.as_ref().is_some_and(|values| {
13019                            matching_targets
13020                                .iter()
13021                                .any(|target| Self::local_purge_values_match(target, values))
13022                        })
13023                    })
13024                })
13025                .cloned()
13026                .collect::<Vec<_>>();
13027            let doomed_ids = doomed
13028                .iter()
13029                .map(|commit| commit.client_commit_id.clone())
13030                .collect::<BTreeSet<_>>();
13031            let mut batch = ChangeAccumulator::default();
13032            for commit in &retained_dropped {
13033                batch.conflicts = true;
13034                batch.rejections = true;
13035                batch.outcomes = true;
13036                for operation in &commit.ops {
13037                    batch.table(&operation.table);
13038                }
13039            }
13040            let mut rejections = Vec::new();
13041            for commit in &doomed {
13042                for operation in &commit.ops {
13043                    batch.table(&operation.table);
13044                }
13045                let results = commit
13046                    .ops
13047                    .iter()
13048                    .enumerate()
13049                    .map(|(op_index, operation)| {
13050                        let rejection = RejectionRecord {
13051                            client_commit_id: commit.client_commit_id.clone(),
13052                            op_index: op_index as i32,
13053                            code: "client.local_data_purged".to_owned(),
13054                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
13055                            retryable: false,
13056                            details: None,
13057                            operation: Some(CommitOperation::from(operation)),
13058                        };
13059                        rejections.push(rejection.clone());
13060                        CommitOperationOutcome::Error { rejection }
13061                    })
13062                    .collect::<Vec<_>>();
13063                self.persist_commit_outcome(
13064                    &commit.client_commit_id,
13065                    CommitOutcomeStatus::Rejected,
13066                    &results,
13067                    Some(&commit.ops),
13068                )?;
13069                self.delete_outbox_persisted(&commit.client_commit_id)?;
13070            }
13071            if !doomed.is_empty() {
13072                self.outbox
13073                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
13074                self.rejections.extend(rejections);
13075                self.prune_commit_outcomes()?;
13076                batch.status = true;
13077                batch.rejections = true;
13078                batch.outcomes = true;
13079            }
13080
13081            let mut purged_rows = 0usize;
13082            for (table, ids) in &row_ids {
13083                if ids.is_empty() {
13084                    continue;
13085                }
13086                batch.table(table);
13087                for row_id in ids {
13088                    self.delete_base_row(table, row_id)?;
13089                    purged_rows += 1;
13090                }
13091            }
13092            self.rebuild_overlay_if_dirty();
13093            self.delete_unreferenced_cached_blobs();
13094            self.conn
13095                .execute(
13096                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
13097                    rusqlite::params![meta_key, canonical_plan],
13098                )
13099                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13100            Ok((
13101                batch,
13102                LocalDataPurgeResult {
13103                    already_applied: false,
13104                    purged_rows,
13105                    dropped_commits: doomed.len(),
13106                },
13107            ))
13108        })();
13109        let (batch, result) = match applied {
13110            Ok(value) => value,
13111            Err(error) => {
13112                self.rollback_observation("syncular_local_purge");
13113                self.outbox = prior_outbox;
13114                self.failed_commits = prior_failed;
13115                self.rejections = prior_rejections;
13116                self.conflicts = prior_conflicts;
13117                self.overlay_dirty.restore(prior_overlay_dirty.clone());
13118                return Err(error);
13119            }
13120        };
13121        if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
13122            self.rollback_observation("syncular_local_purge");
13123            self.outbox = prior_outbox;
13124            self.failed_commits = prior_failed;
13125            self.rejections = prior_rejections;
13126            self.conflicts = prior_conflicts;
13127            self.overlay_dirty.restore(prior_overlay_dirty.clone());
13128            return Err(error);
13129        }
13130        Ok(result)
13131    }
13132
13133    /// Application-authorized recovery of the replicated projection. Unlike a
13134    /// local security purge, this retains the entire outbox, device identity,
13135    /// lease, outcomes, subscription registrations, and protected bookkeeping.
13136    /// The projection reset, subscription rewind, optimistic replay, and
13137    /// idempotency marker share one savepoint for interruption safety.
13138    pub fn rebootstrap_local_data(
13139        &mut self,
13140        input: &LocalDataRebootstrapInput,
13141    ) -> Result<LocalDataRebootstrapResult, String> {
13142        if input.rebootstrap_id.is_empty()
13143            || input.rebootstrap_id.len() > 128
13144            || !is_local_operation_code_like(&input.rebootstrap_id)
13145        {
13146            return Err(
13147                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
13148                    .to_owned(),
13149            );
13150        }
13151        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
13152        if let Some(persisted) = self.get_meta_strict(&meta_key)? {
13153            let (retained_commits, reset_subscriptions) =
13154                decode_local_rebootstrap_receipt(&persisted)?;
13155            return Ok(LocalDataRebootstrapResult {
13156                already_applied: true,
13157                retained_commits,
13158                reset_subscriptions,
13159            });
13160        }
13161        if self.stopped || self.schema_floor.is_some() {
13162            return Err(
13163                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
13164                    .to_owned(),
13165            );
13166        }
13167
13168        let retained_commits = self.outbox.len();
13169        let reset_subscriptions = self.subs.len();
13170        let prior_subs = self.subs.clone();
13171        let prior_upgrading = self.upgrading;
13172        let prior_stopped = self.stopped;
13173        let prior_schema_floor = self.schema_floor.clone();
13174        let prior_overlay_dirty = self.overlay_dirty.snapshot();
13175        let prior_sync_needed = self.sync_needed;
13176        let prior_sync_intents = self.sync_intent_queue.clone();
13177        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
13178
13179        self.cancel_sync_round();
13180        self.begin_observation("syncular_local_rebootstrap")?;
13181        let mut batch = ChangeAccumulator::default();
13182        let applied = (|| -> Result<(), String> {
13183            self.run_schema_reset_observed(&mut batch, false, false)?;
13184            self.conn
13185                .execute(
13186                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
13187                    rusqlite::params![meta_key, receipt],
13188                )
13189                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13190            self.sync_needed = true;
13191            self.sync_intent_queue.push_back(SyncIntent::Interactive);
13192            batch.status = true;
13193            Ok(())
13194        })();
13195        if let Err(error) = applied {
13196            self.rollback_observation("syncular_local_rebootstrap");
13197            self.subs = prior_subs;
13198            self.upgrading = prior_upgrading;
13199            self.stopped = prior_stopped;
13200            self.schema_floor = prior_schema_floor;
13201            self.overlay_dirty.restore(prior_overlay_dirty.clone());
13202            self.sync_needed = prior_sync_needed;
13203            self.sync_intent_queue = prior_sync_intents;
13204            return Err(error);
13205        }
13206        if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
13207            self.rollback_observation("syncular_local_rebootstrap");
13208            self.subs = prior_subs;
13209            self.upgrading = prior_upgrading;
13210            self.stopped = prior_stopped;
13211            self.schema_floor = prior_schema_floor;
13212            self.overlay_dirty.restore(prior_overlay_dirty.clone());
13213            self.sync_needed = prior_sync_needed;
13214            self.sync_intent_queue = prior_sync_intents;
13215            return Err(error);
13216        }
13217
13218        Ok(LocalDataRebootstrapResult {
13219            already_applied: false,
13220            retained_commits,
13221            reset_subscriptions,
13222        })
13223    }
13224
13225    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
13226
13227    /// Delete base rows matching the effective scopes; `Err(())` = no local
13228    /// scope-column mapping for a key (the fail-closed case).
13229    fn purge_scope_rows(
13230        &mut self,
13231        table_name: &str,
13232        effective: &[(String, Vec<String>)],
13233    ) -> Result<(), ()> {
13234        if effective.is_empty() {
13235            return Ok(());
13236        }
13237        let table = self.schema.table(table_name).ok_or(())?.clone();
13238        let mut clauses = Vec::new();
13239        let mut params: Vec<SqlValue> = Vec::new();
13240        for (variable, values) in effective {
13241            let column = table.scope_column(variable).ok_or(())?;
13242            let placeholders: Vec<String> = values
13243                .iter()
13244                .map(|v| {
13245                    params.push(SqlValue::Text(v.clone()));
13246                    "?".to_owned()
13247                })
13248                .collect();
13249            clauses.push(format!(
13250                "{} IN ({})",
13251                quote_ident(column),
13252                placeholders.join(", ")
13253            ));
13254        }
13255        let sql = format!(
13256            "DELETE FROM {} WHERE {}",
13257            base_table(table_name),
13258            clauses.join(" AND ")
13259        );
13260        self.overlay_dirty.table(table_name);
13261        self.conn
13262            .execute(&sql, rusqlite::params_from_iter(&params))
13263            .map_err(|_| ())?;
13264        self.conn
13265            .execute(
13266                &format!(
13267                    "DELETE FROM {} WHERE {}",
13268                    visible_table(table_name),
13269                    clauses.join(" AND ")
13270                ),
13271                rusqlite::params_from_iter(&params),
13272            )
13273            .map_err(|_| ())?;
13274        Ok(())
13275    }
13276
13277    /// §3.3: drop pending commits whose upserts provably land in the
13278    /// revoked effective scopes — whole-commit, never per-operation.
13279    fn drop_doomed_outbox(
13280        &mut self,
13281        table_name: &str,
13282        effective: &[(String, Vec<String>)],
13283    ) -> Result<bool, String> {
13284        if effective.is_empty() {
13285            return Ok(false);
13286        }
13287        let Some(table) = self.schema.table(table_name).cloned() else {
13288            return Ok(false);
13289        };
13290        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
13291        for (variable, values) in effective {
13292            match table.scope_column(variable) {
13293                Some(column) => mappings.push((column, values)),
13294                None => return Ok(false), // not provable without a mapping
13295            }
13296        }
13297        let doomed: Vec<OutboxCommit> = self
13298            .outbox
13299            .iter()
13300            .filter(|commit| {
13301                commit.ops.iter().any(|op| {
13302                    op.upsert
13303                        && op.table == table_name
13304                        && op.values.as_ref().is_some_and(|values| {
13305                            mappings
13306                                .iter()
13307                                .all(|(column, allowed)| match values.get(*column) {
13308                                    Some(Value::String(s)) => allowed.contains(s),
13309                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
13310                                    _ => false,
13311                                })
13312                        })
13313                })
13314            })
13315            .cloned()
13316            .collect();
13317        if doomed.is_empty() {
13318            return Ok(false);
13319        }
13320        let mut rejections = Vec::new();
13321        for commit in &doomed {
13322            let results = commit
13323                .ops
13324                .iter()
13325                .enumerate()
13326                .map(|(op_index, operation)| {
13327                    let rejection = RejectionRecord {
13328                        client_commit_id: commit.client_commit_id.clone(),
13329                        op_index: op_index as i32,
13330                        code: "sync.scope_revoked".to_owned(),
13331                        message: "the commit was dropped because its effective scope was revoked"
13332                            .to_owned(),
13333                        retryable: false,
13334                        details: None,
13335                        operation: Some(CommitOperation::from(operation)),
13336                    };
13337                    rejections.push(rejection.clone());
13338                    CommitOperationOutcome::Error { rejection }
13339                })
13340                .collect::<Vec<_>>();
13341            self.persist_commit_outcome(
13342                &commit.client_commit_id,
13343                CommitOutcomeStatus::Rejected,
13344                &results,
13345                Some(&commit.ops),
13346            )?;
13347            self.delete_outbox_persisted(&commit.client_commit_id)?;
13348        }
13349        self.prune_commit_outcomes()?;
13350        let doomed_ids = doomed
13351            .iter()
13352            .map(|commit| commit.client_commit_id.as_str())
13353            .collect::<BTreeSet<_>>();
13354        self.outbox
13355            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
13356        self.rejections.extend(rejections);
13357        Ok(true)
13358    }
13359
13360    // -- blobs (§5.9) ----------------------------------------------------------------
13361
13362    /// §5.9.7: hash bytes into the content address, cache them, queue the
13363    /// upload (flushed before the next push, B4). Returns the canonical
13364    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
13365    /// column value.
13366    pub fn upload_blob(
13367        &mut self,
13368        bytes: &[u8],
13369        media_type: Option<String>,
13370        name: Option<String>,
13371    ) -> Result<Value, String> {
13372        let blob_id = blob_id_for(bytes);
13373        let now = self.clock_now_ms();
13374        let tx = self
13375            .conn
13376            .transaction()
13377            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13378        tx.execute(
13379            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
13380             ON CONFLICT(blob_id) DO NOTHING",
13381            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
13382        )
13383        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13384        tx.execute(
13385            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
13386            rusqlite::params![blob_id, media_type, now],
13387        )
13388        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13389        tx.commit()
13390            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13391        self.enforce_blob_cache_cap();
13392        let mut obj = Map::new();
13393        obj.insert("blobId".to_owned(), Value::from(blob_id));
13394        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
13395        if let Some(mt) = media_type {
13396            obj.insert("mediaType".to_owned(), Value::from(mt));
13397        }
13398        if let Some(n) = name {
13399            obj.insert("name".to_owned(), Value::from(n));
13400        }
13401        Ok(Value::Object(obj))
13402    }
13403
13404    /// §5.9.7: resolve an owned blob body without driver encoding. A
13405    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
13406    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
13407    pub fn fetch_blob_bytes(
13408        &mut self,
13409        transport: &mut dyn Transport,
13410        blob_id_or_ref: &str,
13411    ) -> Result<FetchedBlob, (String, String)> {
13412        let simple = |m: String| ("client.failed".to_owned(), m);
13413        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
13414            blob_id_or_ref.to_owned()
13415        } else {
13416            let value: Value = serde_json::from_str(blob_id_or_ref)
13417                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
13418            value
13419                .get("blobId")
13420                .and_then(Value::as_str)
13421                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
13422                .to_owned()
13423        };
13424        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
13425            return Ok(cached);
13426        }
13427        if !self.transport_enabled {
13428            return Err(("sync.offline".into(), "transport gate is closed".into()));
13429        }
13430        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
13431        // blob.not_found) verbatim so the harness can assert on it. The
13432        // authorized endpoint serves bytes inline OR (always-issue, presign
13433        // configured) a signed url the client fetches directly — no host auth,
13434        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
13435        #[cfg(feature = "bench-internals")]
13436        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
13437        let bytes = match transport
13438            .blob_download(&blob_id)
13439            .map_err(|e| (e.code, e.message))?
13440        {
13441            BlobDownload::Bytes(bytes) => bytes,
13442            BlobDownload::Url {
13443                url,
13444                url_expires_at_ms,
13445            } => {
13446                // §5.9.5: MUST NOT start a fetch at/past expiry.
13447                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
13448                    return Err((
13449                        "sync.segment_expired".to_owned(),
13450                        format!(
13451                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
13452                        ),
13453                    ));
13454                }
13455                transport
13456                    .fetch_blob_url(&url)
13457                    .map_err(|e| (e.code, e.message))?
13458            }
13459        };
13460        #[cfg(feature = "bench-internals")]
13461        drop(download_phase);
13462        #[cfg(feature = "bench-internals")]
13463        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
13464        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
13465        if blob_id_for(&bytes) != blob_id {
13466            return Err(simple(format!(
13467                "blob content address mismatch for {blob_id}"
13468            )));
13469        }
13470        #[cfg(feature = "bench-internals")]
13471        drop(validate_phase);
13472        #[cfg(feature = "bench-internals")]
13473        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
13474        let now = self.clock_now_ms();
13475        self.conn
13476            .execute(
13477                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
13478                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
13479            )
13480            .map_err(|e| simple(e.to_string()))?;
13481        self.conn
13482            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
13483            .map_err(|e| simple(e.to_string()))?;
13484        self.enforce_blob_cache_cap();
13485        #[cfg(feature = "bench-internals")]
13486        drop(insert_phase);
13487        self.get_cached_blob_bytes(&blob_id)
13488            .map_err(simple)?
13489            .ok_or_else(|| simple("blob cache write failed".to_owned()))
13490    }
13491
13492    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
13493        #[cfg(feature = "bench-internals")]
13494        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
13495        let mut stmt = self
13496            .conn
13497            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
13498            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13499        let mut rows = stmt
13500            .query(rusqlite::params![blob_id])
13501            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13502        if let Some(row) = rows
13503            .next()
13504            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13505        {
13506            let bytes: Vec<u8> = row
13507                .get(0)
13508                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13509            let byte_length: i64 = row
13510                .get(1)
13511                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13512            let media_type: Option<String> = row
13513                .get(2)
13514                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13515            return Ok(Some(FetchedBlob {
13516                blob_id: blob_id.to_owned(),
13517                byte_length,
13518                bytes,
13519                media_type,
13520            }));
13521        }
13522        Ok(None)
13523    }
13524
13525    /// §5.9.7 B4: upload every queued blob before push.
13526    fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
13527        let pending: Vec<(String, Option<String>)> = {
13528            let mut stmt = self
13529                .conn
13530                .prepare(
13531                    "SELECT blob_id, media_type FROM (
13532                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
13533                      UNION ALL
13534                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
13535                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
13536                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
13537                    ) ORDER BY created_at_ms, blob_id",
13538                )
13539                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13540            let rows = stmt
13541                .query_map([], |row| {
13542                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
13543                })
13544                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13545            rows.collect::<Result<_, _>>()
13546                .map_err(|error| match error {
13547                    rusqlite::Error::InvalidColumnType(..)
13548                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
13549                        "sync.local_corrupt",
13550                        "Pending blob upload metadata is invalid",
13551                    ),
13552                    _ => TransportError::new("client.failed", error.to_string()),
13553                })?
13554        };
13555        let mut uploads = Vec::new();
13556        for (blob_id, media_type) in pending {
13557            let bytes = (|| {
13558                let stored: Option<(SqlValue, SqlValue)> = self
13559                    .conn
13560                    .query_row(
13561                        "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
13562                        rusqlite::params![blob_id],
13563                        |row| Ok((row.get(0)?, row.get(1)?)),
13564                    )
13565                    .optional()
13566                    .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13567                let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
13568                    return Err(TransportError::new(
13569                        "sync.local_corrupt",
13570                        "Pending blob upload body is missing or corrupt",
13571                    ));
13572                };
13573                if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
13574                    return Err(TransportError::new(
13575                        "sync.local_corrupt",
13576                        "Pending blob upload body is missing or corrupt",
13577                    ));
13578                }
13579                Ok(bytes)
13580            })();
13581            let failed = bytes.is_err();
13582            uploads.push(crate::round::PendingUpload {
13583                id: blob_id,
13584                bytes,
13585                media_type,
13586            });
13587            if failed {
13588                break;
13589            }
13590        }
13591        Ok(uploads)
13592    }
13593
13594    #[cfg(test)]
13595    fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
13596        for upload in self.pending_blob_uploads()? {
13597            self.upload_one(
13598                transport,
13599                &upload.id,
13600                &upload.bytes?,
13601                upload.media_type.as_deref(),
13602            )?;
13603            self.conn
13604                .execute(
13605                    "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
13606                    rusqlite::params![upload.id],
13607                )
13608                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13609        }
13610        Ok(())
13611    }
13612
13613    /// §5.9.3: upload one blob, preferring the presigned direct-to-storage
13614    /// grant when the transport supports it, else streaming through the direct
13615    /// host-authenticated endpoint (capability, not fallback). A `Url` grant
13616    /// PUTs direct with no host auth; on a grant PUT failure the client streams
13617    /// through the direct endpoint — a *different, host-authenticated
13618    /// capability*, not a fall-through of the grant's authority.
13619    #[cfg(test)]
13620    fn upload_one(
13621        &self,
13622        transport: &mut dyn Transport,
13623        blob_id: &str,
13624        bytes: &[u8],
13625        media_type: Option<&str>,
13626    ) -> Result<(), TransportError> {
13627        crate::round::upload_one(transport, blob_id, bytes, media_type, self.now_ms)
13628    }
13629
13630    fn visible_blob_ids_sql(&self) -> String {
13631        let mut selects = Vec::new();
13632        for table in &self.schema.tables {
13633            for column in table
13634                .columns
13635                .iter()
13636                .filter(|column| column.ty == ColumnType::BlobRef)
13637            {
13638                let identifier = quote_ident(&column.name);
13639                selects.push(format!(
13640                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
13641                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
13642                     AND json_type({identifier}, '$.blobId') = 'text'",
13643                    quote_ident(&table.name),
13644                ));
13645            }
13646        }
13647        if selects.is_empty() {
13648            "SELECT NULL AS blob_id WHERE 0".to_owned()
13649        } else {
13650            selects.join(" UNION ")
13651        }
13652    }
13653
13654    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
13655    /// staged uploads pin bodies directly; no derived refcount is stored.
13656    fn enforce_blob_cache_cap(&self) {
13657        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
13658            return;
13659        };
13660        let mut total: i64 = self
13661            .conn
13662            .query_row(
13663                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
13664                [],
13665                |row| row.get(0),
13666            )
13667            .unwrap_or(0);
13668        if total <= max_bytes {
13669            return;
13670        }
13671        let query = format!(
13672            "SELECT blob_id, byte_length FROM _syncular_blobs
13673             WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
13674               AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
13675               AND blob_id NOT IN ({})
13676             ORDER BY created_at_ms ASC, blob_id ASC",
13677            self.visible_blob_ids_sql(),
13678        );
13679        let candidates: Vec<(String, i64)> = {
13680            let Ok(mut stmt) = self.conn.prepare(&query) else {
13681                return;
13682            };
13683            let Ok(rows) = stmt.query_map([], |row| {
13684                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
13685            }) else {
13686                return;
13687            };
13688            rows.filter_map(Result::ok).collect()
13689        };
13690        for (blob_id, byte_length) in candidates {
13691            if total <= max_bytes {
13692                break;
13693            }
13694            let _ = self.conn.execute(
13695                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
13696                rusqlite::params![blob_id],
13697            );
13698            total -= byte_length;
13699        }
13700    }
13701
13702    fn delete_unreferenced_cached_blobs(&mut self) {
13703        #[cfg(feature = "bench-internals")]
13704        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
13705        if !self.schema_has_blobs() {
13706            return;
13707        }
13708        self.rebuild_overlay_if_dirty();
13709        let _ = self.conn.execute(
13710            &format!(
13711                "DELETE FROM _syncular_blobs
13712                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
13713                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
13714                   AND blob_id NOT IN ({})",
13715                self.visible_blob_ids_sql(),
13716            ),
13717            [],
13718        );
13719    }
13720
13721    // -- local row storage ----------------------------------------------------------
13722
13723    /// The cached per-table primary-key upsert SQL for `full_table` (see
13724    /// the `insert_sql` field: built once, reused per row).
13725    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
13726        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
13727            return sql.clone();
13728        }
13729        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
13730        columns.push(quote_ident("_syncular_version"));
13731        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
13732        let primary_key = quote_ident(&table.primary_key);
13733        let updates = columns
13734            .iter()
13735            .filter(|column| **column != primary_key)
13736            .map(|column| format!("{column}=excluded.{column}"))
13737            .collect::<Vec<_>>()
13738            .join(", ");
13739        let sql = format!(
13740            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
13741            columns.join(", "),
13742            placeholders.join(", ")
13743        );
13744        self.insert_sql
13745            .borrow_mut()
13746            .insert(full_table.to_owned(), sql.clone());
13747        sql
13748    }
13749
13750    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
13751        self.overlay_dirty.table(table_name);
13752        self.write_row(&base_table(table_name), table_name, row, version)
13753    }
13754
13755    fn write_row(
13756        &self,
13757        full_table: &str,
13758        table_name: &str,
13759        row: &Row,
13760        version: i64,
13761    ) -> Result<(), String> {
13762        #[cfg(feature = "bench-internals")]
13763        let _phase = self.benchmark_phases.start(Phase::RowWrite);
13764        let table = self
13765            .schema
13766            .table(table_name)
13767            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
13768        let sql = self.insert_row_sql(full_table, table);
13769        let mut stmt = self
13770            .conn
13771            .prepare_cached(&sql)
13772            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13773        let params = row
13774            .iter()
13775            .map(RowParam::Cell)
13776            .chain(std::iter::once(RowParam::Version(version)));
13777        stmt.execute(rusqlite::params_from_iter(params))
13778            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13779        Ok(())
13780    }
13781
13782    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
13783        let table = self
13784            .schema
13785            .table(table_name)
13786            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
13787        self.overlay_dirty.table(table_name);
13788        let sql = format!(
13789            "DELETE FROM {} WHERE {}",
13790            base_table(table_name),
13791            row_id_predicate(table)
13792        );
13793        let mut stmt = self
13794            .conn
13795            .prepare_cached(&sql)
13796            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13797        stmt.execute(rusqlite::params![row_id])
13798            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13799        Ok(())
13800    }
13801
13802    fn has_acknowledged_rows(&self) -> bool {
13803        self.conn
13804            .query_row(
13805                "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
13806                [],
13807                |row| row.get(0),
13808            )
13809            .expect("read acknowledged intent")
13810    }
13811
13812    fn acknowledged_operations(
13813        &self,
13814        rows: Option<&HashSet<(&str, &str)>>,
13815        tables: Option<&BTreeSet<&str>>,
13816    ) -> Result<Vec<OutboxOp>, String> {
13817        let keys = rows.map(|rows| {
13818            serde_json::to_string(
13819                &rows
13820                    .iter()
13821                    .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
13822                    .collect::<Vec<_>>(),
13823            )
13824            .expect("row keys")
13825        });
13826        let mut statement = self
13827            .conn
13828            .prepare("SELECT op_json,intent_json FROM _syncular_acked_rows WHERE (?1 IS NULL OR json_array(tbl,id) IN(SELECT value FROM json_each(?1))) AND (?2 IS NULL OR tbl IN(SELECT value FROM json_each(?2))) ORDER BY commit_seq,idx")
13829            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13830        let rows = statement
13831            .query_map(
13832                rusqlite::params![
13833                    keys,
13834                    tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
13835                ],
13836                |row| {
13837                    #[cfg(test)]
13838                    self.acknowledged_replay_count
13839                        .set(self.acknowledged_replay_count.get() + 1);
13840                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
13841                },
13842            )
13843            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13844        rows.map(|row| {
13845            let (op, intent) =
13846                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13847            let op: CommitOperation =
13848                serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
13849            let table = self
13850                .schema
13851                .table(&op.table)
13852                .ok_or_else(|| "sync.unknown_table".to_owned())?;
13853            let values = if op.op == "upsert" && self.visible_row(table, &op.row_id).is_none() {
13854                Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
13855            } else {
13856                op.values
13857            };
13858            Ok(OutboxOp {
13859                table: op.table,
13860                row_id: op.row_id,
13861                upsert: op.op == "upsert",
13862                base_version: op.base_version,
13863                values,
13864            })
13865        })
13866        .collect()
13867    }
13868
13869    fn drop_acknowledged_rows(
13870        &self,
13871        matches: impl Fn(&str, &Map<String, Value>) -> bool,
13872    ) -> Result<Vec<OutboxCommit>, String> {
13873        let mut statement = self
13874            .conn
13875            .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
13876            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13877        let rows = statement
13878            .query_map([], |row| {
13879                Ok((
13880                    row.get::<_, String>(0)?,
13881                    row.get::<_, String>(1)?,
13882                    row.get::<_, String>(2)?,
13883                    row.get::<_, String>(3)?,
13884                ))
13885            })
13886            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13887        let mut doomed = HashSet::new();
13888        for row in rows {
13889            let (id, table, intent, row_id) =
13890                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13891            let values =
13892                serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
13893            let base_matches = self
13894                .schema
13895                .table(&table)
13896                .and_then(|schema| self.stored_row(schema, &row_id, true))
13897                .is_some_and(|(base, _)| {
13898                    let schema = self.schema.table(&table).expect("acknowledged table");
13899                    let values = schema
13900                        .columns
13901                        .iter()
13902                        .zip(base.iter())
13903                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
13904                        .collect();
13905                    matches(&table, &values)
13906                });
13907            if matches(&table, &values) || base_matches {
13908                doomed.insert(id);
13909            }
13910        }
13911        let mut dropped = Vec::new();
13912        for id in doomed {
13913            let mut statement = self
13914                .conn
13915                .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
13916                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13917            let operations = statement
13918                .query_map([&id], |row| row.get::<_, String>(0))
13919                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13920            let mut ops = Vec::new();
13921            for operation in operations {
13922                let json = operation
13923                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13924                let op: CommitOperation =
13925                    serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
13926                self.overlay_dirty.table(&op.table);
13927                ops.push(OutboxOp {
13928                    table: op.table,
13929                    row_id: op.row_id,
13930                    upsert: op.op == "upsert",
13931                    base_version: op.base_version,
13932                    values: op.values,
13933                });
13934            }
13935            dropped.push(OutboxCommit {
13936                client_commit_id: id.clone(),
13937                ops,
13938            });
13939            self.conn
13940                .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
13941                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13942        }
13943        Ok(dropped)
13944    }
13945
13946    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
13947    /// the outbox changed since the last rebuild (the no-op sync round).
13948    fn rebuild_overlay_if_dirty(&mut self) {
13949        if self.overlay_dirty.get() {
13950            self.rebuild_overlay();
13951        }
13952    }
13953
13954    /// §7.1: local reads see outbox state applied optimistically — rebuild
13955    /// every visible table as (base server state) + (pending outbox replay
13956    /// on top). Optimistic rows carry version `-1`.
13957    fn rebuild_overlay(&mut self) {
13958        #[cfg(feature = "bench-internals")]
13959        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
13960        #[cfg(test)]
13961        self.overlay_rebuild_count
13962            .set(self.overlay_rebuild_count.get() + 1);
13963        let dirty = self.overlay_dirty.snapshot();
13964        let tables: Vec<_> = self
13965            .schema
13966            .tables
13967            .iter()
13968            .filter(|table| {
13969                dirty
13970                    .as_ref()
13971                    .is_none_or(|names| names.is_empty() || names.contains(&table.name))
13972            })
13973            .cloned()
13974            .collect();
13975        let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
13976        self.exec("SAVEPOINT syncular_overlay");
13977        for table in &tables {
13978            for index in &table.fts_indexes {
13979                let _ = self.drop_fts_triggers(index);
13980            }
13981            let visible = visible_table(&table.name);
13982            let base = base_table(&table.name);
13983            self.exec(&format!("DELETE FROM {visible}"));
13984            self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
13985        }
13986        let acknowledged = self
13987            .acknowledged_operations(None, Some(&names))
13988            .expect("decode acknowledged intent");
13989        self.apply_outbox_ops(
13990            acknowledged
13991                .iter()
13992                .filter(|op| names.contains(op.table.as_str())),
13993        );
13994        for (commit, initial) in &self.failed_commits {
13995            for operation in &commit.ops {
13996                if !names.contains(operation.table.as_str()) || !operation.upsert {
13997                    continue;
13998                }
13999                if let Some(table) = self.schema.table(&operation.table) {
14000                    if self.visible_row(table, &operation.row_id).is_none()
14001                        && operation.values.as_ref().is_some_and(|values| {
14002                            table
14003                                .columns
14004                                .iter()
14005                                .all(|column| values.contains_key(&column.name))
14006                        })
14007                    {
14008                        let key = serde_json::to_string(&(
14009                            operation.table.clone(),
14010                            operation.row_id.clone(),
14011                        ))
14012                        .expect("row key serialization");
14013                        if let Some(Value::Object(values)) = initial.get(&key) {
14014                            self.apply_outbox_ops(&[OutboxOp {
14015                                values: Some(values.clone()),
14016                                ..operation.clone()
14017                            }]);
14018                        }
14019                    }
14020                }
14021            }
14022            self.apply_outbox_ops(
14023                commit
14024                    .ops
14025                    .iter()
14026                    .filter(|op| names.contains(op.table.as_str())),
14027            );
14028        }
14029        for commit in &self.outbox {
14030            self.apply_outbox_ops(
14031                commit
14032                    .ops
14033                    .iter()
14034                    .filter(|op| names.contains(op.table.as_str())),
14035            );
14036        }
14037        for table in &tables {
14038            for index in &table.fts_indexes {
14039                let _ = self.rebuild_fts_projection(table, index);
14040                let _ = self.create_fts_triggers(table, index);
14041            }
14042        }
14043        self.exec("RELEASE syncular_overlay");
14044        self.overlay_dirty.set(false);
14045    }
14046
14047    /// §7.1 replay: every operation is applied over the current visible
14048    /// state. An operation that no longer applies (a server row now holds its
14049    /// unique value) stays pending and invisible until a later replay admits
14050    /// it or the server answers its push.
14051    fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
14052        #[cfg(feature = "bench-internals")]
14053        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
14054        for op in ops {
14055            #[cfg(feature = "bench-internals")]
14056            if let Some(phase) = &mut phase {
14057                phase.unit();
14058            }
14059            if op.upsert {
14060                if let (Some(table), Some(values)) = (
14061                    self.schema
14062                        .table(&op.table)
14063                        .filter(|table| table.indexes.iter().any(|index| index.unique)),
14064                    op.values.as_ref(),
14065                ) {
14066                    let mut intended: Map<String, Value> = self
14067                        .visible_row(table, &op.row_id)
14068                        .map(|(row, _)| {
14069                            table
14070                                .columns
14071                                .iter()
14072                                .zip(&row)
14073                                .map(|(column, value)| {
14074                                    (column.name.clone(), column_value_to_json(value))
14075                                })
14076                                .collect()
14077                        })
14078                        .unwrap_or_default();
14079                    intended.extend(values.clone());
14080                    if !self
14081                        .unique_conflicts(table, &intended, false)
14082                        .expect("overlay unique-key read failed")
14083                        .is_empty()
14084                    {
14085                        continue;
14086                    }
14087                }
14088            }
14089            let _ = self.apply_outbox_op(op);
14090        }
14091    }
14092
14093    fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
14094        let Some(table) = self.schema.table(&op.table) else {
14095            return Ok(());
14096        };
14097        let visible = visible_table(&table.name);
14098        if !op.upsert {
14099            let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
14100            self.conn
14101                .prepare_cached(&sql)
14102                .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
14103                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14104            return Ok(());
14105        }
14106        let Some(values) = op.values.as_ref() else {
14107            return Ok(());
14108        };
14109        // §7.1: the overlay applies the operation's PRESENT columns
14110        // over the current local row. A partial operation over an
14111        // absent local row leaves it absent — the server answers it
14112        // with sync.row_deleted or sync.row_missing (§6.2).
14113        let full = table
14114            .columns
14115            .iter()
14116            .all(|column| values.contains_key(&column.name));
14117        let local = self.visible_row(table, &op.row_id);
14118        if local.is_none() && !full {
14119            return Ok(());
14120        }
14121        let mut row: Row = Vec::with_capacity(table.columns.len());
14122        for (index, column) in table.columns.iter().enumerate() {
14123            let incoming = match values.get(&column.name) {
14124                Some(raw) => json_to_column_value(column, Some(raw)),
14125                None => Ok(local
14126                    .as_ref()
14127                    .and_then(|(values, _)| values.get(index).cloned())
14128                    .flatten()),
14129            };
14130            match incoming {
14131                Ok(value) => row.push(value),
14132                Err(_) => return Ok(()),
14133            }
14134        }
14135        let version = local.as_ref().map_or(-1, |(_, version)| *version);
14136        self.write_row(&visible, &table.name, &row, version)
14137            .map_err(|error| {
14138                if error.starts_with("UNIQUE constraint failed:") {
14139                    "sync.constraint_violation: local write violates a unique constraint".to_owned()
14140                } else {
14141                    error
14142                }
14143            })
14144    }
14145
14146    /// The visible (optimistic) row's values plus version, or `None` when the
14147    /// row is absent from the overlay.
14148    fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
14149        self.stored_row(table, row_id, false)
14150    }
14151
14152    fn stored_row(&self, table: &TableSchema, row_id: &str, base: bool) -> Option<(Row, i64)> {
14153        let sql = format!(
14154            "SELECT * FROM {} WHERE {}",
14155            if base {
14156                base_table(&table.name)
14157            } else {
14158                visible_table(&table.name)
14159            },
14160            row_id_predicate(table)
14161        );
14162        let mut stmt = self.conn.prepare_cached(&sql).ok()?;
14163        let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
14164        let row = rows.next().ok()??;
14165        let mut values = Vec::with_capacity(table.columns.len());
14166        for (index, column) in table.columns.iter().enumerate() {
14167            let raw = row.get_ref(index).ok()?;
14168            let json = sql_ref_to_json(column, raw);
14169            values.push(json_to_column_value(column, Some(&json)).ok()?);
14170        }
14171        let version: i64 = row.get(table.columns.len()).ok()?;
14172        Some((values, version))
14173    }
14174
14175    fn exec(&self, sql: &str) {
14176        let _ = self.conn.execute_batch(sql);
14177    }
14178
14179    // -- realtime (§8) ---------------------------------------------------------------
14180
14181    /// §8.8: the configured realtime policy.
14182    #[must_use]
14183    pub fn realtime_policy(&self) -> RealtimePolicy {
14184        self.realtime_policy
14185    }
14186
14187    /// §8.8: the explicit realtime availability state.
14188    #[must_use]
14189    pub fn realtime_state(&self) -> RealtimeState {
14190        self.realtime_state
14191    }
14192
14193    /// §8.8: set the realtime policy. Refused while the socket is connected:
14194    /// a policy change never silently re-routes an ownership state.
14195    pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
14196        if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
14197            return Err(
14198                "sync.invalid_request: disconnect realtime before changing the realtime policy"
14199                    .to_owned(),
14200            );
14201        }
14202        self.realtime_policy = policy;
14203        match policy {
14204            RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
14205            RealtimePolicy::Required | RealtimePolicy::Optional
14206                if self.realtime_state == RealtimeState::Disabled =>
14207            {
14208                self.set_realtime_state(RealtimeState::Disconnected, None);
14209            }
14210            RealtimePolicy::Required | RealtimePolicy::Optional => {}
14211        }
14212        Ok(())
14213    }
14214
14215    /// Record an availability transition, keeping the reason code bounded and
14216    /// code-like (§7.6) and clearing it where it cannot apply.
14217    fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
14218        self.realtime_state = state;
14219        self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
14220        if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
14221            self.realtime_reason_code = None;
14222            self.realtime_retry_delay_ms = None;
14223        }
14224    }
14225
14226    #[must_use]
14227    fn realtime_connected(&self) -> bool {
14228        self.realtime_state == RealtimeState::Connected
14229    }
14230
14231    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
14232        if !self.transport_enabled {
14233            return Err("sync.offline: transport gate is closed".into());
14234        }
14235        if self.realtime_policy == RealtimePolicy::Off {
14236            return Err(
14237                "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
14238            );
14239        }
14240        if self.realtime_connected() {
14241            return Ok(());
14242        }
14243        self.realtime_state = RealtimeState::Connecting;
14244        match transport.realtime_connect_for_client(&self.client_id) {
14245            Ok(()) => {
14246                self.set_realtime_state(RealtimeState::Connected, None);
14247                Ok(())
14248            }
14249            Err(error) => {
14250                self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
14251                Err(format!("{}: {}", error.code, error.message))
14252            }
14253        }
14254    }
14255
14256    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
14257        self.cancel_sync_round();
14258        // A deliberately disconnected or policy-disabled client has nothing
14259        // to release; a `lost` socket still does (the host owns the handle).
14260        if self.realtime_state == RealtimeState::Disconnected
14261            || self.realtime_state == RealtimeState::Disabled
14262        {
14263            return;
14264        }
14265        let _ = transport.realtime_close();
14266        self.set_realtime_state(
14267            if self.realtime_policy == RealtimePolicy::Off {
14268                RealtimeState::Disabled
14269            } else {
14270                RealtimeState::Disconnected
14271            },
14272            None,
14273        );
14274        self.presence.clear(); // §8.6.1: presence is per-connection
14275    }
14276
14277    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
14278    /// document for `scope_key`. Requires a live socket; the document is
14279    /// ephemeral (lost on disconnect). Authorization is the connection's
14280    /// registration (§8.6.3) — an unheld key is rejected loudly by the
14281    /// server with `presence.forbidden`.
14282    pub fn set_presence(
14283        &mut self,
14284        transport: &mut dyn Transport,
14285        scope_key: &str,
14286        doc: Option<&Value>,
14287    ) -> Result<(), String> {
14288        if !self.transport_enabled {
14289            return Err("sync.offline: transport gate is closed".into());
14290        }
14291        if !self.realtime_connected() {
14292            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
14293        }
14294        let text = encode_presence_publish(scope_key, doc);
14295        transport
14296            .realtime_send(&text)
14297            .map_err(|e| format!("{}: {}", e.code, e.message))
14298    }
14299
14300    /// §8.6: the peers currently present on a scope key (ephemeral).
14301    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
14302        self.presence
14303            .get(scope_key)
14304            .map(|peers| peers.values().cloned().collect())
14305            .unwrap_or_default()
14306    }
14307
14308    /// §8.6 apply an inbound presence fanout to the local map.
14309    fn apply_presence(
14310        &mut self,
14311        scope_key: String,
14312        kind: Option<PresenceKind>,
14313        actor_id: Option<String>,
14314        client_id: Option<String>,
14315        doc: Option<Value>,
14316        error: Option<String>,
14317    ) {
14318        // The publisher-directed error variant is out-of-band; nothing to
14319        // record in the peer map.
14320        if error.is_some() {
14321            return;
14322        }
14323        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
14324            return;
14325        };
14326        let peer_key = format!("{actor_id} {client_id}");
14327        match kind {
14328            PresenceKind::Leave => {
14329                if let Some(peers) = self.presence.get_mut(&scope_key) {
14330                    peers.remove(&peer_key);
14331                    if peers.is_empty() {
14332                        self.presence.remove(&scope_key);
14333                    }
14334                }
14335            }
14336            _ => {
14337                let doc = match doc {
14338                    Some(Value::Object(_)) => doc.unwrap(),
14339                    _ => return,
14340                };
14341                self.presence.entry(scope_key).or_default().insert(
14342                    peer_key,
14343                    PresencePeer {
14344                        actor_id,
14345                        client_id,
14346                        doc,
14347                    },
14348                );
14349            }
14350        }
14351    }
14352
14353    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
14354    pub fn on_realtime_text(&mut self, text: &str) {
14355        match parse_control(text) {
14356            Ok(ControlMessage::Hello { requires_sync, .. }) => {
14357                if requires_sync {
14358                    // §8.1: pull before trusting the socket for continuity.
14359                    self.set_sync_needed(true, true);
14360                }
14361            }
14362            Ok(ControlMessage::Presence {
14363                scope_key,
14364                kind,
14365                actor_id,
14366                client_id,
14367                doc,
14368                error,
14369                ..
14370            }) => {
14371                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
14372            }
14373            Ok(ControlMessage::Wake { .. }) => {
14374                // §8.3: any wake-up means "run a pull soon", never data.
14375                self.set_sync_needed(true, true);
14376            }
14377            _ => {}
14378        }
14379    }
14380
14381    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
14382    /// a pull response per section; an unapplied delta is a wake-up.
14383    /// Mailbox hosts send durable acknowledgements on their I/O executor.
14384    pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
14385        let mut transport = crate::round::DownloadResults::default();
14386        self.on_realtime_binary(&mut transport, bytes);
14387        transport.controls
14388    }
14389
14390    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
14391        if self.stopped || !self.transport_enabled {
14392            return;
14393        }
14394        #[cfg(feature = "bench-internals")]
14395        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
14396        let message = match decode_message(bytes) {
14397            Ok(m) if m.msg_kind == MsgKind::Response => m,
14398            _ => {
14399                self.set_sync_needed(true, true);
14400                return;
14401            }
14402        };
14403        #[cfg(feature = "bench-internals")]
14404        drop(decode_phase);
14405        let mut frames = message.frames.into_iter();
14406        let mut applied_cursor: Option<i64> = None;
14407        let mut any_covered = false;
14408        let mut dropped = false;
14409        while let Some(frame) = frames.next() {
14410            let Frame::SubStart {
14411                id,
14412                status,
14413                effective_scopes,
14414                ..
14415            } = frame
14416            else {
14417                continue;
14418            };
14419            let mut body = Vec::new();
14420            let mut next_cursor: Option<i64> = None;
14421            for inner in frames.by_ref() {
14422                match inner {
14423                    Frame::SubEnd {
14424                        next_cursor: nc, ..
14425                    } => {
14426                        next_cursor = Some(nc);
14427                        break;
14428                    }
14429                    Frame::Unknown { .. } => {}
14430                    other => body.push(other),
14431                }
14432            }
14433            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
14434                dropped = true;
14435                continue;
14436            };
14437            let sub = &self.subs[sub_index];
14438            // §8.2: only locally active, not mid-bootstrap subscriptions
14439            // apply; skipped sections are repaired by the next pull.
14440            if status != SubStatus::Active
14441                || sub.state != SubState::Active
14442                || sub.bootstrap_state.is_some()
14443                || !sub.synced_once
14444            {
14445                dropped = true;
14446                continue;
14447            }
14448            if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
14449                // Idempotent redelivery of an already-covered window.
14450                any_covered = true;
14451                continue;
14452            }
14453            let previous_effective = self.subs[sub_index].effective.clone();
14454            let previous_cursor = self.subs[sub_index].cursor;
14455            self.subs[sub_index].effective = Some(effective_scopes);
14456            let mut failed = false;
14457            for inner in body {
14458                match inner {
14459                    Frame::Commit {
14460                        tables,
14461                        changes,
14462                        commit_seq,
14463                        ..
14464                    } => {
14465                        if self
14466                            .apply_commit_frame(&tables, &changes, Some(commit_seq))
14467                            .is_err()
14468                        {
14469                            failed = true;
14470                            break;
14471                        }
14472                    }
14473                    _ => {
14474                        failed = true;
14475                        break;
14476                    }
14477                }
14478            }
14479            let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
14480                self.apply_sub_end(sub_index, cursor, None)
14481                    .ok()
14482                    .map(|()| cursor)
14483            });
14484            let Some(next_cursor) = committed else {
14485                self.subs[sub_index].effective = previous_effective;
14486                self.subs[sub_index].cursor = previous_cursor;
14487                dropped = true;
14488                continue;
14489            };
14490            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
14491        }
14492        if let Some(cursor) = applied_cursor {
14493            // §8.2 ack point: the highest applied SUB_END.nextCursor.
14494            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
14495            let _ = transport.realtime_send(&ack);
14496        } else if !any_covered || dropped {
14497            // §8.2: a delta not applied at all is treated as a wake-up.
14498            self.set_sync_needed(true, true);
14499        }
14500    }
14501
14502    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
14503    /// cursor across active, non-bootstrapping subscriptions that have
14504    /// synced at least once. No such subscription, no ack.
14505    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
14506        if !self.realtime_connected() {
14507            return;
14508        }
14509        let floor = self
14510            .subs
14511            .iter()
14512            .filter(|s| {
14513                s.state == SubState::Active
14514                    && s.bootstrap_state.is_none()
14515                    && s.synced_once
14516                    && s.cursor >= 0
14517            })
14518            .map(|s| s.cursor)
14519            .min();
14520        if let Some(cursor) = floor {
14521            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
14522            let _ = transport.realtime_send(&ack);
14523        }
14524    }
14525}
14526
14527#[cfg(test)]
14528mod previous_version_wiring_tests {
14529    use super::*;
14530    use serde_json::{json, Value};
14531
14532    fn previous_version_schema(version: i32) -> Value {
14533        json!({
14534            "version": version,
14535            "tables": [{
14536                "name": "tasks",
14537                "primaryKey": "id",
14538                "columns": [
14539                    { "name": "id", "type": "string", "nullable": false },
14540                    { "name": "note", "type": "string", "nullable": true }
14541                ],
14542                "scopes": []
14543            }]
14544        })
14545    }
14546
14547    fn previous_version_limits() -> crate::api::ClientLimits {
14548        crate::api::ClientLimits {
14549            previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
14550                enabled: true,
14551                ..Default::default()
14552            }),
14553            ..Default::default()
14554        }
14555    }
14556
14557    /// The configuration the live client carries, for a direct capture call.
14558    fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
14559        previous_version_limits()
14560            .previous_version_context
14561            .expect("configured")
14562    }
14563
14564    /// Capture the container the way a bump would, so a read test starts from a
14565    /// container this client's own state will then decide about.
14566    fn recapture_previous_version(client: &SyncClient) {
14567        let replica_path = client
14568            .previous_version_replica_path()
14569            .expect("file-backed replica");
14570        let outcome = crate::previous_version::capture_previous_version_from_replica(
14571            &client.conn,
14572            &replica_path,
14573            2,
14574            2,
14575            &previous_version_config(),
14576            1,
14577        )
14578        .expect("capture");
14579        assert!(matches!(
14580            outcome,
14581            crate::previous_version::CaptureOutcome::Captured(_)
14582        ));
14583    }
14584
14585    fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
14586        Subscription {
14587            id: "wiring-sub".to_owned(),
14588            table: "tasks".to_owned(),
14589            requested: Vec::new(),
14590            params: None,
14591            cursor,
14592            bootstrap_state: bootstrap_state.map(str::to_owned),
14593            state,
14594            reason_code: None,
14595            effective: None,
14596            synced_once: false,
14597        }
14598    }
14599
14600    #[test]
14601    fn previous_version_read_reasons_come_from_live_client_state() {
14602        use crate::previous_version::{
14603            PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
14604        };
14605        let path = std::env::temp_dir()
14606            .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
14607            .to_string_lossy()
14608            .into_owned();
14609        let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
14610        {
14611            let mut setup = SyncClient::open_path(
14612                "wiring".to_owned(),
14613                &previous_version_schema(1),
14614                previous_version_limits(),
14615                &path,
14616            )
14617            .expect("v1 install");
14618            setup
14619                .mutate(vec![Mutation::Upsert {
14620                    table: "tasks".to_owned(),
14621                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
14622                    base_version: None,
14623                }])
14624                .expect("seed a row");
14625        }
14626        let mut client = SyncClient::open_path(
14627            "wiring".to_owned(),
14628            &previous_version_schema(2),
14629            previous_version_limits(),
14630            &path,
14631        )
14632        .expect("v2 bump captures");
14633        assert!(std::path::Path::new(&container).exists());
14634        let spec = PreviousVersionReadSpec {
14635            table: "tasks".to_owned(),
14636            ..Default::default()
14637        };
14638
14639        // §7.3.5 lease error ⇒ lease-inactive, and the read discards.
14640        recapture_previous_version(&client);
14641        client.lease_state = Some(LeaseState {
14642            lease_id: None,
14643            expires_at_ms: None,
14644            error_code: Some("lease.expired".to_owned()),
14645        });
14646        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14647        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
14648        assert!(!snapshot.available);
14649        assert!(!std::path::Path::new(&container).exists());
14650
14651        // An expired lease uses the client clock (injected, no sleeps).
14652        recapture_previous_version(&client);
14653        client.set_now_ms(1_000);
14654        client.lease_state = Some(LeaseState {
14655            lease_id: Some("lease".to_owned()),
14656            expires_at_ms: Some(1_000),
14657            error_code: None,
14658        });
14659        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14660        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
14661        assert!(!std::path::Path::new(&container).exists());
14662        client.lease_state = None;
14663
14664        // A revoked subscription ⇒ scope-revoked.
14665        recapture_previous_version(&client);
14666        client.subs.push(subscription(SubState::Revoked, 0, None));
14667        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14668        assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
14669        assert!(!std::path::Path::new(&container).exists());
14670        client.subs.clear();
14671
14672        // Coverage completion: every ACTIVE subscription has a cursor and no
14673        // resume token — a still-resuming subscription is not completion.
14674        recapture_previous_version(&client);
14675        client
14676            .subs
14677            .push(subscription(SubState::Active, 0, Some("resume")));
14678        assert!(!client.previous_version_coverage_complete());
14679        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14680        assert!(snapshot.available);
14681        assert_eq!(snapshot.previous_version, Some(2));
14682        assert_eq!(snapshot.rows.len(), 1);
14683
14684        recapture_previous_version(&client);
14685        client.subs[0].bootstrap_state = None;
14686        assert!(client.previous_version_coverage_complete());
14687        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14688        assert_eq!(
14689            snapshot.reason,
14690            Some(PreviousVersionReason::CoverageComplete)
14691        );
14692        assert!(!std::path::Path::new(&container).exists());
14693        client.subs.clear();
14694
14695        // With no active subscription at all, coverage is NOT complete.
14696        assert!(!client.previous_version_coverage_complete());
14697
14698        drop(client);
14699        let _ = std::fs::remove_file(&path);
14700    }
14701
14702    /// RFC 0005 D7 lifetime drops. The read surface already proves the reasons
14703    /// come from live state; this proves the OTHER caller —
14704    /// `previous_version_lifetime_check`, the once-per-sync-round trigger —
14705    /// actually removes the container for lease-inactivity, coverage
14706    /// completion and scope revocation, and leaves it alone while the
14707    /// replacement bootstrap is still resuming.
14708    #[test]
14709    fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
14710        let path = std::env::temp_dir()
14711            .join(format!(
14712                "syncular-prev-lifetime-{}.db",
14713                uuid::Uuid::new_v4()
14714            ))
14715            .to_string_lossy()
14716            .into_owned();
14717        let container = format!(
14718            "{path}{}",
14719            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
14720        );
14721        let exists = || std::path::Path::new(&container).exists();
14722        {
14723            let mut setup = SyncClient::open_path(
14724                "lifetime".to_owned(),
14725                &previous_version_schema(1),
14726                previous_version_limits(),
14727                &path,
14728            )
14729            .expect("v1 install");
14730            setup
14731                .mutate(vec![Mutation::Upsert {
14732                    table: "tasks".to_owned(),
14733                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
14734                    base_version: None,
14735                }])
14736                .expect("seed a row");
14737        }
14738        let mut client = SyncClient::open_path(
14739            "lifetime".to_owned(),
14740            &previous_version_schema(2),
14741            previous_version_limits(),
14742            &path,
14743        )
14744        .expect("v2 bump captures");
14745        // Pin the clock so the 24h TTL never fires in this test (the TTL has
14746        // its own coverage); the capture record's createdAtMs is 1.
14747        client.set_now_ms(1_000);
14748        assert!(exists());
14749
14750        // §7.3.5 lease error ⇒ the lifetime check drops the container.
14751        client.lease_state = Some(LeaseState {
14752            lease_id: None,
14753            expires_at_ms: None,
14754            error_code: Some("lease.expired".to_owned()),
14755        });
14756        client.previous_version_lifetime_check();
14757        assert!(!exists());
14758        client.lease_state = None;
14759
14760        // §7.4.5 coverage completion (every active sub has a cursor and no
14761        // resume token) ⇒ drop.
14762        recapture_previous_version(&client);
14763        client.subs.push(subscription(SubState::Active, 0, None));
14764        client.previous_version_lifetime_check();
14765        assert!(!exists());
14766        client.subs.clear();
14767
14768        // §3.3 scope revocation ⇒ drop.
14769        recapture_previous_version(&client);
14770        client.subs.push(subscription(SubState::Revoked, 0, None));
14771        client.previous_version_lifetime_check();
14772        assert!(!exists());
14773        client.subs.clear();
14774
14775        // Negative control: a still-resuming active subscription is NOT
14776        // coverage completion, so the container survives the same call.
14777        recapture_previous_version(&client);
14778        client
14779            .subs
14780            .push(subscription(SubState::Active, 0, Some("resume")));
14781        client.previous_version_lifetime_check();
14782        assert!(exists());
14783        client.subs.clear();
14784
14785        drop(client);
14786        let _ = std::fs::remove_file(&path);
14787        let _ = std::fs::remove_file(&container);
14788        let _ = std::fs::remove_file(format!("{container}-wal"));
14789        let _ = std::fs::remove_file(format!("{container}-shm"));
14790    }
14791}