Skip to main content

syncular_client/
client.rs

1//! The Syncular v2 Rust client core (SPEC.md client-behavior contract):
2//! rusqlite local storage, §3.2/§3.3 effective-scope persistence + purge,
3//! §4 pull/cursor/bootstrap (§4.7 resume, §5.6 segment application), §6
4//! push with outbox order, §7 optimistic apply / rollback / replay-on-top,
5//! §2.3 clientCommitId idempotency, §8 realtime client rules, §10 errors.
6//!
7//! Built from `SPEC.md` and the committed `ssp2` codec alone — no
8//! reference to the v1 Rust tree or the v2 TypeScript client.
9
10use crate::{ProgressObserver, ProgressPhase, ProgressState};
11use std::cell::{Cell, RefCell};
12use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
13
14use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
15use rusqlite::{Connection, OpenFlags, OptionalExtension};
16use serde::{Deserialize, Serialize};
17use serde_json::{Map, Value};
18use sha2::{Digest, Sha256};
19use ssp2::decode::WIRE_VERSION;
20use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
21use ssp2::primitives::RawJson;
22use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
23use ssp2::{
24    decode_message, encode_message, encode_presence_publish, parse_control, ControlMessage,
25    PresenceKind,
26};
27
28use crate::api::{
29    ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
30    ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
31    ClientDiagnosticsStorage, ClientLimits, CommandEffects, CommitOperation,
32    CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery, CommitOutcomeResolution,
33    CommitOutcomeStatus, ConflictRecord, CoverageSnapshot, DiagnosticLastChange,
34    DiagnosticLastRound, DiagnosticRoundCounters, DiagnosticSubscription, FetchedBlob, LeaseState,
35    LocalDataPurgeInput, LocalDataPurgeResult, LocalDataPurgeTarget, LocalDataRebootstrapInput,
36    LocalDataRebootstrapResult, Mutation, PresencePeer, PreviousVersionStatus, QueryRow,
37    QuerySnapshot, QueryValue, RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput,
38    RowState, SchemaFloor, SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport,
39    SyncStatusSnapshot, TableChange, WindowBase, WindowChange, WindowCoverage, WindowState,
40    WindowUnitRef, CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
41};
42#[cfg(feature = "bench-internals")]
43use crate::bench::{Phase, Recorder};
44use crate::previous_version::{
45    capture_previous_version_from_replica, first_incompatibility,
46    reconcile_previous_version_at_boot, set_local_schema_descriptor,
47    sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
48    PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
49};
50use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
51use crate::transport::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
52use crate::values::{
53    bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
54    encode_sparse_row_json, full_row_values, json_to_column_value, json_to_scope_map,
55    normalize_values_casing, render_row_id_json, scope_map_to_json, sort_scope_map,
56};
57
58/// §4.2 default: rows and SQLite images, both applied in committed chunks.
59/// Bit 3 (signed URLs, §5.4) is
60/// added per transport capability at request-build time.
61const DEFAULT_ACCEPT: u8 = 0b0111;
62const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
63const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
64const ACCEPT_SQLITE: u8 = 1 << 2;
65const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
66const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
67
68/// §7.4.1 persisted local schema-version marker (`_syncular_meta` key).
69pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
70const LOCAL_REVISION_KEY: &str = "localRevision";
71const CLIENT_ID_KEY: &str = "clientId";
72const LEASE_STATE_KEY: &str = "leaseState";
73const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
74const LOG_EPOCH_KEY: &str = "logEpoch";
75/// Persisted fail-closed quarantine marker: present while a security preflight
76/// is pending and `activateSecurity` has yet to run. Storing it in the replica
77/// keeps the gate with the data it protects, so a rebuilt host handle reopening
78/// the same file returns in preflight even though its in-memory state is fresh.
79const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
80const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
81const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
82/// §7.4.4 client-local code: a pending outbox commit cannot re-encode under
83/// the new schema after a bump. Never a wire code (§10.3).
84const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
85/// §5.11 client-local code: an encode at the push seam resolved no usable key
86/// id or named an unknown key. Never a wire code (§10.3).
87const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
88
89#[derive(Debug, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase", deny_unknown_fields)]
91struct PersistedLocalDataRebootstrapReceipt {
92    version: u8,
93    retained_commits: u64,
94    reset_subscriptions: u64,
95}
96
97fn invalid_local_rebootstrap_receipt() -> String {
98    "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
99}
100
101fn encode_local_rebootstrap_receipt(
102    retained_commits: usize,
103    reset_subscriptions: usize,
104) -> Result<String, String> {
105    let retained_commits =
106        u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
107    let reset_subscriptions =
108        u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
109    if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
110        return Err(invalid_local_rebootstrap_receipt());
111    }
112    serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
113        version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
114        retained_commits,
115        reset_subscriptions,
116    })
117    .map_err(|_| invalid_local_rebootstrap_receipt())
118}
119
120fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
121    // Pre-0.15.36 markers proved application but did not retain the receipt.
122    if value == "v1" {
123        return Ok((0, 0));
124    }
125    let receipt: PersistedLocalDataRebootstrapReceipt =
126        serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
127    if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
128        || receipt.retained_commits > MAX_JS_SAFE_INTEGER
129        || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
130    {
131        return Err(invalid_local_rebootstrap_receipt());
132    }
133    Ok((
134        usize::try_from(receipt.retained_commits)
135            .map_err(|_| invalid_local_rebootstrap_receipt())?,
136        usize::try_from(receipt.reset_subscriptions)
137            .map_err(|_| invalid_local_rebootstrap_receipt())?,
138    ))
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142enum SubState {
143    Active,
144    Revoked,
145    Failed,
146}
147
148#[cfg(test)]
149mod observation_tests {
150    use super::*;
151    use crate::native_transport::HostTransport;
152    use crate::values::encode_row_json;
153    use serde_json::json;
154
155    fn client() -> SyncClient {
156        SyncClient::new(
157            "retry-test".to_owned(),
158            &json!({
159                "version": 1,
160                "tables": [{
161                    "name": "tasks",
162                    "primaryKey": "id",
163                    "columns": [
164                        { "name": "id", "type": "string", "nullable": false },
165                        { "name": "project_id", "type": "string", "nullable": false }
166                    ],
167                    "scopes": [{ "pattern": "project:{project_id}" }]
168                }]
169            }),
170            ClientLimits::default(),
171        )
172        .expect("test client")
173    }
174
175    #[cfg(feature = "e2ee")]
176    #[test]
177    fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
178        let schema = json!({
179            "version": 1,
180            "tables": [{
181                "name": "secrets",
182                "primaryKey": "id",
183                "columns": [
184                    { "name": "id", "type": "string", "nullable": false },
185                    { "name": "encryption_key_id", "type": "string", "nullable": true },
186                    { "name": "note", "type": "bytes", "nullable": true,
187                      "encrypted": true, "declaredType": "string" }
188                ],
189                "scopes": []
190            }]
191        });
192        let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
193            .expect("test client");
194        let mut config = crate::values::EncryptionConfig::default();
195        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
196        config
197            .key_id_columns
198            .insert("secrets".to_owned(), "encryption_key_id".to_owned());
199        client.set_encryption(config);
200        // A patch on a locally absent row presents an encrypted column with no
201        // stored key id to fall back to. Authoring succeeds...
202        let commit_id = client
203            .patch(
204                "secrets",
205                "ghost",
206                Map::from_iter([("note".to_owned(), json!("x"))]),
207                None,
208            )
209            .expect("patch records without an author-time encrypt failure");
210        assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
211        // ...and the push seam raises a durable local rejection, not a throw.
212        assert!(client.drop_unencodable_outbox().unwrap());
213        assert!(client.pending_commit_ids().is_empty());
214        let rejections = client.rejections();
215        assert_eq!(rejections.len(), 1);
216        assert_eq!(rejections[0].code, "client.encrypt_failed");
217        assert_eq!(rejections[0].client_commit_id, commit_id);
218        assert_eq!(
219            client
220                .commit_outcome(&commit_id)
221                .unwrap()
222                .map(|outcome| outcome.status),
223            Some(CommitOutcomeStatus::Rejected)
224        );
225    }
226
227    #[test]
228    fn patch_with_a_scope_column_matches_the_stored_local_row() {
229        let mut client = client();
230        client.create_synced_tables().unwrap();
231        client
232            .conn
233            .execute(
234                "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
235                [],
236            )
237            .unwrap();
238
239        // §3.4 rule 5: a value equal to the stored local row is a no-op, so
240        // the scope column leaves the presence set.
241        client
242            .patch(
243                "tasks",
244                "t1",
245                Map::from_iter([
246                    ("project_id".to_owned(), json!("p1")),
247                    ("id".to_owned(), json!("t1")),
248                ]),
249                None,
250            )
251            .expect("an equal scope column is a no-op");
252        let values = client.outbox[0].ops[0]
253            .values
254            .clone()
255            .expect("the patch records values");
256        assert_eq!(values.get("id"), Some(&json!("t1")));
257        assert!(!values.contains_key("project_id"));
258
259        // A differing value stays rejected.
260        let differing = client.patch(
261            "tasks",
262            "t1",
263            Map::from_iter([("project_id".to_owned(), json!("p2"))]),
264            None,
265        );
266        assert!(differing
267            .expect_err("a differing scope column is rejected")
268            .contains("patch cannot write scope column"));
269
270        // An absent local row leaves nothing to prove equality against, so
271        // the patch fails closed.
272        let absent = client.patch(
273            "tasks",
274            "ghost",
275            Map::from_iter([("project_id".to_owned(), json!("p1"))]),
276            None,
277        );
278        assert!(absent
279            .expect_err("an absent local row is rejected")
280            .contains("patch cannot write scope column"));
281        assert_eq!(client.pending_commit_ids().len(), 1);
282    }
283
284    #[test]
285    fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
286        let schema = json!({
287            "version": 1,
288            "tables": [
289                {
290                    "name": "tenants",
291                    "primaryKey": "tenant_id",
292                    "columns": [
293                        { "name": "tenant_id", "type": "string", "nullable": false },
294                        { "name": "body", "type": "string", "nullable": false }
295                    ],
296                    "scopes": [{ "pattern": "tenant:{tenant_id}" }]
297                },
298                {
299                    "name": "buckets",
300                    "primaryKey": "id",
301                    "columns": [
302                        { "name": "id", "type": "string", "nullable": false },
303                        { "name": "bucket", "type": "float", "nullable": false },
304                        { "name": "body", "type": "string", "nullable": false }
305                    ],
306                    "scopes": [{ "pattern": "bucket:{bucket}" }]
307                }
308            ]
309        });
310        let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
311            .expect("test client");
312        client.create_synced_tables().unwrap();
313
314        // A primary key that is also a scope column with no local row: the
315        // sparse payload's key is the row id being patched, so its value is
316        // proven equal without a stored-row read.
317        client
318            .patch(
319                "tenants",
320                "t1",
321                Map::from_iter([
322                    ("tenant_id".to_owned(), json!("t1")),
323                    ("body".to_owned(), json!("created")),
324                ]),
325                None,
326            )
327            .expect("the primary-key scope column is proven equal by construction");
328
329        // A stored REAL scope value coerces from a supplied integer.
330        client
331            .conn
332            .execute(
333                "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
334                [],
335            )
336            .unwrap();
337        client
338            .patch(
339                "buckets",
340                "b1",
341                Map::from_iter([
342                    ("bucket".to_owned(), json!(2)),
343                    ("body".to_owned(), json!("coerced")),
344                ]),
345                None,
346            )
347            .expect("the stored REAL value coerces from a supplied integer");
348        let values = client.outbox[1].ops[0]
349            .values
350            .clone()
351            .expect("the patch records values");
352        assert!(!values.contains_key("bucket"));
353
354        // A differing value stays rejected.
355        let differing = client.patch(
356            "buckets",
357            "b1",
358            Map::from_iter([("bucket".to_owned(), json!(3))]),
359            None,
360        );
361        assert!(differing
362            .expect_err("a differing scope value is rejected")
363            .contains("patch cannot write scope column"));
364    }
365
366    #[cfg(feature = "e2ee")]
367    #[test]
368    fn stored_key_fallback_resolves_an_integer_primary_key() {
369        let schema = json!({
370            "version": 1,
371            "tables": [{
372                "name": "patients",
373                "primaryKey": "id",
374                "columns": [
375                    { "name": "id", "type": "integer", "nullable": false },
376                    { "name": "encryption_key_id", "type": "string", "nullable": true },
377                    { "name": "note", "type": "bytes", "nullable": true,
378                      "encrypted": true, "declaredType": "string" }
379                ],
380                "scopes": []
381            }]
382        });
383        let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
384            .expect("test client");
385        client.create_synced_tables().unwrap();
386        client
387            .conn
388            .execute(
389                "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
390                 VALUES (5, 'k1', NULL, 1)",
391                [],
392            )
393            .unwrap();
394        let mut config = crate::values::EncryptionConfig::default();
395        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
396        config
397            .key_id_columns
398            .insert("patients".to_owned(), "encryption_key_id".to_owned());
399        client.set_encryption(config);
400        let table = client.schema.table("patients").unwrap();
401        let fallback = client
402            .stored_key_fallback(table, "5")
403            .expect("the stored integer key resolves the fallback");
404        assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
405    }
406
407    #[test]
408    fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
409        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
410            {"name":"id","type":"string","nullable":false},
411            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
412        for state in ["absent", "cached", "pinned"] {
413            let path = std::env::temp_dir().join(format!(
414                "syncular-blob-stage-{}.sqlite",
415                uuid::Uuid::new_v4()
416            ));
417            let mut client = SyncClient::open_path(
418                "stage-test".into(),
419                &schema,
420                Default::default(),
421                path.to_str().unwrap(),
422            )
423            .unwrap();
424            let bytes = b"atomic stage";
425            if state != "absent" {
426                client
427                    .upload_blob(bytes, Some("text/plain".into()), None)
428                    .unwrap();
429                if state == "cached" {
430                    client
431                        .conn
432                        .execute("DELETE FROM _syncular_blob_uploads", [])
433                        .unwrap();
434                }
435            }
436            let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
437            client
438                .conn
439                .execute_batch(
440                    "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
441                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
442                 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
443                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
444                )
445                .unwrap();
446            let error = client
447                .upload_blob(bytes, Some("application/octet-stream".into()), None)
448                .unwrap_err();
449            assert!(error.contains("injected stage failure"), "{state}: {error}");
450            assert!(client.conn.is_autocommit());
451            let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
452            drop(client);
453            let mut reopened = SyncClient::open_path(
454                "stage-test".into(),
455                &schema,
456                Default::default(),
457                path.to_str().unwrap(),
458            )
459            .unwrap();
460            let durable = reopened
461                .query("SELECT * FROM _syncular_blobs", &[])
462                .unwrap();
463            reopened
464                .conn
465                .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
466                .unwrap();
467            let retried = reopened.upload_blob(bytes, None, None).unwrap();
468            assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
469            assert_eq!(
470                reopened
471                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
472                    .unwrap(),
473                vec![Map::from_iter([(
474                    "blob_id".into(),
475                    Value::from(blob_id_for(bytes)),
476                )])]
477            );
478            drop(reopened);
479            std::fs::remove_file(&path).unwrap();
480            assert_eq!(durable, bodies, "{state}: reopened body state");
481            assert_eq!(immediate, bodies, "{state}: immediate body state");
482        }
483    }
484
485    #[test]
486    fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
487        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
488            {"name":"id","type":"string","nullable":false},
489            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
490        for fault in [
491            "missing",
492            "body-type",
493            "length",
494            "hash",
495            "pin-metadata",
496            "read",
497            "pin-delete",
498        ] {
499            let path = std::env::temp_dir().join(format!(
500                "syncular-blob-upload-{}.sqlite",
501                uuid::Uuid::new_v4()
502            ));
503            let mut client = SyncClient::open_path(
504                "upload-test".into(),
505                &schema,
506                Default::default(),
507                path.to_str().unwrap(),
508            )
509            .unwrap();
510            let bytes = b"durable upload";
511            let ref_value = client
512                .upload_blob(bytes, Some("text/plain".into()), None)
513                .unwrap();
514            let commit = client
515                .mutate(vec![Mutation::Upsert {
516                    table: "attachments".into(),
517                    values: Map::from_iter([
518                        ("id".into(), Value::from("pending")),
519                        (
520                            "file".into(),
521                            Value::from(format!(
522                                "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
523                                blob_id_for(bytes),
524                                bytes.len()
525                            )),
526                        ),
527                    ]),
528                    base_version: None,
529                }])
530                .unwrap();
531            client
532                .conn
533                .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
534                .unwrap();
535            client.conn.execute_batch(match fault {
536                "missing" => "DELETE FROM _syncular_blobs",
537                "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
538                "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
539                "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
540                "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
541                "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
542                _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
543            }).unwrap();
544            let state = if fault == "read" {
545                Vec::new()
546            } else {
547                client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
548            };
549            let mut transport = CountingRealtimeTransport::default();
550            let error = client.flush_blob_uploads(&mut transport).unwrap_err();
551            assert_eq!(
552                error.code,
553                if matches!(fault, "read" | "pin-delete") {
554                    "client.failed"
555                } else {
556                    "sync.local_corrupt"
557                },
558                "{fault}"
559            );
560            assert_eq!(
561                transport.blob_uploads.len(),
562                usize::from(fault == "pin-delete"),
563                "{fault}: corrupt bytes must never be uploaded"
564            );
565            assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
566            if fault != "read" {
567                assert_eq!(
568                    client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
569                    state
570                );
571            } else {
572                client
573                    .conn
574                    .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
575                    .unwrap();
576            }
577            drop(client);
578            let mut reopened = SyncClient::open_path(
579                "upload-test".into(),
580                &schema,
581                Default::default(),
582                path.to_str().unwrap(),
583            )
584            .unwrap();
585            assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
586            if fault != "read" {
587                assert_eq!(
588                    reopened
589                        .query("SELECT * FROM _syncular_blobs", &[])
590                        .unwrap(),
591                    state
592                );
593            }
594            if fault == "pin-delete" {
595                reopened
596                    .conn
597                    .execute_batch("DROP TRIGGER fail_pin_delete")
598                    .unwrap();
599            }
600            if fault == "pin-metadata" {
601                reopened
602                    .conn
603                    .execute(
604                        "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
605                        [],
606                    )
607                    .unwrap();
608            }
609            reopened
610                .conn
611                .execute_batch(
612                    "DELETE FROM _syncular_blobs;
613                     INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
614                )
615                .unwrap();
616            transport.blob_uploads.clear();
617            reopened.flush_blob_uploads(&mut transport).unwrap();
618            assert_eq!(
619                transport.blob_uploads,
620                [(blob_id_for(bytes), bytes.to_vec())]
621            );
622            assert_eq!(
623                reopened
624                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
625                    .unwrap(),
626                Vec::<Map<String, Value>>::new()
627            );
628            assert_eq!(reopened.pending_commit_ids(), [commit]);
629            assert_eq!(
630                reopened
631                    .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
632                    .unwrap()
633                    .unwrap()
634                    .bytes,
635                bytes
636            );
637            drop(reopened);
638            std::fs::remove_file(path).unwrap();
639        }
640    }
641
642    #[test]
643    fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
644        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
645            {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
646            "scopes":[{"pattern":"project:{project_id}"}]}]});
647        for realtime in [false, true] {
648            for fault in [
649                "none",
650                "row",
651                "revision",
652                "commit",
653                "cursor",
654                "error",
655                "missing-end",
656            ] {
657                if realtime && fault == "missing-end" {
658                    continue;
659                }
660                let path = std::env::temp_dir()
661                    .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
662                let mut client = SyncClient::open_path(
663                    "frame-test".into(),
664                    &schema,
665                    Default::default(),
666                    path.to_str().unwrap(),
667                )
668                .unwrap();
669                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
670                client
671                    .subscribe(
672                        "tasks".into(),
673                        "tasks".into(),
674                        vec![("project_id".into(), vec!["p1".into()])],
675                        None,
676                    )
677                    .unwrap();
678                client.subs[0].cursor = 0;
679                client.subs[0].synced_once = true;
680                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
681                client.persist_sub(&client.subs[0]).unwrap();
682                client.realtime_connected = true;
683                client.drain_change_batches();
684                let mut transport = CountingRealtimeTransport::default();
685                let (_, meta) = client.build_request(false);
686                let mut response = Message {
687                    wire_version: WIRE_VERSION,
688                    msg_kind: MsgKind::Response,
689                    frames: vec![
690                        Frame::RespHeader {
691                            required_schema_version: None,
692                            latest_schema_version: None,
693                            log_epoch: Some("epoch-1".into()),
694                            reset_required: Some(false),
695                        },
696                        Frame::SubStart {
697                            id: "tasks".into(),
698                            status: SubStatus::Active,
699                            reason_code: "".into(),
700                            effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
701                            bootstrap: false,
702                        },
703                    ],
704                };
705                for (index, id) in ["first", "second"].iter().enumerate() {
706                    let values = Map::from_iter([
707                        ("id".into(), Value::from(*id)),
708                        ("project_id".into(), Value::from("p1")),
709                    ]);
710                    response.frames.push(Frame::Commit {
711                        commit_seq: index as i64 + 1,
712                        actor_id: "writer".into(),
713                        created_at_ms: 1,
714                        tables: vec!["tasks".into()],
715                        changes: vec![ssp2::model::Change {
716                            table_index: 0,
717                            row_id: (*id).into(),
718                            op: Op::Upsert,
719                            row_version: Some(index as i64 + 1),
720                            scopes: vec![("project_id".into(), "p1".into())],
721                            row: Some(
722                                encode_row_json(
723                                    client.schema.table("tasks").unwrap(),
724                                    id,
725                                    &values,
726                                    &client.encryption,
727                                )
728                                .unwrap(),
729                            ),
730                        }],
731                    });
732                }
733                response.frames.push(Frame::SubEnd {
734                    next_cursor: 2,
735                    bootstrap_state: None,
736                });
737                let valid = response.clone();
738                match fault {
739                    "row" => {
740                        let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
741                        let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
742                    },
743                    "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(),
744                    "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(),
745                    "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
746                    "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});},
747                    "missing-end" => {response.frames.truncate(3);},
748                    _ => {}
749                }
750                if realtime {
751                    client.on_realtime_binary(&mut transport, &encode_message(&response));
752                } else {
753                    assert_eq!(
754                        matches!(
755                            client.process_response(&mut transport, response, &meta),
756                            SyncOutcome::Ok(_)
757                        ),
758                        fault == "none",
759                        "{fault}"
760                    );
761                }
762                assert!(
763                    client.conn.is_autocommit(),
764                    "no open transaction after {realtime}/{fault}"
765                );
766                let count = if matches!(fault, "none" | "cursor") {
767                    2
768                } else {
769                    1
770                };
771                let expected_revision = count + u64::from(realtime && fault != "none");
772                assert_eq!(
773                    client.local_revision(),
774                    expected_revision,
775                    "{realtime}/{fault}"
776                );
777                let batches = client.drain_change_batches();
778                assert_eq!(
779                    batches.len(),
780                    expected_revision as usize,
781                    "{realtime}/{fault}"
782                );
783                let row_batches: Vec<_> = batches
784                    .iter()
785                    .filter(|batch| !batch.tables.is_empty())
786                    .collect();
787                assert_eq!(row_batches.len(), count as usize);
788                assert_eq!(
789                    row_batches
790                        .iter()
791                        .map(|b| b.revision.clone())
792                        .collect::<Vec<_>>(),
793                    (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
794                );
795                for table in ["tasks", "_syncular_base_tasks"] {
796                    assert_eq!(
797                        client
798                            .conn
799                            .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
800                                .get::<_, u64>(0))
801                            .unwrap(),
802                        count,
803                        "{realtime}/{fault}/{table}"
804                    );
805                }
806                assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
807                assert_eq!(
808                    transport
809                        .messages
810                        .iter()
811                        .filter(|m| m.contains("\"ack\""))
812                        .count(),
813                    usize::from(fault == "none")
814                );
815                if matches!(fault, "revision" | "commit" | "cursor") {
816                    client
817                        .conn
818                        .execute_batch("DROP TRIGGER fail_frame")
819                        .unwrap();
820                }
821                drop(client);
822                let mut reopened = SyncClient::open_path(
823                    "frame-test".into(),
824                    &schema,
825                    Default::default(),
826                    path.to_str().unwrap(),
827                )
828                .unwrap();
829                assert_eq!(reopened.local_revision(), expected_revision);
830                assert_eq!(
831                    reopened
832                        .query("SELECT id FROM tasks ORDER BY id", &[])
833                        .unwrap()
834                        .len(),
835                    count as usize
836                );
837                assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
838                let (_, meta) = reopened.build_request(false);
839                assert!(matches!(
840                    reopened.process_response(&mut transport, valid, &meta),
841                    SyncOutcome::Ok(_)
842                ));
843                assert_eq!(
844                    reopened
845                        .query("SELECT id FROM tasks ORDER BY id", &[])
846                        .unwrap()
847                        .len(),
848                    2
849                );
850                assert_eq!(reopened.subs[0].cursor, 2);
851                drop(reopened);
852                std::fs::remove_file(path).unwrap();
853            }
854        }
855    }
856
857    #[test]
858    fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
859        for status in [SubStatus::Reset, SubStatus::Revoked] {
860            for fault in ["subscription", "revision"] {
861                let mut client = client();
862                client
863                    .subscribe(
864                        "tasks".into(),
865                        "tasks".into(),
866                        vec![("project_id".into(), vec!["p1".into()])],
867                        None,
868                    )
869                    .unwrap();
870                client.subs[0].cursor = 41;
871                client.subs[0].synced_once = true;
872                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
873                client.persist_sub(&client.subs[0]).unwrap();
874                client
875                    .mutate(vec![Mutation::Upsert {
876                        table: "tasks".into(),
877                        values: Map::from_iter([
878                            ("id".into(), Value::from("pending")),
879                            ("project_id".into(), Value::from("p1")),
880                        ]),
881                        base_version: None,
882                    }])
883                    .unwrap();
884                let ids = client.pending_commit_ids();
885                let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
886                let revision = client.local_revision();
887                client.drain_change_batches();
888                let (_, meta) = client.build_request(false);
889                client.conn.execute_batch(if fault == "subscription" {
890                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
891                } else {
892                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
893                }).unwrap();
894                let mut transport = CountingRealtimeTransport::default();
895                let mut report = SyncReport::default();
896                // Reset changes window completeness and therefore needs a revision.
897                if status == SubStatus::Reset {
898                    client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
899                }
900                let result = client.process_section(
901                    &mut transport,
902                    "tasks",
903                    status,
904                    "sync.scope_revoked",
905                    vec![],
906                    vec![],
907                    Some((42, None)),
908                    &meta,
909                    &mut report,
910                );
911                assert!(result.is_err(), "{status:?}/{fault}");
912                assert!(client.conn.is_autocommit());
913                assert_eq!(client.subs[0].cursor, 41);
914                assert_eq!(client.subs[0].state, SubState::Active);
915                assert_eq!(client.pending_commit_ids(), ids);
916                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
917                assert!(client.rejections.is_empty());
918                assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
919                assert_eq!(client.local_revision(), revision);
920                assert!(client.drain_change_batches().is_empty());
921                assert!(report.revoked.is_empty() && report.resets.is_empty());
922            }
923        }
924    }
925
926    #[test]
927    fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
928        let mut client = client();
929        client
930            .subscribe(
931                "tasks".into(),
932                "tasks".into(),
933                vec![("project_id".into(), vec!["p1".into()])],
934                None,
935            )
936            .unwrap();
937        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
938        client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
939            CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
940        let row = |id: &str| ssp2::segment::SegmentRow {
941            server_version: 1,
942            values: vec![
943                Some(ColumnValue::String(id.into())),
944                Some(ColumnValue::String("p1".into())),
945            ],
946        };
947        let segment = RowsSegment {
948            table: "tasks".into(),
949            schema_version: 1,
950            columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
951            blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
952        };
953        assert!(client.apply_segment(0, &segment, true).is_err());
954        assert!(client.conn.is_autocommit());
955        assert_eq!(
956            client
957                .query("SELECT id FROM tasks ORDER BY id", &[])
958                .unwrap(),
959            vec![Map::from_iter([("id".into(), Value::from("first"))])]
960        );
961        assert_eq!(client.local_revision(), 1);
962        assert_eq!(client.drain_change_batches().len(), 1);
963        assert_eq!(client.subs[0].cursor, -1);
964        client
965            .conn
966            .execute_batch("DROP TRIGGER fail_block")
967            .unwrap();
968        assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
969        assert_eq!(client.local_revision(), 3);
970        assert_eq!(client.drain_change_batches().len(), 2);
971    }
972
973    #[test]
974    fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
975        assert_eq!(
976            client().diagnostics_storage().blob_cache_bytes_approx,
977            Some(0)
978        );
979        let client = SyncClient::new(
980            "diagnostic-storage".to_owned(),
981            &json!({
982                "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
983                    "columns": [{"name": "id", "type": "string", "nullable": false},
984                        {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
985            }),
986            ClientLimits {
987                blob_cache_max_bytes: Some(5),
988                ..Default::default()
989            },
990        )
991        .unwrap();
992        let empty = client.diagnostics_storage();
993        assert_eq!(empty.status, "healthy");
994        assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
995        assert_eq!(empty.retained_outcome_entries, Some(0));
996        assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
997        assert_eq!(empty.blob_cache_bytes_approx, Some(0));
998        client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
999            INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1000                VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1001            INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1002                VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1003        let written = client.diagnostics_storage();
1004        assert_eq!(written.status, "pressure");
1005        assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1006        assert_eq!(written.retained_outcome_entries, Some(2));
1007        assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1008        assert_eq!(written.blob_cache_bytes_approx, Some(8));
1009        assert_eq!(
1010            written.pressure_reason_code.as_deref(),
1011            Some("client.blob_cache_over_limit")
1012        );
1013        client
1014            .conn
1015            .execute_batch(
1016                "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1017            DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1018            )
1019            .unwrap();
1020        let staged = client.diagnostics_storage();
1021        assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1022        assert_eq!(staged.retained_outcome_entries, Some(0));
1023        assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1024        assert_eq!(staged.status, "healthy");
1025        client.conn.execute_batch("ROLLBACK").unwrap();
1026        assert_eq!(client.diagnostics_storage(), written);
1027        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1028        let unreadable = client.diagnostics_storage();
1029        assert_eq!(unreadable.status, "unreadable");
1030        assert!(unreadable.database_bytes_approx.is_none());
1031        assert!(unreadable.pending_outbox_bytes_approx.is_none());
1032        assert!(unreadable.retained_outcome_entries.is_none());
1033        assert!(unreadable.retained_outcome_bytes_approx.is_none());
1034        assert!(unreadable.blob_cache_bytes_approx.is_none());
1035        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1036        let restored = client.diagnostics_storage();
1037        assert_eq!(restored.status, written.status);
1038        assert_eq!(
1039            restored.retained_outcome_bytes_approx,
1040            written.retained_outcome_bytes_approx
1041        );
1042        client.conn.set_prepared_statement_cache_capacity(0);
1043        assert_eq!(client.diagnostics_storage(), restored);
1044    }
1045
1046    #[test]
1047    fn background_retry_deadlines_back_off_and_reset() {
1048        let mut client = client();
1049        client.schedule_background_retry();
1050        assert!(matches!(
1051            client.drain_sync_intents().as_slice(),
1052            [SyncIntent::Background { delay_ms: 250 }]
1053        ));
1054        client.schedule_background_retry();
1055        assert!(matches!(
1056            client.drain_sync_intents().as_slice(),
1057            [SyncIntent::Background { delay_ms: 500 }]
1058        ));
1059        client.reset_background_retry();
1060        client.schedule_background_retry();
1061        assert!(matches!(
1062            client.drain_sync_intents().as_slice(),
1063            [SyncIntent::Background { delay_ms: 250 }]
1064        ));
1065    }
1066
1067    #[test]
1068    fn log_epoch_reset_requests_an_immediate_follow_up_round() {
1069        let mut client = client();
1070        client
1071            .subscribe(
1072                "epoch-tasks".to_owned(),
1073                "tasks".to_owned(),
1074                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1075                None,
1076            )
1077            .expect("subscribe");
1078        client.drain_sync_intents();
1079
1080        client
1081            .run_log_epoch_reset("epoch-2")
1082            .expect("reset partition log epoch");
1083
1084        assert!(matches!(
1085            client.drain_sync_intents().as_slice(),
1086            [SyncIntent::Interactive]
1087        ));
1088    }
1089
1090    #[derive(Default)]
1091    struct CountingRealtimeTransport {
1092        connects: usize,
1093        closes: usize,
1094        messages: Vec<String>,
1095        blob_uploads: Vec<(String, Vec<u8>)>,
1096        blob_download: Option<Vec<u8>>,
1097    }
1098
1099    impl Transport for CountingRealtimeTransport {
1100        fn blob_upload(
1101            &mut self,
1102            blob_id: &str,
1103            bytes: &[u8],
1104            _media_type: Option<&str>,
1105        ) -> Result<(), TransportError> {
1106            self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1107            Ok(())
1108        }
1109
1110        fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1111            self.blob_download
1112                .clone()
1113                .map(BlobDownload::Bytes)
1114                .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1115        }
1116
1117        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1118            Err(TransportError::new("sync.transport_failed", "offline"))
1119        }
1120
1121        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1122            Err(TransportError::new("sync.transport_failed", "offline"))
1123        }
1124
1125        fn download_segment(
1126            &mut self,
1127            _request: &SegmentRequest,
1128            _on_progress: &mut dyn FnMut(u64),
1129        ) -> Result<Vec<u8>, TransportError> {
1130            Err(TransportError::new("sync.transport_failed", "offline"))
1131        }
1132
1133        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1134            self.connects += 1;
1135            Ok(())
1136        }
1137
1138        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1139            self.messages.push(text.to_owned());
1140            Ok(())
1141        }
1142
1143        fn realtime_close(&mut self) -> Result<(), TransportError> {
1144            self.closes += 1;
1145            Ok(())
1146        }
1147    }
1148
1149    #[test]
1150    fn blob_results_own_downloaded_bytes() {
1151        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1152            {"name":"id","type":"string","nullable":false},
1153            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1154        let bytes = b"owned typed result".to_vec();
1155        let blob_id = blob_id_for(&bytes);
1156        let mut transport = CountingRealtimeTransport {
1157            blob_download: Some(bytes.clone()),
1158            ..Default::default()
1159        };
1160        let result = {
1161            let mut client =
1162                SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1163                    .unwrap();
1164            let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1165            assert_eq!(result.blob_id, blob_id);
1166            assert_eq!(result.byte_length, bytes.len() as i64);
1167            assert_eq!(result.bytes, bytes);
1168            assert_eq!(result.media_type, None);
1169            assert_eq!(
1170                client
1171                    .conn
1172                    .query_row(
1173                        "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1174                        rusqlite::params![blob_id],
1175                        |row| row.get::<_, i64>(0),
1176                    )
1177                    .unwrap(),
1178                0
1179            );
1180            result
1181        };
1182        assert_eq!(result.bytes, bytes);
1183
1184        let mut client =
1185            SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1186        assert_eq!(
1187            client
1188                .fetch_blob_bytes(&mut transport, "not json")
1189                .unwrap_err(),
1190            (
1191                "client.failed".to_owned(),
1192                "blob ref is not JSON".to_owned()
1193            )
1194        );
1195    }
1196
1197    #[test]
1198    fn visible_blob_references_survive_cache_trimming() {
1199        let schema = json!({"version":1,"tables":[
1200            {"name":"attachments","primaryKey":"id","columns":[
1201                {"name":"id","type":"string","nullable":false},
1202                {"name":"body","type":"blob_ref","nullable":true},
1203                {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
1204            {"name":"avatars","primaryKey":"id","columns":[
1205                {"name":"id","type":"string","nullable":false},
1206                {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
1207        ]});
1208        let bytes = b"referenced above cap".to_vec();
1209        let blob_id = blob_id_for(&bytes);
1210        let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
1211        let mut client = SyncClient::new(
1212            "visible-blob-refs".to_owned(),
1213            &schema,
1214            ClientLimits {
1215                blob_cache_max_bytes: Some(1),
1216                ..Default::default()
1217            },
1218        )
1219        .unwrap();
1220        client.conn.execute(
1221            "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
1222            rusqlite::params![reference, reference, reference, reference],
1223        ).unwrap();
1224        client
1225            .conn
1226            .execute(
1227                "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
1228                rusqlite::params![reference],
1229            )
1230            .unwrap();
1231        client.conn.execute(
1232            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
1233            [],
1234        ).unwrap();
1235        let mut transport = CountingRealtimeTransport {
1236            blob_download: Some(bytes.clone()),
1237            ..Default::default()
1238        };
1239
1240        assert_eq!(
1241            client
1242                .fetch_blob_bytes(&mut transport, &blob_id)
1243                .unwrap()
1244                .bytes,
1245            bytes
1246        );
1247        let rows = client
1248            .conn
1249            .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1250            .unwrap()
1251            .query_map([], |row| row.get::<_, String>(0))
1252            .unwrap()
1253            .collect::<Result<Vec<_>, _>>()
1254            .unwrap();
1255        assert_eq!(rows, vec![blob_id]);
1256    }
1257
1258    #[test]
1259    fn realtime_connection_ownership_is_idempotent() {
1260        let mut client = client();
1261        let mut transport = CountingRealtimeTransport {
1262            connects: 0,
1263            closes: 0,
1264            ..Default::default()
1265        };
1266        client
1267            .connect_realtime(&mut transport)
1268            .expect("first connect");
1269        client
1270            .connect_realtime(&mut transport)
1271            .expect("idempotent connect");
1272        assert_eq!(transport.connects, 1);
1273        client.disconnect_realtime(&mut transport);
1274        client.disconnect_realtime(&mut transport);
1275        assert_eq!(transport.closes, 1);
1276        client
1277            .connect_realtime(&mut transport)
1278            .expect("deliberate reconnect");
1279        assert_eq!(transport.connects, 2);
1280    }
1281
1282    #[test]
1283    fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1284        let mut client = client();
1285        client
1286            .subscribe(
1287                "repair-tasks".to_owned(),
1288                "tasks".to_owned(),
1289                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1290                None,
1291            )
1292            .expect("subscribe");
1293        {
1294            let sub = client
1295                .subs
1296                .iter_mut()
1297                .find(|sub| sub.id == "repair-tasks")
1298                .expect("subscription");
1299            sub.cursor = 42;
1300            sub.synced_once = true;
1301            let persisted = sub.clone();
1302            client.persist_sub(&persisted).unwrap();
1303        }
1304        for table in ["_syncular_base_tasks", "tasks"] {
1305            client
1306                .conn
1307                .execute(
1308                    &format!(
1309                        "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1310                    ),
1311                    rusqlite::params!["server-row", "p1"],
1312                )
1313                .expect("seed server row");
1314        }
1315        let pending = client
1316            .mutate(vec![Mutation::Upsert {
1317                table: "tasks".to_owned(),
1318                values: Map::from_iter([
1319                    ("id".to_owned(), Value::from("offline-row")),
1320                    ("project_id".to_owned(), Value::from("p1")),
1321                ]),
1322                base_version: None,
1323            }])
1324            .expect("queue offline work");
1325        client.drain_change_batches();
1326        client.drain_sync_intents();
1327
1328        assert_eq!(
1329            client
1330                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1331                    rebootstrap_id: "support-case-001".to_owned(),
1332                })
1333                .expect("rebootstrap"),
1334            LocalDataRebootstrapResult {
1335                already_applied: false,
1336                retained_commits: 1,
1337                reset_subscriptions: 1,
1338            }
1339        );
1340        let visible_ids = client
1341            .conn
1342            .prepare("SELECT id FROM tasks ORDER BY id")
1343            .expect("prepare visible ids")
1344            .query_map([], |row| row.get::<_, String>(0))
1345            .expect("query visible ids")
1346            .collect::<Result<Vec<_>, _>>()
1347            .expect("collect visible ids");
1348        assert_eq!(visible_ids, vec!["offline-row"]);
1349        assert_eq!(client.pending_commit_ids(), vec![pending]);
1350        assert_eq!(
1351            client
1352                .subscription_state("repair-tasks")
1353                .expect("subscription")
1354                .cursor,
1355            -1
1356        );
1357        assert!(client.upgrading());
1358        assert!(client.sync_needed());
1359        assert!(matches!(
1360            client.drain_sync_intents().as_slice(),
1361            [SyncIntent::Interactive]
1362        ));
1363        assert_eq!(client.drain_change_batches().len(), 1);
1364
1365        assert_eq!(
1366            client
1367                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1368                    rebootstrap_id: "support-case-001".to_owned(),
1369                })
1370                .expect("idempotent retry"),
1371            LocalDataRebootstrapResult {
1372                already_applied: true,
1373                retained_commits: 1,
1374                reset_subscriptions: 1,
1375            }
1376        );
1377        assert!(client.drain_change_batches().is_empty());
1378    }
1379
1380    #[test]
1381    fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1382        let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1383        assert_eq!(
1384            decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1385            (3, 4)
1386        );
1387        assert_eq!(
1388            decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1389            (0, 0)
1390        );
1391        for malformed in [
1392            "",
1393            "{}",
1394            r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1395            r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1396            r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1397        ] {
1398            assert_eq!(
1399                decode_local_rebootstrap_receipt(malformed)
1400                    .expect_err("malformed receipt must fail"),
1401                "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1402            );
1403        }
1404    }
1405
1406    #[test]
1407    fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1408        let path = std::env::temp_dir().join(format!(
1409            "syncular-rebootstrap-receipt-{}.db",
1410            uuid::Uuid::new_v4()
1411        ));
1412        let schema = json!({
1413            "version": 1,
1414            "tables": [{
1415                "name": "tasks",
1416                "primaryKey": "id",
1417                "columns": [
1418                    { "name": "id", "type": "string", "nullable": false },
1419                    { "name": "project_id", "type": "string", "nullable": false }
1420                ],
1421                "scopes": [{ "pattern": "project:{project_id}" }]
1422            }]
1423        });
1424        let path_string = path.to_str().expect("UTF-8 temp path");
1425
1426        {
1427            let mut first = SyncClient::open_path(
1428                "repair-restart-client".to_owned(),
1429                &schema,
1430                ClientLimits::default(),
1431                path_string,
1432            )
1433            .expect("first open");
1434            first
1435                .subscribe(
1436                    "repair-tasks".to_owned(),
1437                    "tasks".to_owned(),
1438                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1439                    None,
1440                )
1441                .expect("subscribe");
1442            first
1443                .mutate(vec![Mutation::Upsert {
1444                    table: "tasks".to_owned(),
1445                    values: Map::from_iter([
1446                        ("id".to_owned(), Value::from("offline-row")),
1447                        ("project_id".to_owned(), Value::from("p1")),
1448                    ]),
1449                    base_version: None,
1450                }])
1451                .expect("queue offline work");
1452            assert_eq!(
1453                first
1454                    .rebootstrap_local_data(&LocalDataRebootstrapInput {
1455                        rebootstrap_id: "restart-receipt".to_owned(),
1456                    })
1457                    .expect("first rebootstrap"),
1458                LocalDataRebootstrapResult {
1459                    already_applied: false,
1460                    retained_commits: 1,
1461                    reset_subscriptions: 1,
1462                }
1463            );
1464        }
1465
1466        let mut reopened = SyncClient::open_path(
1467            "repair-restart-client".to_owned(),
1468            &schema,
1469            ClientLimits::default(),
1470            path_string,
1471        )
1472        .expect("reopen");
1473        reopened.drain_change_batches();
1474        reopened.drain_sync_intents();
1475        assert_eq!(
1476            reopened
1477                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1478                    rebootstrap_id: "restart-receipt".to_owned(),
1479                })
1480                .expect("receipt replay"),
1481            LocalDataRebootstrapResult {
1482                already_applied: true,
1483                retained_commits: 1,
1484                reset_subscriptions: 1,
1485            }
1486        );
1487        assert!(reopened.drain_change_batches().is_empty());
1488        assert!(reopened.drain_sync_intents().is_empty());
1489        drop(reopened);
1490        std::fs::remove_file(path).expect("remove temp database");
1491    }
1492
1493    #[test]
1494    fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1495        let mut malformed = client();
1496        malformed
1497            .subscribe(
1498                "repair-tasks".to_owned(),
1499                "tasks".to_owned(),
1500                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1501                None,
1502            )
1503            .expect("subscribe");
1504        malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1505        malformed.drain_change_batches();
1506        malformed.drain_sync_intents();
1507        let malformed_error = malformed
1508            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1509                rebootstrap_id: "malformed".to_owned(),
1510            })
1511            .expect_err("malformed receipt must fail closed");
1512        assert_eq!(
1513            malformed_error,
1514            "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1515        );
1516        assert!(!malformed.upgrading());
1517        assert_eq!(
1518            malformed
1519                .subscription_state("repair-tasks")
1520                .expect("unchanged subscription")
1521                .cursor,
1522            -1
1523        );
1524        assert!(malformed.drain_change_batches().is_empty());
1525        assert!(malformed.drain_sync_intents().is_empty());
1526
1527        let mut unreadable = client();
1528        unreadable
1529            .conn
1530            .execute(
1531                "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1532                rusqlite::params!["server-row", "p1"],
1533            )
1534            .expect("seed visible row");
1535        unreadable
1536            .conn
1537            .execute("DROP TABLE _syncular_meta", [])
1538            .expect("break marker storage");
1539        let unreadable_error = unreadable
1540            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1541                rebootstrap_id: "unreadable".to_owned(),
1542            })
1543            .expect_err("unreadable marker storage must fail closed");
1544        assert_eq!(
1545            unreadable_error,
1546            "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1547        );
1548        let visible_rows = unreadable
1549            .conn
1550            .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1551            .expect("visible projection remains");
1552        assert_eq!(visible_rows, 1);
1553        assert!(!unreadable.upgrading());
1554        assert!(unreadable.drain_change_batches().is_empty());
1555        assert!(unreadable.drain_sync_intents().is_empty());
1556    }
1557
1558    #[test]
1559    fn local_rebootstrap_cannot_bypass_schema_floor() {
1560        let mut client = client();
1561        client.set_schema_floor(Some(SchemaFloor {
1562            required_schema_version: Some(2),
1563            latest_schema_version: Some(2),
1564        }));
1565        let error = client
1566            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1567                rebootstrap_id: "blocked-floor".to_owned(),
1568            })
1569            .expect_err("schema floor must block repair");
1570        assert!(error.contains("cannot bypass an active schema-floor stop"));
1571    }
1572
1573    #[test]
1574    fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
1575        let mut client = client();
1576        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1577        const COMMIT_COUNT: usize = 32;
1578
1579        for index in 0..COMMIT_COUNT {
1580            client
1581                .mutate(vec![Mutation::Upsert {
1582                    table: "tasks".to_owned(),
1583                    values: Map::from_iter([
1584                        ("id".to_owned(), Value::from(format!("task-{index}"))),
1585                        ("project_id".to_owned(), Value::from("project-1")),
1586                    ]),
1587                    base_version: None,
1588                }])
1589                .expect("queue commit");
1590        }
1591
1592        let (_, request_meta) = client.build_request(false);
1593        assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
1594        let mut frames = vec![Frame::RespHeader {
1595            required_schema_version: None,
1596            latest_schema_version: None,
1597            log_epoch: Some("epoch-1".to_owned()),
1598            reset_required: Some(false),
1599        }];
1600        frames.extend(request_meta.pushed_ids.iter().enumerate().map(
1601            |(index, client_commit_id)| Frame::PushResult {
1602                client_commit_id: client_commit_id.clone(),
1603                status: PushStatus::Applied,
1604                commit_seq: Some(index as i64 + 1),
1605                results: vec![OpResult::Applied { op_index: 0 }],
1606            },
1607        ));
1608        let response = Message {
1609            wire_version: WIRE_VERSION,
1610            msg_kind: MsgKind::Response,
1611            frames,
1612        };
1613        let mut transport =
1614            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
1615
1616        client.overlay_rebuild_count.set(0);
1617        client.outcome_prune_count.set(0);
1618        let outcome = client.process_response(&mut transport, response, &request_meta);
1619        assert!(matches!(outcome, SyncOutcome::Ok(_)));
1620        assert!(client.pending_commit_ids().is_empty());
1621        assert_eq!(
1622            client.overlay_rebuild_count.get(),
1623            1,
1624            "one response must reconcile its acknowledged commits with one overlay rebuild"
1625        );
1626        assert_eq!(
1627            client.outcome_prune_count.get(),
1628            1,
1629            "one response must enforce outcome retention once"
1630        );
1631    }
1632
1633    #[test]
1634    fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
1635        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
1636            "columns": [
1637                {"name":"id", "type":"string", "nullable":false},
1638                {"name":"project_id", "type":"string", "nullable":false},
1639                {"name":"title", "type":"string", "nullable":false}
1640            ], "scopes": [{"pattern":"project:{project_id}"}],
1641            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1642            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
1643        }] });
1644        let mut incremental =
1645            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1646        let mut reference =
1647            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1648        let tables = vec!["tasks".to_owned()];
1649        for index in 0..32 {
1650            let id = format!("task-{}", index % 8);
1651            let values = Map::from_iter([
1652                ("id".to_owned(), Value::from(id.clone())),
1653                (
1654                    "project_id".to_owned(),
1655                    Value::from(format!("p{}", index % 2)),
1656                ),
1657                ("title".to_owned(), Value::from(format!("needle{index}"))),
1658            ]);
1659            let payload = encode_row_json(
1660                incremental.schema.table("tasks").unwrap(),
1661                &id,
1662                &values,
1663                &incremental.encryption,
1664            )
1665            .unwrap();
1666            let change = ssp2::model::Change {
1667                table_index: 0,
1668                row_id: id,
1669                op: if index % 5 == 0 {
1670                    Op::Delete
1671                } else {
1672                    Op::Upsert
1673                },
1674                row_version: Some(index + 1),
1675                scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
1676                row: Some(payload),
1677            };
1678            reference.overlay_dirty.set(true);
1679            for instance in [&mut incremental, &mut reference] {
1680                instance.begin_observation("mirror_test").unwrap();
1681                let mut batch = ChangeAccumulator::default();
1682                instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
1683                instance
1684                    .apply_commit_changes(&tables, std::slice::from_ref(&change))
1685                    .unwrap();
1686                instance.rebuild_overlay_if_dirty();
1687                instance.finish_observation("mirror_test", batch).unwrap();
1688            }
1689            for sql in [
1690                "SELECT * FROM tasks ORDER BY id",
1691                "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
1692            ] {
1693                assert_eq!(
1694                    incremental.query(sql, &[]).unwrap(),
1695                    reference.query(sql, &[]).unwrap()
1696                );
1697            }
1698            assert_eq!(incremental.local_revision(), reference.local_revision());
1699            assert_eq!(
1700                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1701                serde_json::to_value(reference.drain_change_batches()).unwrap()
1702            );
1703        }
1704        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
1705        assert_eq!(reference.overlay_rebuild_count.get(), 32);
1706    }
1707
1708    #[test]
1709    fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
1710        let cases = [
1711            (
1712                "string",
1713                vec![
1714                    json!("1"),
1715                    json!("01"),
1716                    json!(""),
1717                    json!("nul\u{0}id"),
1718                    json!("é"),
1719                ],
1720            ),
1721            (
1722                "integer",
1723                vec![
1724                    json!(i64::MIN),
1725                    json!(-1),
1726                    json!(0),
1727                    json!(1),
1728                    json!(i64::MAX),
1729                ],
1730            ),
1731            ("boolean", vec![json!(false), json!(true)]),
1732            ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
1733        ];
1734        for (kind, ids) in cases {
1735            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1736                "columns":[{"name":"id","type":kind,"nullable":false},
1737                    {"name":"project_id","type":"string","nullable":false}],
1738                "scopes":[{"pattern":"project:{project_id}"}]}]});
1739            let mut client =
1740                SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
1741            for (index, id) in ids.iter().enumerate() {
1742                let table = client.schema.table("tasks").unwrap();
1743                client
1744                    .write_base_row(
1745                        "tasks",
1746                        &vec![
1747                            json_to_column_value(&table.columns[0], Some(id)).unwrap(),
1748                            Some(ColumnValue::String(format!("p{index}"))),
1749                        ],
1750                        1,
1751                    )
1752                    .unwrap();
1753            }
1754            client.rebuild_overlay_if_dirty();
1755            for base in [true, false] {
1756                let full = if base {
1757                    base_table("tasks")
1758                } else {
1759                    visible_table("tasks")
1760                };
1761                let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
1762                for row_id in [
1763                    "1",
1764                    "01",
1765                    "1.0",
1766                    "1.0000000000000002",
1767                    "+1",
1768                    "1e0",
1769                    " 1",
1770                    "-1",
1771                    "0",
1772                    "9223372036854775807",
1773                    "-9223372036854775808",
1774                    "true",
1775                    "false",
1776                    "",
1777                    "nul\u{0}id",
1778                    "é",
1779                    "null",
1780                    "{\"a\":1}",
1781                ] {
1782                    let old_sql =
1783                        format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
1784                    let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
1785                    let old = client
1786                        .conn
1787                        .prepare(&old_sql)
1788                        .unwrap()
1789                        .query_map([row_id], |row| row.get::<_, String>(0))
1790                        .unwrap()
1791                        .collect::<Result<Vec<_>, _>>()
1792                        .unwrap();
1793                    let new = client
1794                        .conn
1795                        .prepare_cached(&new_sql)
1796                        .unwrap()
1797                        .query_map([row_id], |row| row.get::<_, String>(0))
1798                        .unwrap()
1799                        .collect::<Result<Vec<_>, _>>()
1800                        .unwrap();
1801                    assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
1802                    let mut batch = ChangeAccumulator::default();
1803                    assert_eq!(
1804                        client.record_row_scopes(&mut batch, "tasks", row_id, base),
1805                        !old.is_empty()
1806                    );
1807                    let expected = old.first().map(|scope| {
1808                        BTreeMap::from([(
1809                            "tasks".to_owned(),
1810                            Some(BTreeSet::from([format!("project:{scope}")])),
1811                        )])
1812                    });
1813                    assert_eq!(batch.tables, expected.unwrap_or_default());
1814                    client
1815                        .conn
1816                        .execute_batch("SAVEPOINT lookup_delete")
1817                        .unwrap();
1818                    if base {
1819                        client.delete_base_row("tasks", row_id).unwrap();
1820                    } else {
1821                        client.apply_outbox_ops(&[OutboxOp {
1822                            upsert: false,
1823                            table: "tasks".into(),
1824                            row_id: row_id.into(),
1825                            base_version: None,
1826                            values: None,
1827                        }]);
1828                    }
1829                    let remaining: i64 = client
1830                        .conn
1831                        .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
1832                            row.get(0)
1833                        })
1834                        .unwrap();
1835                    assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
1836                    client
1837                        .conn
1838                        .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
1839                        .unwrap();
1840                    client.overlay_dirty.set(false);
1841                }
1842                for (predicate, expected) in [
1843                    ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
1844                    (predicate, "SEARCH"),
1845                ] {
1846                    let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
1847                    let details = client
1848                        .conn
1849                        .prepare(&sql)
1850                        .unwrap()
1851                        .query_map(["1"], |row| row.get::<_, String>(3))
1852                        .unwrap()
1853                        .collect::<Result<Vec<_>, _>>()
1854                        .unwrap();
1855                    assert!(
1856                        details.iter().any(|detail| detail.contains(expected)),
1857                        "{kind}: {details:?}"
1858                    );
1859                }
1860            }
1861        }
1862    }
1863
1864    #[test]
1865    fn primary_keys_reject_every_ineligible_column_type() {
1866        // §2.4: a `rowId` is a string, so a primary key type whose string form
1867        // differs per renderer, or cannot be reproduced by local storage
1868        // comparison, is a schema error rather than a silent lookup miss.
1869        for ty in ["float", "bytes", "crdt", "blob_ref"] {
1870            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1871                "columns":[{"name":"id","type":ty,"nullable":false},
1872                    {"name":"project_id","type":"string","nullable":false}],
1873                "scopes":[{"pattern":"project:{project_id}"}]}]});
1874            let error =
1875                match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
1876                    Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
1877                    Err(error) => error,
1878                };
1879            assert!(
1880                error.contains("has an ineligible column type (§2.4)"),
1881                "{ty}: {error}"
1882            );
1883        }
1884        for ty in ["string", "integer", "boolean", "json"] {
1885            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1886                "columns":[{"name":"id","type":ty,"nullable":false},
1887                    {"name":"project_id","type":"string","nullable":false}],
1888                "scopes":[{"pattern":"project:{project_id}"}]}]});
1889            SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
1890                .unwrap_or_else(|error| panic!("{ty}: {error}"));
1891        }
1892    }
1893
1894    #[test]
1895    fn pending_row_reconciliation_matches_full_fifo_replay() {
1896        for unique in [false, true] {
1897            let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
1898                "name": name, "primaryKey": "id", "columns": [
1899                    {"name":"id", "type":"string", "nullable":false},
1900                    {"name":"project_id", "type":"string", "nullable":false},
1901                    {"name":"title", "type":"string", "nullable":false}],
1902                "scopes": [{"pattern":"project:{project_id}"}],
1903                "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
1904                "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
1905            })))});
1906            let mut incremental =
1907                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
1908            let mut reference =
1909                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
1910            let tables = vec!["tasks".to_owned(), "notes".to_owned()];
1911            for instance in [&mut incremental, &mut reference] {
1912                for table in &tables {
1913                    for index in 0..12 {
1914                        instance
1915                            .write_base_row(
1916                                table,
1917                                &vec![
1918                                    Some(ColumnValue::String(format!("row{index}"))),
1919                                    Some(ColumnValue::String("p0".into())),
1920                                    Some(ColumnValue::String(format!("base{index}"))),
1921                                ],
1922                                1,
1923                            )
1924                            .unwrap();
1925                    }
1926                }
1927                instance.rebuild_overlay_if_dirty();
1928            }
1929            for index in 0..80 {
1930                let mutations = (0..3)
1931                    .map(|offset| {
1932                        let table = tables[(index + offset) % 2].clone();
1933                        let id = format!("row{}", (index + offset) % 16);
1934                        if (index + offset) % 7 == 0 {
1935                            Mutation::Delete {
1936                                table,
1937                                row_id: id,
1938                                base_version: None,
1939                            }
1940                        } else {
1941                            Mutation::Upsert {
1942                                table,
1943                                values: Map::from_iter([
1944                                    ("id".into(), Value::from(id)),
1945                                    ("project_id".into(), Value::from(format!("p{}", index % 3))),
1946                                    (
1947                                        "title".into(),
1948                                        Value::from(format!("needle{}", (index + offset) % 5)),
1949                                    ),
1950                                ]),
1951                                base_version: None,
1952                            }
1953                        }
1954                    })
1955                    .collect::<Vec<_>>();
1956                for instance in [&mut incremental, &mut reference] {
1957                    instance.mutate(mutations.clone()).unwrap();
1958                }
1959                // Most frames touch only the table without unique constraints.
1960                // Mixed frames must retain full replay for both tables.
1961                let changes = (0..3)
1962                    .map(|offset| {
1963                        let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
1964                        let id = format!("row{}", (index + offset) % 16);
1965                        let values = Map::from_iter([
1966                            ("id".into(), Value::from(id.clone())),
1967                            (
1968                                "project_id".into(),
1969                                Value::from(format!("p{}", (index + 1) % 3)),
1970                            ),
1971                            (
1972                                "title".into(),
1973                                Value::from(format!("remote{index}_{offset}")),
1974                            ),
1975                        ]);
1976                        ssp2::model::Change {
1977                            table_index: table_index as u16,
1978                            row_id: id.clone(),
1979                            op: if (index + offset) % 5 == 0 {
1980                                Op::Delete
1981                            } else {
1982                                Op::Upsert
1983                            },
1984                            row_version: Some(index as i64 + 2),
1985                            scopes: vec![],
1986                            row: Some(
1987                                encode_row_json(
1988                                    incremental.schema.table(&tables[table_index]).unwrap(),
1989                                    &id,
1990                                    &values,
1991                                    &incremental.encryption,
1992                                )
1993                                .unwrap(),
1994                            ),
1995                        }
1996                    })
1997                    .collect::<Vec<_>>();
1998                reference.overlay_dirty.set(true);
1999                incremental.apply_commit_frame(&tables, &changes).unwrap();
2000                reference.apply_commit_frame(&tables, &changes).unwrap();
2001                for table in &tables {
2002                    for sql in [format!("SELECT * FROM {table} ORDER BY id"),
2003                        format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
2004                        format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
2005                        format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
2006                        assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
2007                    }
2008                }
2009                assert_eq!(incremental.local_revision(), reference.local_revision());
2010                assert_eq!(
2011                    serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2012                    serde_json::to_value(reference.drain_change_batches()).unwrap()
2013                );
2014            }
2015            assert_eq!(
2016                incremental.overlay_rebuild_count.get(),
2017                if unique { 81 } else { 21 }
2018            );
2019            assert_eq!(reference.overlay_rebuild_count.get(), 81);
2020        }
2021    }
2022
2023    #[test]
2024    fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
2025        let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
2026            "columns":[{"name":"id", "type":"string", "nullable":false},
2027                {"name":"title", "type":"string", "nullable":false}],
2028            "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
2029        let mut client =
2030            SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
2031        for (id, title) in [("a", "original"), ("b", "occupied")] {
2032            client
2033                .write_base_row(
2034                    "tasks",
2035                    &vec![
2036                        Some(ColumnValue::String(id.into())),
2037                        Some(ColumnValue::String(title.into())),
2038                    ],
2039                    1,
2040                )
2041                .unwrap();
2042        }
2043        client.rebuild_overlay_if_dirty();
2044        let pending = client
2045            .mutate(vec![Mutation::Upsert {
2046                table: "tasks".into(),
2047                values: Map::from_iter([
2048                    ("id".into(), Value::from("a")),
2049                    ("title".into(), Value::from("occupied")),
2050                ]),
2051                base_version: None,
2052            }])
2053            .unwrap();
2054        assert_eq!(
2055            client
2056                .conn
2057                .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2058                    .get::<_, String>(
2059                    0
2060                ))
2061                .unwrap(),
2062            "original"
2063        );
2064        for (title, expected) in [("available", "occupied"), ("occupied", "original")] {
2065            let change = ssp2::model::Change {
2066                table_index: 0,
2067                row_id: "b".into(),
2068                op: Op::Upsert,
2069                row_version: Some(2),
2070                scopes: vec![],
2071                row: Some(
2072                    encode_row_json(
2073                        client.schema.table("tasks").unwrap(),
2074                        "b",
2075                        &Map::from_iter([
2076                            ("id".into(), Value::from("b")),
2077                            ("title".into(), Value::from(title)),
2078                        ]),
2079                        &client.encryption,
2080                    )
2081                    .unwrap(),
2082                ),
2083            };
2084            client
2085                .apply_commit_frame(&["tasks".into()], &[change])
2086                .unwrap();
2087            assert_eq!(
2088                client
2089                    .conn
2090                    .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2091                        .get::<_, String>(
2092                        0
2093                    ))
2094                    .unwrap(),
2095                expected
2096            );
2097            assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
2098        }
2099        assert_eq!(client.overlay_rebuild_count.get(), 3);
2100    }
2101
2102    #[test]
2103    fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
2104        for failure in ["visible", "revision", "commit"] {
2105            let mut client = client();
2106            let id = client
2107                .mutate(vec![Mutation::Upsert {
2108                    table: "tasks".into(),
2109                    values: Map::from_iter([
2110                        ("id".into(), Value::from("t1")),
2111                        ("project_id".into(), Value::from("pending")),
2112                    ]),
2113                    base_version: None,
2114                }])
2115                .unwrap();
2116            client.drain_change_batches();
2117            let revision = client.local_revision();
2118            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
2119            let trigger = match failure {
2120                "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
2121                "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"),
2122                _ => "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(),
2123            };
2124            client.conn.execute_batch(&trigger).unwrap();
2125            let change = ssp2::model::Change {
2126                table_index: 0,
2127                row_id: "t1".into(),
2128                op: Op::Upsert,
2129                row_version: Some(2),
2130                scopes: vec![],
2131                row: Some(
2132                    encode_row_json(
2133                        client.schema.table("tasks").unwrap(),
2134                        "t1",
2135                        &Map::from_iter([
2136                            ("id".into(), Value::from("t1")),
2137                            ("project_id".into(), Value::from("remote")),
2138                        ]),
2139                        &client.encryption,
2140                    )
2141                    .unwrap(),
2142                ),
2143            };
2144            assert!(client
2145                .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change))
2146                .is_err());
2147            assert!(client.conn.is_autocommit());
2148            assert!(!client.overlay_dirty.get());
2149            assert_eq!(client.local_revision(), revision);
2150            assert!(client.drain_change_batches().is_empty());
2151            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
2152            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
2153            assert!(client
2154                .query("SELECT * FROM _syncular_base_tasks", &[])
2155                .unwrap()
2156                .is_empty());
2157            client
2158                .conn
2159                .execute_batch("DROP TRIGGER fail_pending")
2160                .unwrap();
2161            client
2162                .apply_commit_frame(&["tasks".into()], &[change])
2163                .unwrap();
2164            assert_eq!(client.local_revision(), revision + 1);
2165            assert_eq!(client.pending_commit_ids(), vec![id]);
2166            // The overlay keeps the pending write visible; the row now carries
2167            // the server row's version it is layered on (§7.1).
2168            let mut expected = before[0].clone();
2169            expected.insert("_syncular_version".to_owned(), Value::from(2));
2170            assert_eq!(
2171                client.query("SELECT * FROM tasks", &[]).unwrap(),
2172                vec![expected]
2173            );
2174            assert_eq!(
2175                client
2176                    .conn
2177                    .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
2178                        .get::<_, String>(0))
2179                    .unwrap(),
2180                "remote"
2181            );
2182        }
2183    }
2184
2185    #[test]
2186    fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
2187        let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
2188            "columns": [
2189                {"name":"id", "type":"string", "nullable":false},
2190                {"name":"project_id", "type":"string", "nullable":false},
2191                {"name":"title", "type":"string", "nullable":false}],
2192            "scopes": [{"pattern":"project:{project_id}"}],
2193            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2194            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
2195        let mut incremental =
2196            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2197        let mut reference =
2198            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2199        for instance in [&mut incremental, &mut reference] {
2200            for index in 0..16 {
2201                instance
2202                    .write_base_row(
2203                        "tasks",
2204                        &vec![
2205                            Some(ColumnValue::String(format!("task{index}"))),
2206                            Some(ColumnValue::String("p0".to_owned())),
2207                            Some(ColumnValue::String(format!("base{index}"))),
2208                        ],
2209                        1,
2210                    )
2211                    .unwrap();
2212            }
2213            instance.rebuild_overlay_if_dirty();
2214            instance.overlay_rebuild_count.set(0);
2215        }
2216        for index in 0..100 {
2217            let mutations = (0..1 + index % 4)
2218                .map(|offset| {
2219                    let id = format!("task{}", (index + offset) % 24);
2220                    if (index + offset) % 7 == 0 {
2221                        Mutation::Delete {
2222                            table: "tasks".to_owned(),
2223                            row_id: id,
2224                            base_version: None,
2225                        }
2226                    } else {
2227                        Mutation::Upsert {
2228                            table: "tasks".to_owned(),
2229                            values: Map::from_iter([
2230                                ("id".to_owned(), Value::from(id)),
2231                                (
2232                                    "project_id".to_owned(),
2233                                    Value::from(format!("p{}", index % 2)),
2234                                ),
2235                                (
2236                                    "title".to_owned(),
2237                                    Value::from(format!("needle{}", (index + offset) % 5)),
2238                                ),
2239                            ]),
2240                            base_version: None,
2241                        }
2242                    }
2243                })
2244                .collect::<Vec<_>>();
2245            reference.overlay_dirty.set(true);
2246            incremental.mutate(mutations.clone()).unwrap();
2247            reference.mutate(mutations).unwrap();
2248            for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2249                "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
2250                assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
2251            }
2252            assert_eq!(incremental.local_revision(), reference.local_revision());
2253            assert_eq!(
2254                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2255                serde_json::to_value(reference.drain_change_batches()).unwrap()
2256            );
2257        }
2258        assert_eq!(incremental.outbox.len(), 100);
2259        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2260        assert_eq!(reference.overlay_rebuild_count.get(), 100);
2261    }
2262
2263    #[test]
2264    fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
2265        for fail_at in ["outbox", "revision"] {
2266            let mut client = client();
2267            let values = Map::from_iter([
2268                ("id".to_owned(), Value::from("t1")),
2269                ("project_id".to_owned(), Value::from("p1")),
2270            ]);
2271            let mutation = Mutation::Upsert {
2272                table: "tasks".to_owned(),
2273                values,
2274                base_version: None,
2275            };
2276            let trigger = if fail_at == "outbox" {
2277                "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
2278            } else {
2279                format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
2280            };
2281            client.conn.execute_batch(&trigger).unwrap();
2282            let revision = client.local_revision();
2283            assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
2284            assert_eq!(client.local_revision(), revision);
2285            assert!(client.outbox.is_empty());
2286            assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2287            assert_eq!(
2288                client
2289                    .conn
2290                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
2291                        .get::<_, i64>(0))
2292                    .unwrap(),
2293                0
2294            );
2295            assert!(client.drain_change_batches().is_empty());
2296            assert!(!client.overlay_dirty.get());
2297            client
2298                .conn
2299                .execute_batch("DROP TRIGGER fail_append")
2300                .unwrap();
2301            client.mutate(vec![mutation]).unwrap();
2302            assert_eq!(client.outbox.len(), 1);
2303            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
2304        }
2305    }
2306
2307    #[test]
2308    fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
2309        let mut client = client();
2310        let tables = vec!["tasks".to_owned(), "missing".to_owned()];
2311        let values = Map::from_iter([
2312            ("id".to_owned(), Value::from("t1")),
2313            ("project_id".to_owned(), Value::from("p1")),
2314        ]);
2315        let payload = encode_row_json(
2316            client.schema.table("tasks").unwrap(),
2317            "t1",
2318            &values,
2319            &client.encryption,
2320        )
2321        .unwrap();
2322        let first = ssp2::model::Change {
2323            table_index: 0,
2324            row_id: "t1".to_owned(),
2325            op: Op::Upsert,
2326            row_version: Some(1),
2327            scopes: vec![("project_id".to_owned(), "p1".to_owned())],
2328            row: Some(payload),
2329        };
2330        let mut invalid = first.clone();
2331        invalid.table_index = 1;
2332        let revision = client.local_revision();
2333        client.begin_observation("failed_mirror").unwrap();
2334        assert!(client
2335            .apply_commit_changes(&tables, &[first, invalid])
2336            .is_err());
2337        client.rollback_observation("failed_mirror");
2338        client.rebuild_overlay_if_dirty();
2339        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2340        assert_eq!(
2341            client
2342                .conn
2343                .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
2344                    .get::<_, i64>(
2345                    0
2346                ))
2347                .unwrap(),
2348            0
2349        );
2350        assert_eq!(client.local_revision(), revision);
2351        assert!(client.drain_change_batches().is_empty());
2352    }
2353
2354    #[test]
2355    fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
2356        for failure in ["journal", "revision", "commit", "later-frame"] {
2357            let mut client = client();
2358            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2359            for id in ["first", "second"] {
2360                client
2361                    .mutate(vec![Mutation::Upsert {
2362                        table: "tasks".to_owned(),
2363                        values: Map::from_iter([
2364                            ("id".to_owned(), Value::from(id)),
2365                            ("project_id".to_owned(), Value::from("p1")),
2366                        ]),
2367                        base_version: None,
2368                    }])
2369                    .unwrap();
2370            }
2371            let (_, meta) = client.build_request(false);
2372            // A malicious or stale response must not drain an unsent local commit.
2373            client
2374                .mutate(vec![Mutation::Upsert {
2375                    table: "tasks".to_owned(),
2376                    values: Map::from_iter([
2377                        ("id".to_owned(), Value::from("later")),
2378                        ("project_id".to_owned(), Value::from("p1")),
2379                    ]),
2380                    base_version: None,
2381                }])
2382                .unwrap();
2383            let ids = client.pending_commit_ids();
2384            let rows = client
2385                .query("SELECT * FROM tasks ORDER BY id", &[])
2386                .unwrap();
2387            let revision = client.local_revision();
2388            client.drain_change_batches();
2389            if failure != "later-frame" {
2390                client.conn.execute_batch(match failure {
2391                    "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",
2392                    "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",
2393                    _ => "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",
2394                }).unwrap();
2395            }
2396            let mut response = Message {
2397                wire_version: WIRE_VERSION,
2398                msg_kind: MsgKind::Response,
2399                frames: vec![Frame::RespHeader {
2400                    required_schema_version: None,
2401                    latest_schema_version: None,
2402                    log_epoch: Some("epoch-1".to_owned()),
2403                    reset_required: Some(false),
2404                }],
2405            };
2406            for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
2407                response.frames.push(Frame::PushResult {
2408                    client_commit_id: id.to_owned(),
2409                    status: PushStatus::Applied,
2410                    commit_seq: Some(1),
2411                    results: vec![OpResult::Applied { op_index: 0 }],
2412                });
2413            }
2414            if failure == "later-frame" {
2415                response.frames.push(Frame::Error {
2416                    code: "sync.invalid_request".to_owned(),
2417                    message: "later error".to_owned(),
2418                    category: "protocol".to_owned(),
2419                    retryable: false,
2420                    recommended_action: "retry".to_owned(),
2421                    details: None,
2422                });
2423            }
2424            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2425            let outcome = client.process_response(&mut transport, response.clone(), &meta);
2426            assert!(
2427                matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
2428            );
2429            if failure == "later-frame" {
2430                assert_eq!(client.pending_commit_ids(), ids[2..]);
2431                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2432                assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
2433                let batches = client.drain_change_batches();
2434                assert_eq!(batches.len(), 1);
2435                assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
2436                assert_eq!(client.local_revision(), revision + 1);
2437                response.frames.pop();
2438            } else {
2439                assert_eq!(client.pending_commit_ids(), ids);
2440                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2441                assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
2442                assert_eq!(client.local_revision(), revision);
2443                assert!(client.drain_change_batches().is_empty());
2444                assert_eq!(
2445                    client
2446                        .query("SELECT * FROM tasks ORDER BY id", &[])
2447                        .unwrap(),
2448                    rows
2449                );
2450                client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
2451            }
2452            for frame in &mut response.frames {
2453                if let Frame::PushResult { status, .. } = frame {
2454                    *status = PushStatus::Cached;
2455                }
2456            }
2457            let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
2458            else {
2459                panic!("retry failed");
2460            };
2461            assert_eq!(
2462                report.applied,
2463                if failure == "later-frame" {
2464                    vec![]
2465                } else {
2466                    ids[..2].to_vec()
2467                }
2468            );
2469            assert_eq!(client.pending_commit_ids(), ids[2..]);
2470            let batches = client.drain_change_batches();
2471            assert_eq!(batches.len(), usize::from(failure != "later-frame"));
2472            if let Some(batch) = batches.first() {
2473                assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
2474            }
2475            assert_eq!(client.local_revision(), revision + 1);
2476        }
2477    }
2478
2479    #[test]
2480    fn failed_push_result_preserves_memory_disk_and_retry_identity() {
2481        for failure in ["revision", "journal", "commit"] {
2482            for status in ["applied", "cached", "rejected", "conflict"] {
2483                let mut client = client();
2484                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2485                for id in ["first", "later"] {
2486                    client
2487                        .mutate(vec![Mutation::Upsert {
2488                            table: "tasks".to_owned(),
2489                            values: Map::from_iter([
2490                                ("id".to_owned(), Value::from(id)),
2491                                ("project_id".to_owned(), Value::from("p1")),
2492                            ]),
2493                            base_version: None,
2494                        }])
2495                        .unwrap();
2496                }
2497                let (_, meta) = client.build_request(false);
2498                let ids = client.pending_commit_ids();
2499                let rows = client
2500                    .query("SELECT * FROM tasks ORDER BY id", &[])
2501                    .unwrap();
2502                let revision = client.local_revision();
2503                client.drain_change_batches();
2504                let (push_status, results) = match status {
2505                    "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
2506                    "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
2507                    "conflict" => (
2508                        PushStatus::Rejected,
2509                        vec![OpResult::Conflict {
2510                            op_index: 0,
2511                            code: "sync.version_conflict".to_owned(),
2512                            message: "conflict".to_owned(),
2513                            server_version: 1,
2514                            server_row: encode_row_json(
2515                                client.schema.table("tasks").unwrap(),
2516                                "first",
2517                                &Map::from_iter([
2518                                    ("id".to_owned(), Value::from("first")),
2519                                    ("project_id".to_owned(), Value::from("p1")),
2520                                ]),
2521                                &client.encryption,
2522                            )
2523                            .unwrap(),
2524                            conflict_columns: Vec::new(),
2525                        }],
2526                    ),
2527                    _ => (
2528                        PushStatus::Rejected,
2529                        vec![OpResult::Error {
2530                            op_index: 0,
2531                            code: "sync.validation_failed".to_owned(),
2532                            message: "rejected".to_owned(),
2533                            retryable: false,
2534                        }],
2535                    ),
2536                };
2537                let response = Message {
2538                    wire_version: WIRE_VERSION,
2539                    msg_kind: MsgKind::Response,
2540                    frames: vec![
2541                        Frame::RespHeader {
2542                            required_schema_version: None,
2543                            latest_schema_version: None,
2544                            log_epoch: Some("epoch-1".to_owned()),
2545                            reset_required: Some(false),
2546                        },
2547                        Frame::PushResult {
2548                            client_commit_id: ids[0].clone(),
2549                            status: push_status,
2550                            commit_seq: Some(1),
2551                            results,
2552                        },
2553                    ],
2554                };
2555                client.conn.execute_batch(match failure {
2556                    "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
2557                    "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
2558                    _ => "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",
2559                }).unwrap();
2560                let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2561                let outcome = client.process_response(&mut transport, response.clone(), &meta);
2562                assert_eq!(
2563                    client.pending_commit_ids(),
2564                    ids,
2565                    "{failure}/{status}: pending identity"
2566                );
2567                assert_eq!(
2568                    client.local_revision(),
2569                    revision,
2570                    "{failure}/{status}: revision"
2571                );
2572                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2573                assert!(client.conflicts().is_empty());
2574                assert!(client.rejections().is_empty());
2575                assert!(client.drain_change_batches().is_empty());
2576                assert_eq!(
2577                    client
2578                        .query("SELECT * FROM tasks ORDER BY id", &[])
2579                        .unwrap(),
2580                    rows
2581                );
2582                assert!(
2583                    matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
2584                    "{failure}/{status}: failed round"
2585                );
2586                client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
2587                assert!(matches!(
2588                    client.process_response(&mut transport, response, &meta),
2589                    SyncOutcome::Ok(_)
2590                ));
2591                assert_eq!(client.pending_commit_ids(), ids[1..]);
2592                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2593                assert_eq!(client.drain_change_batches().len(), 1);
2594            }
2595        }
2596    }
2597
2598    #[test]
2599    fn mixed_push_results_reconcile_and_prune_once_per_response() {
2600        let mut client = client();
2601        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2602        for index in 0..4 {
2603            client
2604                .mutate(vec![Mutation::Upsert {
2605                    table: "tasks".to_owned(),
2606                    values: Map::from_iter([
2607                        ("id".to_owned(), Value::from(format!("task-{index}"))),
2608                        ("project_id".to_owned(), Value::from("project-1")),
2609                    ]),
2610                    base_version: None,
2611                }])
2612                .expect("queue commit");
2613        }
2614
2615        let (_, request_meta) = client.build_request(false);
2616        let ids = &request_meta.pushed_ids;
2617        assert_eq!(ids.len(), 4);
2618        let response = Message {
2619            wire_version: WIRE_VERSION,
2620            msg_kind: MsgKind::Response,
2621            frames: vec![
2622                Frame::RespHeader {
2623                    required_schema_version: None,
2624                    latest_schema_version: None,
2625                    log_epoch: Some("epoch-1".to_owned()),
2626                    reset_required: Some(false),
2627                },
2628                Frame::PushResult {
2629                    client_commit_id: ids[0].clone(),
2630                    status: PushStatus::Applied,
2631                    commit_seq: Some(1),
2632                    results: vec![OpResult::Applied { op_index: 0 }],
2633                },
2634                Frame::PushResult {
2635                    client_commit_id: ids[1].clone(),
2636                    status: PushStatus::Cached,
2637                    commit_seq: Some(2),
2638                    results: vec![OpResult::Applied { op_index: 0 }],
2639                },
2640                Frame::PushResult {
2641                    client_commit_id: ids[2].clone(),
2642                    status: PushStatus::Rejected,
2643                    commit_seq: None,
2644                    results: vec![OpResult::Error {
2645                        op_index: 0,
2646                        code: "sync.validation_failed".to_owned(),
2647                        message: "rejected".to_owned(),
2648                        retryable: false,
2649                    }],
2650                },
2651                Frame::PushResult {
2652                    client_commit_id: ids[3].clone(),
2653                    status: PushStatus::Rejected,
2654                    commit_seq: None,
2655                    results: vec![OpResult::Error {
2656                        op_index: 0,
2657                        code: "sync.idempotency_cache_miss".to_owned(),
2658                        message: "retry".to_owned(),
2659                        retryable: true,
2660                    }],
2661                },
2662            ],
2663        };
2664        let mut transport =
2665            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
2666
2667        client.overlay_rebuild_count.set(0);
2668        client.outcome_prune_count.set(0);
2669        let outcome = client.process_response(&mut transport, response, &request_meta);
2670        let SyncOutcome::Ok(report) = outcome else {
2671            panic!("mixed push-result response failed");
2672        };
2673
2674        assert_eq!(report.applied, ids[..2]);
2675        assert_eq!(report.rejected, ids[2..3]);
2676        assert_eq!(report.retryable, ids[3..4]);
2677        assert_eq!(client.pending_commit_ids(), ids[3..4]);
2678        assert_eq!(
2679            client
2680                .query("SELECT id FROM tasks ORDER BY id", &[])
2681                .expect("query visible overlay"),
2682            vec![Map::from_iter([("id".to_owned(), Value::from("task-3"))])]
2683        );
2684        assert_eq!(client.overlay_rebuild_count.get(), 1);
2685        assert_eq!(client.outcome_prune_count.get(), 1);
2686    }
2687
2688    #[test]
2689    fn secondary_unique_collision_preserves_existing_synced_row() {
2690        let client = SyncClient::new(
2691            "unique-upsert-test".to_owned(),
2692            &json!({
2693                "version": 1,
2694                "tables": [{
2695                    "name": "tasks",
2696                    "primaryKey": "id",
2697                    "columns": [
2698                        { "name": "id", "type": "string", "nullable": false },
2699                        { "name": "project_id", "type": "string", "nullable": false },
2700                        { "name": "title", "type": "string", "nullable": false }
2701                    ],
2702                    "scopes": [{ "pattern": "project:{project_id}" }],
2703                    "indexes": [{
2704                        "name": "tasks_by_project_title",
2705                        "columns": ["project_id", "title"],
2706                        "unique": true
2707                    }]
2708                }]
2709            }),
2710            ClientLimits::default(),
2711        )
2712        .expect("test client");
2713        let table = client.schema.table("tasks").expect("tasks table");
2714        let sql = client.insert_row_sql(&base_table("tasks"), table);
2715
2716        client
2717            .conn
2718            .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
2719            .expect("insert first row");
2720        client
2721            .conn
2722            .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
2723            .expect("update same primary key");
2724        client
2725            .conn
2726            .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
2727            .expect("insert second row");
2728        assert!(client
2729            .conn
2730            .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
2731            .is_err());
2732
2733        let rows = client
2734            .conn
2735            .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
2736            .expect("prepare rows")
2737            .query_map([], |row| {
2738                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2739            })
2740            .expect("query rows")
2741            .collect::<Result<Vec<_>, _>>()
2742            .expect("collect rows");
2743        assert_eq!(
2744            rows,
2745            vec![
2746                ("t1".to_owned(), "updated".to_owned()),
2747                ("t2".to_owned(), "original".to_owned())
2748            ]
2749        );
2750    }
2751
2752    #[test]
2753    fn reopening_active_subscriptions_emits_a_catch_up_intent() {
2754        let path = std::env::temp_dir().join(format!(
2755            "syncular-startup-intent-{}.db",
2756            uuid::Uuid::new_v4()
2757        ));
2758        let schema = json!({
2759            "version": 1,
2760            "tables": [{
2761                "name": "tasks",
2762                "primaryKey": "id",
2763                "columns": [
2764                    { "name": "id", "type": "string", "nullable": false },
2765                    { "name": "project_id", "type": "string", "nullable": false }
2766                ],
2767                "scopes": [{ "pattern": "project:{project_id}" }]
2768            }]
2769        });
2770
2771        {
2772            let mut first = SyncClient::open_path_with_identity(
2773                None,
2774                &schema,
2775                ClientLimits::default(),
2776                path.to_str().expect("UTF-8 temp path"),
2777            )
2778            .expect("first open");
2779            first
2780                .set_window(
2781                    &WindowBase {
2782                        table: "tasks".to_owned(),
2783                        variable: "project_id".to_owned(),
2784                        fixed_scopes: Vec::new(),
2785                        params: None,
2786                    },
2787                    &["persisted".to_owned()],
2788                )
2789                .expect("persist window");
2790        }
2791
2792        let mut reopened = SyncClient::open_path_with_identity(
2793            None,
2794            &schema,
2795            ClientLimits::default(),
2796            path.to_str().expect("UTF-8 temp path"),
2797        )
2798        .expect("reopen");
2799        assert!(reopened.sync_needed());
2800        assert!(matches!(
2801            reopened.drain_sync_intents().as_slice(),
2802            [SyncIntent::Interactive]
2803        ));
2804        drop(reopened);
2805        std::fs::remove_file(path).expect("remove temp database");
2806    }
2807
2808    #[test]
2809    fn reopening_preserves_immutable_subscription_identity_and_progress() {
2810        let path = std::env::temp_dir().join(format!(
2811            "syncular-subscription-identity-{}.db",
2812            uuid::Uuid::new_v4()
2813        ));
2814        let schema = json!({
2815            "version": 1,
2816            "tables": [
2817                {
2818                    "name": "tasks",
2819                    "primaryKey": "id",
2820                    "columns": [
2821                        { "name": "id", "type": "string", "nullable": false },
2822                        { "name": "project_id", "type": "string", "nullable": false }
2823                    ],
2824                    "scopes": [{ "pattern": "project:{project_id}" }]
2825                },
2826                {
2827                    "name": "docs",
2828                    "primaryKey": "id",
2829                    "columns": [
2830                        { "name": "id", "type": "string", "nullable": false },
2831                        { "name": "org_id", "type": "string", "nullable": false },
2832                        { "name": "project_id", "type": "string", "nullable": false }
2833                    ],
2834                    "scopes": [
2835                        { "pattern": "org:{org_id}" },
2836                        { "pattern": "project:{projectId}", "column": "project_id" }
2837                    ]
2838                }
2839            ]
2840        });
2841
2842        {
2843            let mut first = SyncClient::open_path_with_identity(
2844                None,
2845                &schema,
2846                ClientLimits::default(),
2847                path.to_str().expect("UTF-8 temp path"),
2848            )
2849            .expect("first open");
2850            first
2851                .subscribe(
2852                    "stable-subscription".to_owned(),
2853                    "tasks".to_owned(),
2854                    vec![(
2855                        "project_id".to_owned(),
2856                        vec!["p2".to_owned(), "p1".to_owned()],
2857                    )],
2858                    Some(r#"{"view":"v1"}"#.to_owned()),
2859                )
2860                .expect("persist subscription");
2861            let persisted = {
2862                let subscription = first
2863                    .subs
2864                    .iter_mut()
2865                    .find(|subscription| subscription.id == "stable-subscription")
2866                    .expect("subscription");
2867                subscription.cursor = 41;
2868                subscription.bootstrap_state = Some("resume-token".to_owned());
2869                subscription.effective = Some(vec![(
2870                    "project_id".to_owned(),
2871                    vec!["p1".to_owned(), "p2".to_owned()],
2872                )]);
2873                subscription.synced_once = true;
2874                subscription.clone()
2875            };
2876            first.persist_sub(&persisted).unwrap();
2877        }
2878
2879        let mut reopened = SyncClient::open_path_with_identity(
2880            None,
2881            &schema,
2882            ClientLimits::default(),
2883            path.to_str().expect("UTF-8 temp path"),
2884        )
2885        .expect("reopen");
2886        let progress = reopened
2887            .subscription_state("stable-subscription")
2888            .expect("persisted state");
2889        assert_eq!(progress.cursor, 41);
2890        assert!(progress.has_resume_token);
2891
2892        reopened
2893            .subscribe(
2894                "stable-subscription".to_owned(),
2895                "tasks".to_owned(),
2896                vec![(
2897                    "project_id".to_owned(),
2898                    vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
2899                )],
2900                Some(r#"{"view":"v1"}"#.to_owned()),
2901            )
2902            .expect("canonical intent is idempotent");
2903        assert_eq!(
2904            reopened
2905                .subscription_state("stable-subscription")
2906                .expect("unchanged state")
2907                .cursor,
2908            progress.cursor
2909        );
2910
2911        for (table, scopes, params) in [
2912            (
2913                "tasks",
2914                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2915                Some(r#"{"view":"v1"}"#.to_owned()),
2916            ),
2917            (
2918                "tasks",
2919                vec![(
2920                    "project_id".to_owned(),
2921                    vec!["p2".to_owned(), "p1".to_owned()],
2922                )],
2923                Some(r#"{"view":"v2"}"#.to_owned()),
2924            ),
2925            (
2926                "docs",
2927                vec![
2928                    ("org_id".to_owned(), vec!["o1".to_owned()]),
2929                    ("projectId".to_owned(), vec!["p1".to_owned()]),
2930                ],
2931                Some(r#"{"view":"v1"}"#.to_owned()),
2932            ),
2933        ] {
2934            let error = reopened
2935                .subscribe(
2936                    "stable-subscription".to_owned(),
2937                    table.to_owned(),
2938                    scopes,
2939                    params,
2940                )
2941                .expect_err("identity rebind must fail");
2942            assert!(error.starts_with("client.subscription_intent_mismatch:"));
2943            assert_eq!(
2944                reopened
2945                    .subscription_state("stable-subscription")
2946                    .expect("unchanged state")
2947                    .cursor,
2948                progress.cursor
2949            );
2950        }
2951
2952        drop(reopened);
2953        std::fs::remove_file(path).expect("remove temp database");
2954    }
2955
2956    #[test]
2957    fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
2958        let path = std::env::temp_dir().join(format!(
2959            "syncular-satisfied-schema-floor-{}.db",
2960            uuid::Uuid::new_v4()
2961        ));
2962        let schema = json!({
2963            "version": 23,
2964            "tables": [{
2965                "name": "tasks",
2966                "primaryKey": "id",
2967                "columns": [
2968                    { "name": "id", "type": "string", "nullable": false },
2969                    { "name": "project_id", "type": "string", "nullable": false }
2970                ],
2971                "scopes": [{ "pattern": "project:{project_id}" }]
2972            }]
2973        });
2974
2975        {
2976            let mut first = SyncClient::open_path_with_identity(
2977                None,
2978                &schema,
2979                ClientLimits::default(),
2980                path.to_str().expect("UTF-8 temp path"),
2981            )
2982            .expect("first open");
2983            first
2984                .subscribe(
2985                    "tasks".to_owned(),
2986                    "tasks".to_owned(),
2987                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2988                    None,
2989                )
2990                .expect("persist subscription");
2991            first.set_schema_floor(Some(SchemaFloor {
2992                required_schema_version: Some(22),
2993                latest_schema_version: Some(22),
2994            }));
2995        }
2996
2997        let mut reopened = SyncClient::open_path_with_identity(
2998            None,
2999            &schema,
3000            ClientLimits::default(),
3001            path.to_str().expect("UTF-8 temp path"),
3002        )
3003        .expect("reopen");
3004        assert!(reopened.schema_floor().is_none());
3005        assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
3006        assert!(reopened.sync_needed());
3007        assert!(matches!(
3008            reopened.drain_sync_intents().as_slice(),
3009            [SyncIntent::Interactive]
3010        ));
3011        drop(reopened);
3012        std::fs::remove_file(path).expect("remove temp database");
3013    }
3014
3015    #[test]
3016    fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
3017        let path = std::env::temp_dir().join(format!(
3018            "syncular-unsatisfied-schema-floor-{}.db",
3019            uuid::Uuid::new_v4()
3020        ));
3021        let schema = json!({
3022            "version": 1,
3023            "tables": [{
3024                "name": "tasks",
3025                "primaryKey": "id",
3026                "columns": [
3027                    { "name": "id", "type": "string", "nullable": false },
3028                    { "name": "project_id", "type": "string", "nullable": false }
3029                ],
3030                "scopes": [{ "pattern": "project:{project_id}" }]
3031            }]
3032        });
3033
3034        {
3035            let mut first = SyncClient::open_path_with_identity(
3036                None,
3037                &schema,
3038                ClientLimits::default(),
3039                path.to_str().expect("UTF-8 temp path"),
3040            )
3041            .expect("first open");
3042            first
3043                .subscribe(
3044                    "tasks".to_owned(),
3045                    "tasks".to_owned(),
3046                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3047                    None,
3048                )
3049                .expect("persist subscription");
3050            first.set_schema_floor(Some(SchemaFloor {
3051                required_schema_version: Some(2),
3052                latest_schema_version: Some(2),
3053            }));
3054        }
3055
3056        let reopened = SyncClient::open_path_with_identity(
3057            None,
3058            &schema,
3059            ClientLimits::default(),
3060            path.to_str().expect("UTF-8 temp path"),
3061        )
3062        .expect("reopen");
3063        assert_eq!(
3064            reopened.schema_floor(),
3065            Some(&SchemaFloor {
3066                required_schema_version: Some(2),
3067                latest_schema_version: Some(2),
3068            })
3069        );
3070        assert!(!reopened.sync_needed());
3071        drop(reopened);
3072        std::fs::remove_file(path).expect("remove temp database");
3073    }
3074
3075    #[test]
3076    fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
3077        let path = std::env::temp_dir().join(format!(
3078            "syncular-indexed-column-bump-{}.db",
3079            uuid::Uuid::new_v4()
3080        ));
3081        let old_schema = json!({
3082            "version": 1,
3083            "tables": [
3084                {
3085                    "name": "tasks",
3086                    "primaryKey": "id",
3087                    "columns": [
3088                        { "name": "id", "type": "string", "nullable": false },
3089                        { "name": "project_id", "type": "string", "nullable": false }
3090                    ],
3091                    "scopes": [{ "pattern": "project:{project_id}" }]
3092                },
3093                {
3094                    "name": "legacy",
3095                    "primaryKey": "id",
3096                    "columns": [
3097                        { "name": "id", "type": "string", "nullable": false },
3098                        { "name": "project_id", "type": "string", "nullable": false }
3099                    ],
3100                    "scopes": [{ "pattern": "project:{project_id}" }]
3101                }
3102            ]
3103        });
3104        {
3105            let mut first = SyncClient::open_path_with_identity(
3106                None,
3107                &old_schema,
3108                ClientLimits::default(),
3109                path.to_str().expect("UTF-8 temp path"),
3110            )
3111            .expect("open old schema");
3112            first
3113                .subscribe(
3114                    "legacy-sub".to_owned(),
3115                    "legacy".to_owned(),
3116                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3117                    None,
3118                )
3119                .expect("persist legacy subscription");
3120        }
3121
3122        let new_schema = json!({
3123            "version": 2,
3124            "tables": [{
3125                "name": "tasks",
3126                "primaryKey": "id",
3127                "columns": [
3128                    { "name": "id", "type": "string", "nullable": false },
3129                    { "name": "project_id", "type": "string", "nullable": false },
3130                    { "name": "facility_membership_id", "type": "string", "nullable": true }
3131                ],
3132                "scopes": [{ "pattern": "project:{project_id}" }],
3133                "indexes": [{
3134                    "name": "tasks_by_membership",
3135                    "columns": ["project_id", "facility_membership_id"],
3136                    "unique": false
3137                }]
3138            }]
3139        });
3140        let upgraded = SyncClient::open_path_with_identity(
3141            None,
3142            &new_schema,
3143            ClientLimits::default(),
3144            path.to_str().expect("UTF-8 temp path"),
3145        )
3146        .expect("open upgraded schema");
3147        let columns = upgraded
3148            .conn
3149            .prepare("PRAGMA table_info(tasks)")
3150            .expect("prepare columns")
3151            .query_map([], |row| row.get::<_, String>(1))
3152            .expect("query columns")
3153            .collect::<Result<Vec<_>, _>>()
3154            .expect("collect columns");
3155        assert!(columns.contains(&"facility_membership_id".to_owned()));
3156        let index_count: i64 = upgraded
3157            .conn
3158            .query_row(
3159                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
3160                [],
3161                |row| row.get(0),
3162            )
3163            .expect("query index");
3164        assert_eq!(index_count, 1);
3165        assert!(upgraded.subscription_state("legacy-sub").is_none());
3166        drop(upgraded);
3167        std::fs::remove_file(path).expect("remove temp database");
3168    }
3169
3170    #[test]
3171    fn migrates_pre_envelope_outcome_journal_additively() {
3172        let path = std::env::temp_dir().join(format!(
3173            "syncular-outcome-migration-{}.db",
3174            uuid::Uuid::new_v4()
3175        ));
3176        let conn = Connection::open(&path).expect("open legacy database");
3177        conn.execute_batch(
3178            "CREATE TABLE _syncular_commit_outcomes (
3179               seq INTEGER PRIMARY KEY AUTOINCREMENT,
3180               client_commit_id TEXT NOT NULL UNIQUE,
3181               status TEXT NOT NULL,
3182               recorded_at_ms INTEGER NOT NULL,
3183               results_json TEXT NOT NULL,
3184               resolution TEXT NOT NULL DEFAULT 'active',
3185               resolved_at_ms INTEGER,
3186               replacement_client_commit_id TEXT);",
3187        )
3188        .expect("create legacy outcome journal");
3189        drop(conn);
3190        let schema = json!({
3191            "version": 1,
3192            "tables": [{
3193                "name": "tasks",
3194                "primaryKey": "id",
3195                "columns": [
3196                    { "name": "id", "type": "string", "nullable": false },
3197                    { "name": "project_id", "type": "string", "nullable": false }
3198                ],
3199                "scopes": [{ "pattern": "project:{project_id}" }]
3200            }]
3201        });
3202        let client = SyncClient::open_path(
3203            "migration-native".to_owned(),
3204            &schema,
3205            ClientLimits::default(),
3206            path.to_str().expect("UTF-8 temp path"),
3207        )
3208        .expect("migrate database");
3209        let has_operations = client
3210            .conn
3211            .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
3212            .expect("prepare table info")
3213            .query_map([], |row| row.get::<_, String>(1))
3214            .expect("query table info")
3215            .filter_map(Result::ok)
3216            .any(|column| column == "operations_json");
3217        assert!(has_operations);
3218        drop(client);
3219        std::fs::remove_file(path).expect("remove temp database");
3220    }
3221
3222    #[test]
3223    fn durable_conflict_outcome_and_resolution_survive_reopen() {
3224        let path = std::env::temp_dir().join(format!(
3225            "syncular-durable-outcome-{}.db",
3226            uuid::Uuid::new_v4()
3227        ));
3228        let schema = json!({
3229            "version": 1,
3230            "tables": [{
3231                "name": "tasks",
3232                "primaryKey": "id",
3233                "columns": [
3234                    { "name": "id", "type": "string", "nullable": false },
3235                    { "name": "project_id", "type": "string", "nullable": false }
3236                ],
3237                "scopes": [{ "pattern": "project:{project_id}" }]
3238            }]
3239        });
3240        let conflict = ConflictRecord {
3241            client_commit_id: "losing-commit".to_owned(),
3242            op_index: 0,
3243            table: "tasks".to_owned(),
3244            row_id: "t1".to_owned(),
3245            code: "sync.version_conflict".to_owned(),
3246            message: "stale base version".to_owned(),
3247            server_version: 2,
3248            server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
3249            conflict_columns: vec!["project_id".to_owned()],
3250            operation: Some(CommitOperation {
3251                table: "tasks".to_owned(),
3252                row_id: "t1".to_owned(),
3253                op: "upsert".to_owned(),
3254                base_version: Some(1),
3255                values: None,
3256            }),
3257        };
3258        let failed_operations = vec![
3259            OutboxOp {
3260                upsert: true,
3261                table: "tasks".to_owned(),
3262                row_id: "t1".to_owned(),
3263                base_version: Some(1),
3264                values: None,
3265            },
3266            OutboxOp {
3267                upsert: true,
3268                table: "tasks".to_owned(),
3269                row_id: "status-event-1".to_owned(),
3270                base_version: Some(0),
3271                values: None,
3272            },
3273        ];
3274
3275        {
3276            let mut first = SyncClient::open_path(
3277                "durable-native".to_owned(),
3278                &schema,
3279                ClientLimits::default(),
3280                path.to_str().expect("UTF-8 temp path"),
3281            )
3282            .expect("first open");
3283            first
3284                .begin_observation("test_outcome")
3285                .expect("begin outcome");
3286            first
3287                .persist_commit_outcome(
3288                    "losing-commit",
3289                    CommitOutcomeStatus::Conflict,
3290                    &[CommitOperationOutcome::Conflict {
3291                        conflict: conflict.clone(),
3292                    }],
3293                    Some(&failed_operations),
3294                )
3295                .expect("persist outcome");
3296            first.conflicts.push(conflict);
3297            first
3298                .finish_observation(
3299                    "test_outcome",
3300                    ChangeAccumulator {
3301                        conflicts: true,
3302                        outcomes: true,
3303                        ..ChangeAccumulator::default()
3304                    },
3305                )
3306                .expect("commit outcome");
3307        }
3308
3309        {
3310            let mut reopened = SyncClient::open_path(
3311                "durable-native".to_owned(),
3312                &schema,
3313                ClientLimits::default(),
3314                path.to_str().expect("UTF-8 temp path"),
3315            )
3316            .expect("reopen");
3317            assert_eq!(reopened.conflicts().len(), 1);
3318            assert_eq!(
3319                reopened.conflicts()[0].conflict_columns,
3320                vec!["project_id".to_owned()],
3321                "conflictColumns survives the journal round trip"
3322            );
3323            let outcome = reopened
3324                .commit_outcome("losing-commit")
3325                .expect("read outcome")
3326                .expect("outcome");
3327            assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
3328            let operations = outcome.operations.expect("aggregate envelope");
3329            assert_eq!(operations.len(), 2);
3330            assert_eq!(operations[1].row_id, "status-event-1");
3331            let resolved = reopened
3332                .resolve_commit_outcome(ResolveCommitOutcomeInput {
3333                    client_commit_id: "losing-commit".to_owned(),
3334                    resolution: CommitOutcomeResolution::ResolvedKeepServer,
3335                    replacement_client_commit_id: None,
3336                })
3337                .expect("resolve");
3338            assert_eq!(
3339                resolved.resolution,
3340                CommitOutcomeResolution::ResolvedKeepServer
3341            );
3342            assert!(reopened.conflicts().is_empty());
3343        }
3344
3345        let reopened = SyncClient::open_path(
3346            "durable-native".to_owned(),
3347            &schema,
3348            ClientLimits::default(),
3349            path.to_str().expect("UTF-8 temp path"),
3350        )
3351        .expect("second reopen");
3352        assert!(reopened.conflicts().is_empty());
3353        assert_eq!(
3354            reopened
3355                .commit_outcome("losing-commit")
3356                .expect("read outcome")
3357                .expect("outcome")
3358                .resolution,
3359            CommitOutcomeResolution::ResolvedKeepServer
3360        );
3361        drop(reopened);
3362        std::fs::remove_file(path).expect("remove temp database");
3363    }
3364
3365    #[test]
3366    fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
3367        let path =
3368            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
3369        let schema = json!({
3370            "version": 1,
3371            "tables": [{
3372                "name": "tasks",
3373                "primaryKey": "id",
3374                "columns": [
3375                    { "name": "id", "type": "string", "nullable": false },
3376                    { "name": "project_id", "type": "string", "nullable": false }
3377                ],
3378                "scopes": [{ "pattern": "project:{project_id}" }]
3379            }]
3380        });
3381        let mut client = SyncClient::open_path_with_identity(
3382            Some("sidecar-client".to_owned()),
3383            &schema,
3384            ClientLimits::default(),
3385            path.to_str().expect("UTF-8 temp path"),
3386        )
3387        .expect("open owner");
3388        let base = WindowBase {
3389            table: "tasks".to_owned(),
3390            variable: "project_id".to_owned(),
3391            fixed_scopes: Vec::new(),
3392            params: None,
3393        };
3394        client
3395            .set_window(&base, &["one".to_owned()])
3396            .expect("set window");
3397        client
3398            .mutate(vec![Mutation::Upsert {
3399                table: "tasks".to_owned(),
3400                values: Map::from_iter([
3401                    ("id".to_owned(), Value::from("t1")),
3402                    ("project_id".to_owned(), Value::from("one")),
3403                ]),
3404                base_version: None,
3405            }])
3406            .expect("local mutate");
3407
3408        let coverage = [WindowCoverage {
3409            base,
3410            units: vec!["one".to_owned(), "missing".to_owned()],
3411        }];
3412        let owner = client
3413            .query_snapshot(
3414                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3415                &[],
3416                &coverage,
3417            )
3418            .expect("owner snapshot");
3419        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
3420        let sidecar = reader
3421            .query_snapshot(
3422                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3423                &[],
3424                &coverage,
3425            )
3426            .expect("sidecar snapshot");
3427
3428        assert_eq!(sidecar.revision, owner.revision);
3429        assert_eq!(sidecar.rows, owner.rows);
3430        assert_eq!(
3431            serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
3432            serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
3433        );
3434        assert_eq!(sidecar.revision, "2");
3435        assert_eq!(sidecar.rows[0]["id"], "t1");
3436        assert_eq!(sidecar.rows[0]["server_version"], -1);
3437        assert!(!sidecar.coverage.complete);
3438        assert_eq!(sidecar.coverage.pending.len(), 1);
3439        assert_eq!(sidecar.coverage.missing.len(), 1);
3440
3441        drop(reader);
3442        drop(client);
3443        std::fs::remove_file(path).expect("remove temp database");
3444    }
3445
3446    #[test]
3447    fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
3448        let mut client = client();
3449        let image = Connection::open_in_memory().unwrap();
3450        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
3451            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3452            INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
3453            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3454            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
3455        let table = client.schema.table("tasks").unwrap().clone();
3456        let scopes = vec![("project_id".into(), vec!["p1".into()])];
3457        assert!(client
3458            .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
3459            .is_err());
3460        assert!(client.conn.is_autocommit());
3461        assert_eq!(
3462            client
3463                .query("SELECT count(*) AS n FROM tasks", &[])
3464                .unwrap()[0]["n"],
3465            1024
3466        );
3467        assert_eq!(client.drain_change_batches().len(), 1);
3468        image
3469            .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
3470            .unwrap();
3471        assert!(matches!(
3472            client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
3473            Ok(2050)
3474        ));
3475        assert_eq!(
3476            client
3477                .query("SELECT count(*) AS n FROM tasks", &[])
3478                .unwrap()[0]["n"],
3479            2050
3480        );
3481        assert_eq!(client.drain_change_batches().len(), 3);
3482    }
3483
3484    #[test]
3485    fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
3486        let mut client = client();
3487        let base = WindowBase {
3488            table: "tasks".into(),
3489            variable: "project_id".into(),
3490            fixed_scopes: Vec::new(),
3491            params: None,
3492        };
3493        client
3494            .set_window(&base, &["p1".into(), "p2".into()])
3495            .unwrap();
3496        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3497            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3498            INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
3499            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3500            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3501        client.drain_change_batches();
3502        assert!(client.set_window(&base, &["p2".into()]).is_err());
3503        assert!(client.conn.is_autocommit());
3504        assert_eq!(
3505            client
3506                .query("SELECT count(*) AS n FROM tasks", &[])
3507                .unwrap()[0]["n"],
3508            1027
3509        );
3510        assert_eq!(client.window_state(&base).units, vec!["p2"]);
3511        assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
3512        assert_eq!(client.drain_change_batches().len(), 1);
3513        client
3514            .conn
3515            .execute_batch("DROP TRIGGER interrupt_evict")
3516            .unwrap();
3517        client.drain_pending_evictions().unwrap();
3518        assert!(client.load_pending_evictions().unwrap().is_empty());
3519        assert_eq!(
3520            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3521            "held"
3522        );
3523        assert_eq!(client.drain_change_batches().len(), 2);
3524    }
3525
3526    #[test]
3527    fn eviction_marker_failures_roll_back_window_changes() {
3528        let mut client = client();
3529        let base = WindowBase {
3530            table: "tasks".into(),
3531            variable: "project_id".into(),
3532            fixed_scopes: Vec::new(),
3533            params: None,
3534        };
3535        client.set_window(&base, &["p1".into()]).unwrap();
3536        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3537            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3538            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3539            CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3540        let revision = client.local_revision();
3541        assert!(client.set_window(&base, &[]).is_err());
3542        assert!(client.conn.is_autocommit());
3543        assert_eq!(client.local_revision(), revision);
3544        assert_eq!(
3545            client
3546                .query("SELECT count(*) AS n FROM tasks", &[])
3547                .unwrap()[0]["n"],
3548            2050
3549        );
3550        assert_eq!(client.window_state(&base).units, vec!["p1"]);
3551        client.conn.execute_batch("DROP TRIGGER fail_marker;
3552            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3553        assert!(client.set_window(&base, &[]).is_err());
3554        client.conn.execute_batch("DROP TRIGGER interrupt_evict;
3555            CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3556        assert!(client.drain_pending_evictions().is_err());
3557        assert!(client.conn.is_autocommit());
3558        assert_eq!(
3559            client
3560                .query("SELECT count(*) AS n FROM tasks", &[])
3561                .unwrap()[0]["n"],
3562            2
3563        );
3564        assert!(client.set_window(&base, &["p1".into()]).is_err());
3565        assert!(client.conn.is_autocommit());
3566        assert!(client.window_state(&base).units.is_empty());
3567        assert!(client.subs.is_empty());
3568        assert_eq!(
3569            client
3570                .query(
3571                    "SELECT count(*) AS n FROM _syncular_window_pending_evict",
3572                    &[]
3573                )
3574                .unwrap()[0]["n"],
3575            1
3576        );
3577        client
3578            .conn
3579            .execute_batch("DROP TRIGGER fail_marker")
3580            .unwrap();
3581        client.set_window(&base, &["p1".into()]).unwrap();
3582        assert_eq!(client.window_state(&base).units, vec!["p1"]);
3583    }
3584
3585    #[test]
3586    fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
3587        let mut client = client();
3588        client
3589            .conn
3590            .execute(
3591                "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
3592                [],
3593            )
3594            .unwrap();
3595        assert_eq!(
3596            client.drain_pending_evictions().unwrap_err(),
3597            "sync.local_corrupt: invalid pending eviction scopes"
3598        );
3599        assert!(client.conn.is_autocommit());
3600        assert!(client.enqueue_startup_sync_if_needed().is_err());
3601    }
3602
3603    #[test]
3604    fn image_chunks_reject_null_primary_keys() {
3605        let mut client = client();
3606        let image = Connection::open_in_memory().unwrap();
3607        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3608            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
3609            INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
3610            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3611            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
3612        let table = client.schema.table("tasks").unwrap().clone();
3613        assert!(client
3614            .apply_sqlite_image(
3615                &image,
3616                &table,
3617                true,
3618                &[("project_id".into(), vec!["p1".into()])],
3619                1025,
3620                7,
3621                "digest"
3622            )
3623            .is_err());
3624        assert!(client.conn.is_autocommit());
3625        assert_eq!(
3626            client
3627                .query("SELECT count(*) AS n FROM tasks", &[])
3628                .unwrap()[0]["n"],
3629            0
3630        );
3631    }
3632
3633    #[test]
3634    fn import_chunks_with_unique_constraints_match_full_pending_replay() {
3635        for images in [false, true] {
3636            let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
3637                "name":name,"primaryKey":"id","columns":[
3638                    {"name":"id","type":"string","nullable":false},
3639                    {"name":"project_id","type":"string","nullable":false},
3640                    {"name":"title","type":"string","nullable":false}],
3641                "scopes":[{"pattern":"project:{project_id}"}],
3642                "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
3643                "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
3644            })))});
3645            let mut actual =
3646                SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
3647            let mut reference =
3648                SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
3649            let scopes = vec![("project_id".into(), vec!["p1".into()])];
3650            for client in [&mut actual, &mut reference] {
3651                client
3652                    .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
3653                    .unwrap();
3654                client.subs[0].effective = Some(scopes.clone());
3655                for (table, id, title) in [
3656                    ("tasks", "a", "original"),
3657                    ("tasks", "b", "occupied"),
3658                    ("tasks", "c", "deleted"),
3659                    ("notes", "n", "untouched"),
3660                ] {
3661                    client
3662                        .write_base_row(
3663                            table,
3664                            &vec![
3665                                Some(ColumnValue::String(id.into())),
3666                                Some(ColumnValue::String("p1".into())),
3667                                Some(ColumnValue::String(title.into())),
3668                            ],
3669                            1,
3670                        )
3671                        .unwrap();
3672                }
3673                client.rebuild_overlay_if_dirty();
3674                client
3675                    .mutate(vec![
3676                        Mutation::Upsert {
3677                            table: "tasks".into(),
3678                            values: Map::from_iter([
3679                                ("id".into(), Value::from("a")),
3680                                ("project_id".into(), Value::from("p1")),
3681                                ("title".into(), Value::from("occupied")),
3682                            ]),
3683                            base_version: None,
3684                        },
3685                        Mutation::Delete {
3686                            table: "tasks".into(),
3687                            row_id: "c".into(),
3688                            base_version: None,
3689                        },
3690                        Mutation::Upsert {
3691                            table: "tasks".into(),
3692                            values: Map::from_iter([
3693                                ("id".into(), Value::from("local")),
3694                                ("project_id".into(), Value::from("p1")),
3695                                ("title".into(), Value::from("local value")),
3696                            ]),
3697                            base_version: None,
3698                        },
3699                        Mutation::Upsert {
3700                            table: "notes".into(),
3701                            values: Map::from_iter([
3702                                ("id".into(), Value::from("n")),
3703                                ("project_id".into(), Value::from("p1")),
3704                                ("title".into(), Value::from("pending note")),
3705                            ]),
3706                            base_version: None,
3707                        },
3708                    ])
3709                    .unwrap();
3710            }
3711            let pending = actual.pending_commit_ids();
3712            actual.overlay_rebuild_count.set(0);
3713            for (id, title) in [
3714                ("b", "available"),
3715                ("b", "occupied"),
3716                ("a", "server original"),
3717                ("d", "local value"),
3718                ("d", "released"),
3719                ("c", "redelivered"),
3720            ] {
3721                let table = actual.schema.table("tasks").unwrap().clone();
3722                let values = vec![
3723                    Some(ColumnValue::String(id.into())),
3724                    Some(ColumnValue::String("p1".into())),
3725                    Some(ColumnValue::String(title.into())),
3726                ];
3727                if images {
3728                    let image = Connection::open_in_memory().unwrap();
3729                    image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3730                        CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3731                        INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
3732                    image
3733                        .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
3734                        .unwrap();
3735                    assert!(matches!(
3736                        actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
3737                        Ok(1)
3738                    ));
3739                } else {
3740                    let segment = RowsSegment {
3741                        table: "tasks".into(),
3742                        schema_version: 1,
3743                        columns: table.wire_columns.clone(),
3744                        blocks: vec![vec![ssp2::segment::SegmentRow {
3745                            server_version: 2,
3746                            values: values.clone(),
3747                        }]],
3748                    };
3749                    assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
3750                }
3751                reference.write_base_row("tasks", &values, 2).unwrap();
3752                reference.rebuild_overlay();
3753                for table in ["tasks", "notes"] {
3754                    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")] {
3755                        assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
3756                    }
3757                }
3758                assert_eq!(actual.pending_commit_ids(), pending);
3759            }
3760            assert_eq!(
3761                actual.overlay_rebuild_count.get(),
3762                0,
3763                "each chunk must leave unrelated tables alone"
3764            );
3765            let before = actual
3766                .query("SELECT * FROM tasks ORDER BY id", &[])
3767                .unwrap();
3768            let fts_before = actual
3769                .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
3770                .unwrap();
3771            let revision = actual.local_revision();
3772            actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
3773            let table = actual.schema.table("tasks").unwrap().clone();
3774            let segment = RowsSegment {
3775                table: "tasks".into(),
3776                schema_version: 1,
3777                columns: table.wire_columns.clone(),
3778                blocks: vec![vec![ssp2::segment::SegmentRow {
3779                    server_version: 3,
3780                    values: vec![
3781                        Some(ColumnValue::String("b".into())),
3782                        Some(ColumnValue::String("p1".into())),
3783                        Some(ColumnValue::String("fresh".into())),
3784                    ],
3785                }]],
3786            };
3787            assert!(actual.apply_segment(0, &segment, false).is_err());
3788            assert!(actual.conn.is_autocommit());
3789            assert_eq!(
3790                actual
3791                    .query("SELECT * FROM tasks ORDER BY id", &[])
3792                    .unwrap(),
3793                before
3794            );
3795            assert_eq!(
3796                actual
3797                    .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
3798                    .unwrap(),
3799                fts_before
3800            );
3801            assert_eq!(actual.local_revision(), revision);
3802            assert_eq!(actual.pending_commit_ids(), pending);
3803        }
3804    }
3805
3806    #[test]
3807    fn import_reconciliation_preserves_typed_pending_primary_keys() {
3808        for (kind, a, b) in [
3809            ("string", json!("a"), json!("b")),
3810            ("integer", json!(1), json!(2)),
3811            ("boolean", json!(true), json!(false)),
3812            ("json", json!("1"), json!("2")),
3813        ] {
3814            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}]}]});
3815            for images in [false, true] {
3816                let mut client =
3817                    SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
3818                let table = client.schema.table("tasks").unwrap().clone();
3819                client
3820                    .subscribe("tasks".into(), "tasks".into(), vec![], None)
3821                    .unwrap();
3822                for (id, title) in [(&a, "original"), (&b, "occupied")] {
3823                    client
3824                        .write_base_row(
3825                            "tasks",
3826                            &vec![
3827                                json_to_column_value(&table.columns[0], Some(id)).unwrap(),
3828                                Some(ColumnValue::String(title.into())),
3829                            ],
3830                            1,
3831                        )
3832                        .unwrap();
3833                }
3834                client.rebuild_overlay_if_dirty();
3835                client
3836                    .mutate(vec![Mutation::Upsert {
3837                        table: "tasks".into(),
3838                        values: Map::from_iter([
3839                            ("id".into(), a.clone()),
3840                            ("title".into(), json!("occupied")),
3841                        ]),
3842                        base_version: None,
3843                    }])
3844                    .unwrap();
3845                client.overlay_rebuild_count.set(0);
3846                for (title, expected) in [("available", "occupied"), ("occupied", "original")] {
3847                    let values = vec![
3848                        json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
3849                        Some(ColumnValue::String(title.into())),
3850                    ];
3851                    if images {
3852                        let image = Connection::open_in_memory().unwrap();
3853                        image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3854                            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3855                            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
3856                        image
3857                            .execute(
3858                                "INSERT INTO tasks VALUES (?1,?2,2)",
3859                                rusqlite::params![RowParam::Cell(&values[0]), title],
3860                            )
3861                            .unwrap();
3862                        assert!(
3863                            matches!(
3864                                client.apply_sqlite_image(
3865                                    &image,
3866                                    &table,
3867                                    false,
3868                                    &[],
3869                                    1,
3870                                    7,
3871                                    "digest"
3872                                ),
3873                                Ok(1)
3874                            ),
3875                            "{kind}, images={images}"
3876                        );
3877                    } else {
3878                        let segment = RowsSegment {
3879                            table: "tasks".into(),
3880                            schema_version: 1,
3881                            columns: table.wire_columns.clone(),
3882                            blocks: vec![vec![ssp2::segment::SegmentRow {
3883                                server_version: 2,
3884                                values,
3885                            }]],
3886                        };
3887                        assert!(
3888                            matches!(client.apply_segment(0, &segment, false), Ok(1)),
3889                            "{kind}"
3890                        );
3891                    }
3892                    let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
3893                    let actual: String = client
3894                        .conn
3895                        .query_row(
3896                            "SELECT title FROM tasks WHERE id=?1",
3897                            [RowParam::Cell(&key)],
3898                            |row| row.get(0),
3899                        )
3900                        .unwrap();
3901                    assert_eq!(actual, expected, "{kind}, images={images}");
3902                }
3903                assert_eq!(client.overlay_rebuild_count.get(), 0);
3904            }
3905        }
3906    }
3907
3908    fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
3909        let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
3910            "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
3911            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
3912            "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
3913        })))});
3914        let mut client =
3915            SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
3916        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();
3917        client.rebuild_overlay();
3918        client
3919            .mutate(vec![Mutation::Upsert {
3920                table: "tasks".into(),
3921                values: Map::from_iter([
3922                    ("id".into(), json!("offline")),
3923                    ("project_id".into(), json!("p1")),
3924                    ("title".into(), json!("pending edit")),
3925                ]),
3926                base_version: None,
3927            }])
3928            .unwrap();
3929        let image = Connection::open_in_memory().unwrap();
3930        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3931            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
3932        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();
3933        image
3934            .execute(
3935                "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
3936                [rows],
3937            )
3938            .unwrap();
3939        client.overlay_rebuild_count.set(0);
3940        let table = client.schema.table("tasks").unwrap().clone();
3941        (client, image, table)
3942    }
3943
3944    #[test]
3945    fn large_unique_import_copies_no_unrelated_tables() {
3946        let (mut client, image, table) = unique_import_fixture(10240);
3947        let pending = client.pending_commit_ids();
3948        client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
3949            CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
3950        assert!(matches!(
3951            client.apply_sqlite_image(
3952                &image,
3953                &table,
3954                true,
3955                &[("project_id".into(), vec!["p1".into()])],
3956                10240,
3957                7,
3958                "digest"
3959            ),
3960            Ok(10240)
3961        ));
3962        assert_eq!(client.overlay_rebuild_count.get(), 0);
3963        assert_eq!(client.pending_commit_ids(), pending);
3964        assert_eq!(
3965            client
3966                .query("SELECT count(*) AS n FROM tasks", &[])
3967                .unwrap()[0]["n"],
3968            10241
3969        );
3970        assert_eq!(
3971            client
3972                .query("SELECT count(*) AS n FROM tasks_fts", &[])
3973                .unwrap()[0]["n"],
3974            10241
3975        );
3976        assert_eq!(
3977            client
3978                .query("SELECT count(*) AS n FROM notes_fts", &[])
3979                .unwrap()[0]["n"],
3980            8192
3981        );
3982    }
3983
3984    #[test]
3985    #[ignore = "manual scaling benchmark; see bench/README.md"]
3986    fn benchmark_unique_image_import() {
3987        let mut samples = Vec::new();
3988        for rows in [10240, 51200, 102400] {
3989            for trial in 0..4 {
3990                let (mut client, image, table) = unique_import_fixture(rows);
3991                let start = std::time::Instant::now();
3992                assert!(
3993                    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)
3994                );
3995                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
3996                assert_eq!(
3997                    client
3998                        .query("SELECT count(*) AS n FROM tasks", &[])
3999                        .unwrap()[0]["n"],
4000                    rows + 1
4001                );
4002                assert_eq!(
4003                    client
4004                        .query("SELECT count(*) AS n FROM tasks_fts", &[])
4005                        .unwrap()[0]["n"],
4006                    rows + 1
4007                );
4008                assert_eq!(
4009                    client
4010                        .query("SELECT count(*) AS n FROM notes_fts", &[])
4011                        .unwrap()[0]["n"],
4012                    8192
4013                );
4014                assert_eq!(client.pending_commit_ids().len(), 1);
4015                if trial > 0 {
4016                    samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
4017                }
4018            }
4019        }
4020        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});
4021        let output = serde_json::to_string_pretty(&artifact).unwrap();
4022        if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
4023            use std::io::Write;
4024            let mut file = std::fs::OpenOptions::new()
4025                .write(true)
4026                .create_new(true)
4027                .open(path)
4028                .unwrap();
4029            file.write_all(output.as_bytes()).unwrap();
4030        } else {
4031            println!("{output}");
4032        }
4033    }
4034
4035    #[test]
4036    fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
4037        let schema = json!({
4038            "version": 1,
4039            "tables": [{
4040                "name": "catalogue_codes",
4041                "primaryKey": "id",
4042                "columns": [
4043                    { "name": "id", "type": "string", "nullable": false },
4044                    { "name": "release_id", "type": "string", "nullable": false },
4045                    { "name": "code", "type": "string", "nullable": false },
4046                    { "name": "title", "type": "string", "nullable": false }
4047                ],
4048                "scopes": [{ "pattern": "release:{release_id}" }],
4049                "ftsIndexes": [{
4050                    "name": "catalogue_codes_fts",
4051                    "columns": ["code", "title"],
4052                    "tokenize": "unicode61 remove_diacritics 2"
4053                }]
4054            }]
4055        });
4056        let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
4057            .expect("FTS5 client");
4058        let insert_trigger: String = client
4059            .conn
4060            .query_row(
4061                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
4062                [],
4063                |row| row.get(0),
4064            )
4065            .expect("insert trigger");
4066        let replace_guard: String = client
4067            .conn
4068            .query_row(
4069                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
4070                [],
4071                |row| row.get(0),
4072            )
4073            .expect("replace guard");
4074        assert!(!insert_trigger.contains("DELETE FROM"));
4075        assert!(replace_guard.contains("BEFORE INSERT"));
4076        assert!(replace_guard.contains("WHEN EXISTS"));
4077        let search = |client: &SyncClient, query: &str| {
4078            client
4079                .query(
4080                    "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",
4081                    &[Value::from(query)],
4082                )
4083                .expect("FTS query")
4084        };
4085
4086        client
4087            .mutate(vec![Mutation::Upsert {
4088                table: "catalogue_codes".to_owned(),
4089                values: Map::from_iter([
4090                    ("id".to_owned(), Value::from("c1")),
4091                    ("release_id".to_owned(), Value::from("r1")),
4092                    ("code".to_owned(), Value::from("A01")),
4093                    ("title".to_owned(), Value::from("Cholera")),
4094                ]),
4095                base_version: None,
4096            }])
4097            .expect("insert code");
4098        assert_eq!(search(&client, "cholera").len(), 1);
4099        let prior = client
4100            .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4101            .unwrap();
4102        client
4103            .conn
4104            .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
4105            .unwrap();
4106        client.create_synced_tables().unwrap();
4107        assert_eq!(
4108            client
4109                .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4110                .unwrap(),
4111            prior
4112        );
4113        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();
4114        let rendered = serde_json::to_string(&plan).unwrap();
4115        assert!(rendered.contains("COVERING INDEX"));
4116        assert!(rendered.contains("INDEX 0:="));
4117        client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
4118        assert_eq!(search(&client, "cholera").len(), 1);
4119
4120        client
4121            .mutate(vec![Mutation::Upsert {
4122                table: "catalogue_codes".to_owned(),
4123                values: Map::from_iter([
4124                    ("id".to_owned(), Value::from("c1")),
4125                    ("release_id".to_owned(), Value::from("r1")),
4126                    ("code".to_owned(), Value::from("A01")),
4127                    ("title".to_owned(), Value::from("Enteric infection")),
4128                ]),
4129                base_version: None,
4130            }])
4131            .expect("update code");
4132        assert!(search(&client, "cholera").is_empty());
4133        assert_eq!(search(&client, "enteric").len(), 1);
4134
4135        client
4136            .mutate(vec![Mutation::Delete {
4137                table: "catalogue_codes".to_owned(),
4138                row_id: "c1".to_owned(),
4139                base_version: None,
4140            }])
4141            .expect("delete code");
4142        assert!(search(&client, "enteric").is_empty());
4143    }
4144
4145    #[test]
4146    fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
4147        let schema = json!({
4148            "version": 1,
4149            "tables": [{
4150                "name": "patient_notes",
4151                "primaryKey": "id",
4152                "columns": [
4153                    { "name": "id", "type": "string", "nullable": false },
4154                    { "name": "practice_id", "type": "string", "nullable": false },
4155                    { "name": "encryption_key_id", "type": "string", "nullable": false },
4156                    { "name": "title", "type": "string", "nullable": false }
4157                ],
4158                "scopes": [{ "pattern": "practice:{practice_id}" }],
4159                "ftsIndexes": [{
4160                    "name": "patient_notes_fts",
4161                    "columns": ["title"],
4162                    "tokenize": "unicode61 remove_diacritics 2"
4163                }]
4164            }]
4165        });
4166        let mut client = SyncClient::new(
4167            "local-purge-test".to_owned(),
4168            &schema,
4169            ClientLimits::default(),
4170        )
4171        .expect("local purge client");
4172        client
4173            .conn
4174            .execute(
4175                "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
4176                 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
4177                 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
4178                [],
4179            )
4180            .expect("seed base rows");
4181        client.overlay_dirty.set(true);
4182        client.rebuild_overlay_if_dirty();
4183
4184        let note = |id: &str, key_id: &str, title: &str| {
4185            Map::from_iter([
4186                ("id".to_owned(), Value::from(id)),
4187                ("practice_id".to_owned(), Value::from("practice-1")),
4188                ("encryption_key_id".to_owned(), Value::from(key_id)),
4189                ("title".to_owned(), Value::from(title)),
4190            ])
4191        };
4192        let doomed = client
4193            .mutate(vec![
4194                Mutation::Upsert {
4195                    table: "patient_notes".to_owned(),
4196                    values: note("target", "key-revoked", "Target changed"),
4197                    base_version: None,
4198                },
4199                Mutation::Upsert {
4200                    table: "patient_notes".to_owned(),
4201                    values: note("unrelated", "key-held", "Unrelated changed"),
4202                    base_version: None,
4203                },
4204            ])
4205            .expect("doomed commit");
4206        let kept = client
4207            .mutate(vec![Mutation::Upsert {
4208                table: "patient_notes".to_owned(),
4209                values: note("kept", "key-held", "Kept optimistic"),
4210                base_version: None,
4211            }])
4212            .expect("kept commit");
4213        client.drain_change_batches();
4214
4215        let input = LocalDataPurgeInput {
4216            purge_id: "purge-001".to_owned(),
4217            targets: vec![LocalDataPurgeTarget {
4218                table: "patient_notes".to_owned(),
4219                selectors: BTreeMap::from([(
4220                    "encryption_key_id".to_owned(),
4221                    vec!["key-revoked".to_owned()],
4222                )]),
4223            }],
4224        };
4225        assert_eq!(
4226            client.purge_local_data(&input).expect("apply purge"),
4227            LocalDataPurgeResult {
4228                already_applied: false,
4229                purged_rows: 1,
4230                dropped_commits: 1,
4231            }
4232        );
4233        let rows = client
4234            .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
4235            .expect("visible rows");
4236        assert_eq!(rows.len(), 2);
4237        assert_eq!(rows[0]["id"], "kept");
4238        assert_eq!(rows[1]["id"], "unrelated");
4239        assert_eq!(rows[1]["title"], "Unrelated original");
4240        let fts = client
4241            .query(
4242                "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'",
4243                &[],
4244            )
4245            .expect("fts query");
4246        assert!(fts.is_empty());
4247        assert_eq!(client.outbox.len(), 1);
4248        assert_eq!(client.outbox[0].client_commit_id, kept);
4249        let outcome = client
4250            .commit_outcome(&doomed)
4251            .expect("read doomed outcome")
4252            .expect("doomed outcome");
4253        assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
4254        match &outcome.results[0] {
4255            CommitOperationOutcome::Error { rejection } => {
4256                assert_eq!(rejection.code, "client.local_data_purged");
4257                assert_eq!(rejection.client_commit_id, doomed);
4258            }
4259            other => panic!("expected local purge rejection, got {other:?}"),
4260        }
4261        assert_eq!(client.drain_change_batches().len(), 1);
4262        assert_eq!(
4263            client.purge_local_data(&input).expect("retry purge"),
4264            LocalDataPurgeResult {
4265                already_applied: true,
4266                purged_rows: 0,
4267                dropped_commits: 0,
4268            }
4269        );
4270        let conflicting = LocalDataPurgeInput {
4271            purge_id: input.purge_id.clone(),
4272            targets: vec![LocalDataPurgeTarget {
4273                table: "patient_notes".to_owned(),
4274                selectors: BTreeMap::from([(
4275                    "encryption_key_id".to_owned(),
4276                    vec!["key-held".to_owned()],
4277                )]),
4278            }],
4279        };
4280        assert!(client
4281            .purge_local_data(&conflicting)
4282            .expect_err("id collision must fail")
4283            .contains("already used with a different plan"));
4284    }
4285}
4286
4287impl SubState {
4288    fn name(self) -> &'static str {
4289        match self {
4290            SubState::Active => "active",
4291            SubState::Revoked => "revoked",
4292            SubState::Failed => "failed",
4293        }
4294    }
4295
4296    fn parse(value: &str) -> Self {
4297        match value {
4298            "revoked" => Self::Revoked,
4299            "failed" => Self::Failed,
4300            _ => Self::Active,
4301        }
4302    }
4303}
4304
4305#[derive(Debug, Clone)]
4306struct Subscription {
4307    id: String,
4308    table: String,
4309    requested: Vec<(String, Vec<String>)>,
4310    params: Option<String>,
4311    cursor: i64,
4312    /// §4.7 resume token, round-tripped opaquely.
4313    bootstrap_state: Option<String>,
4314    state: SubState,
4315    reason_code: Option<String>,
4316    /// Last effective scopes echoed while active (§3.3: persisted for the
4317    /// purge contract; each active echo replaces it).
4318    effective: Option<Vec<(String, Vec<String>)>>,
4319    synced_once: bool,
4320}
4321
4322#[derive(Debug, Clone)]
4323struct OutboxOp {
4324    upsert: bool,
4325    table: String,
4326    row_id: String,
4327    base_version: Option<i64>,
4328    /// Schema-agnostic local form (§0): driver JSON values, encoded with
4329    /// the current codec at send time. A key's presence IS the §6.1 sparse
4330    /// presence set — both the overlay and the wire payload derive from it.
4331    values: Option<Map<String, Value>>,
4332}
4333
4334impl From<&OutboxOp> for CommitOperation {
4335    fn from(operation: &OutboxOp) -> Self {
4336        Self {
4337            table: operation.table.clone(),
4338            row_id: operation.row_id.clone(),
4339            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
4340            base_version: operation.base_version,
4341            values: operation.values.clone(),
4342        }
4343    }
4344}
4345
4346#[derive(Debug, Clone)]
4347struct OutboxCommit {
4348    client_commit_id: String,
4349    ops: Vec<OutboxOp>,
4350}
4351
4352/// One commit's `(table, value keys)` per upsert operation — the classification
4353/// shape the §7.4.4 send-time drop and the RFC 0005 D6 audit share, so the two
4354/// can never disagree about which commits cannot re-encode.
4355fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
4356    commit
4357        .ops
4358        .iter()
4359        .filter(|operation| operation.upsert)
4360        .map(|operation| {
4361            (
4362                operation.table.as_str(),
4363                operation
4364                    .values
4365                    .as_ref()
4366                    .map(|values| values.keys().map(String::as_str).collect())
4367                    .unwrap_or_default(),
4368            )
4369        })
4370        .collect()
4371}
4372
4373#[derive(Debug, Clone)]
4374struct CompiledLocalDataPurgeTarget {
4375    table: String,
4376    selectors: Vec<(String, Vec<String>)>,
4377}
4378
4379struct StoredCommitOutcomeRow {
4380    sequence: i64,
4381    client_commit_id: String,
4382    status: String,
4383    recorded_at_ms: i64,
4384    results_json: String,
4385    operations_json: Option<String>,
4386    resolution: String,
4387    resolved_at_ms: Option<i64>,
4388    replacement_client_commit_id: Option<String>,
4389}
4390
4391/// Section outcome distinguishing the §5.6 subscription-local fail-closed
4392/// path from a round-aborting failure (§1.4 rule 5).
4393enum SectionError {
4394    FailClosed,
4395    Abort(String, String),
4396}
4397
4398struct RequestMeta {
4399    pushed_ids: Vec<String>,
4400    /// Subscription id → the request carried `cursor < 0` and no resume
4401    /// token (§5.6 first-page detection: a *fresh* bootstrap).
4402    fresh: Vec<(String, bool)>,
4403    accept: u8,
4404    /// §6.1 splitBatch: outbox commits held back from THIS request because
4405    /// the running operation count reached the push cap — the next round
4406    /// pushes them (`sync_needed` stays set while any remain).
4407    deferred_commits: usize,
4408}
4409
4410#[derive(Default)]
4411struct ChangeAccumulator {
4412    /// None means table-wide; Some is the exact scoped domain.
4413    tables: BTreeMap<String, Option<BTreeSet<String>>>,
4414    windows: BTreeMap<(String, String), BTreeSet<String>>,
4415    status: bool,
4416    conflicts: bool,
4417    rejections: bool,
4418    outcomes: bool,
4419}
4420
4421impl ChangeAccumulator {
4422    fn table(&mut self, table: &str) {
4423        self.tables.insert(table.to_owned(), None);
4424    }
4425
4426    fn scope(&mut self, table: &str, key: String) {
4427        match self.tables.get_mut(table) {
4428            Some(None) => {}
4429            Some(Some(keys)) => {
4430                keys.insert(key);
4431            }
4432            None => {
4433                self.tables
4434                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
4435            }
4436        }
4437    }
4438
4439    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
4440        self.windows
4441            .entry((base_key.to_owned(), table.to_owned()))
4442            .or_default()
4443            .insert(unit.to_owned());
4444    }
4445
4446    fn touched(&self) -> bool {
4447        !self.tables.is_empty()
4448            || !self.windows.is_empty()
4449            || self.status
4450            || self.conflicts
4451            || self.rejections
4452            || self.outcomes
4453    }
4454}
4455
4456/// §6.1: the server caps total operations per request (reference default
4457/// 500) and rejects the whole batch with `sync.too_many_operations`; the
4458/// client "splits and retries". Splitting happens at build time: commits are
4459/// included IN ORDER until the operation budget is spent, the rest wait for
4460/// the next round.
4461const PUSH_OPS_PER_REQUEST: usize = 500;
4462const MAX_LOCAL_PURGE_TARGETS: usize = 64;
4463const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
4464const MAX_LOCAL_PURGE_VALUES: usize = 128;
4465const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
4466pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
4467
4468pub struct SyncClient {
4469    #[cfg(feature = "bench-internals")]
4470    benchmark_phases: Recorder,
4471    conn: Connection,
4472    schema: ClientSchema,
4473    client_id: String,
4474    limits: ClientLimits,
4475    subs: Vec<Subscription>,
4476    outbox: Vec<OutboxCommit>,
4477    conflicts: Vec<ConflictRecord>,
4478    rejections: Vec<RejectionRecord>,
4479    schema_floor: Option<SchemaFloor>,
4480    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
4481    lease_state: Option<LeaseState>,
4482    /// §1.6: the schema-floor response stops syncing until an upgrade.
4483    stopped: bool,
4484    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
4485    upgrading: bool,
4486    /// §8.4 coalesced sync-needed signal.
4487    sync_needed: bool,
4488    realtime_connected: bool,
4489    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
4490    presence: HashMap<String, HashMap<String, PresencePeer>>,
4491    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
4492    /// host may pin it (conformance runs on a virtual clock).
4493    now_ms: Option<i64>,
4494    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
4495    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
4496    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
4497    encryption: crate::values::EncryptionConfig,
4498    /// Fail-closed host bootstrap gate. While set, command hosts permit only
4499    /// status/lifecycle inspection and an exact authorized local purge.
4500    security_preflight: bool,
4501    /// RFC 0005 D2/D8: the previous-version capture config. `None` (default)
4502    /// means the feature is off; the descriptor write and the container orphan
4503    /// sweep still happen.
4504    previous_version: Option<PreviousVersionContextConfig>,
4505    /// Per-table primary-key upsert SQL, built once per (full table name) —
4506    /// the row write path runs per row during bootstrap (§5.6), so the SQL
4507    /// string (and, via `prepare_cached`, its compiled statement) is reused
4508    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
4509    /// schema reset (the column lists may have changed).
4510    insert_sql: RefCell<HashMap<String, String>>,
4511    /// §7.1 rebuild gate: true whenever the base tables or the outbox have
4512    /// diverged from the visible overlay since the last rebuild. Lets a
4513    /// no-op sync round skip the full base→visible copy.
4514    overlay_dirty: Cell<bool>,
4515    /// Test-only structural performance signal: response processing must not
4516    /// turn a batch of acknowledgements into one full overlay rebuild each.
4517    #[cfg(test)]
4518    overlay_rebuild_count: Cell<usize>,
4519    /// Test-only structural performance signal: outcome retention is enforced
4520    /// once per response rather than once per acknowledgement.
4521    #[cfg(test)]
4522    outcome_prune_count: Cell<usize>,
4523    /// Exact observer-transaction output drained by command/FFI hosts.
4524    progress: ProgressObserver,
4525    change_queue: VecDeque<ClientChangeBatch>,
4526    sync_intent_queue: VecDeque<SyncIntent>,
4527    /// Explicit exponential retry policy for transient transport failures.
4528    retry_delay_ms: u64,
4529    last_round: Option<DiagnosticLastRound>,
4530    last_change: Option<DiagnosticLastChange>,
4531}
4532
4533pub(crate) fn quote_ident(name: &str) -> String {
4534    format!("\"{}\"", name.replace('"', "\"\""))
4535}
4536/// §7.4.1 `_syncular_meta` helpers. Free functions so RFC 0005's
4537/// previous-version module reads and writes the SAME records as the client
4538/// instead of growing a parallel meta layer.
4539pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
4540    conn.query_row(
4541        "SELECT value FROM _syncular_meta WHERE key = ?1",
4542        rusqlite::params![key],
4543        |row| row.get::<_, String>(0),
4544    )
4545    .ok()
4546}
4547
4548pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
4549    let _ = conn.execute(
4550        "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
4551        rusqlite::params![key, value],
4552    );
4553}
4554
4555pub(crate) fn meta_delete(conn: &Connection, key: &str) {
4556    let _ = conn.execute(
4557        "DELETE FROM _syncular_meta WHERE key = ?1",
4558        rusqlite::params![key],
4559    );
4560}
4561
4562/// Seek by the existing key while retaining the exact legacy text match.
4563fn row_id_predicate(table: &TableSchema) -> String {
4564    let key = quote_ident(&table.primary_key);
4565    let text_match = format!("CAST({key} AS TEXT) = ?1");
4566    match table.columns[table.pk_index].ty {
4567        ColumnType::String | ColumnType::Json => {
4568            format!("{key} = ?1 AND {text_match}")
4569        }
4570        // Native columns have no declared SQLite affinity. Match the stored
4571        // integer bind type explicitly. Unary + removes expression affinity so
4572        // SQLite can seek the typeless key; the text check preserves exact IDs.
4573        ColumnType::Integer | ColumnType::Boolean => {
4574            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
4575        }
4576        // REAL-to-text conversion can round distinct values to the same text.
4577        // Narrowing those matches by numeric equality would change behavior.
4578        _ => text_match,
4579    }
4580}
4581
4582fn is_local_operation_code_like(value: &str) -> bool {
4583    let bytes = value.as_bytes();
4584    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
4585        && bytes
4586            .iter()
4587            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
4588}
4589
4590fn base_table(name: &str) -> String {
4591    quote_ident(&format!("_syncular_base_{name}"))
4592}
4593
4594/// §4.8: a stable, server-opaque key for a window base — table + variable +
4595/// canonical fixed scopes. Two `set_window` calls with the same base
4596/// address the same registry rows.
4597/// §4.8 deferred eviction record: (sub id, table, effective scope map).
4598type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
4599
4600fn window_base_key(base: &WindowBase) -> String {
4601    format!(
4602        "{}\0{}\0{}",
4603        base.table,
4604        base.variable,
4605        canonical_scope_json(&base.fixed_scopes)
4606    )
4607}
4608
4609/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
4610fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
4611    let mut scopes = base.fixed_scopes.clone();
4612    scopes.retain(|(k, _)| k != &base.variable);
4613    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
4614    scopes
4615}
4616
4617/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
4618/// echoed not interpreted by the server, so the exact hash is client
4619/// convention; SHA-256 matches the SPEC's worked example.
4620fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
4621    let canonical = canonical_scope_json(&unit_scopes(base, unit));
4622    let digest = Sha256::digest(canonical.as_bytes());
4623    let hex = bytes_to_hex(&digest);
4624    format!("w:{}:{}", base.table, &hex[..16])
4625}
4626
4627fn visible_table(name: &str) -> String {
4628    quote_ident(name)
4629}
4630
4631const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
4632
4633/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
4634/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
4635fn is_synced_table_name(name: &str) -> bool {
4636    if name.starts_with("sqlite_") {
4637        return false;
4638    }
4639    if name.starts_with("_syncular_base_") {
4640        return true; // the base half of a synced table pair
4641    }
4642    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
4643    !name.starts_with("_syncular_")
4644}
4645
4646/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
4647fn blob_id_for(bytes: &[u8]) -> String {
4648    let digest = Sha256::digest(bytes);
4649    format!("sha256:{}", bytes_to_hex(&digest))
4650}
4651
4652/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
4653/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
4654/// per row on the §5.6 bootstrap path), scalars bind owned.
4655enum RowParam<'a> {
4656    Cell(&'a Option<ColumnValue>),
4657    Version(i64),
4658}
4659
4660impl rusqlite::ToSql for RowParam<'_> {
4661    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
4662        Ok(match self {
4663            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
4664            RowParam::Cell(cell) => match cell {
4665                None => ToSqlOutput::Owned(SqlValue::Null),
4666                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
4667                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
4668                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
4669                Some(ColumnValue::Boolean(b)) => {
4670                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
4671                }
4672                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
4673                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
4674                }
4675                // §5.10: crdt bytes store as BLOB, like bytes.
4676                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
4677                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
4678                }
4679            },
4680        })
4681    }
4682}
4683
4684// Retain a typed primary key across the current import chunk without copying row payloads.
4685fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
4686    match param.to_sql().map_err(|error| error.to_string())? {
4687        ToSqlOutput::Owned(value) => Ok(value),
4688        ToSqlOutput::Borrowed(value) => {
4689            SqlValue::try_from(value).map_err(|error| error.to_string())
4690        }
4691        _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
4692    }
4693}
4694
4695/// §5.3 image cell → bind parameter, strict per the declared column type
4696/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
4697/// nullable). Mismatches are image-producer violations. Returns the cell
4698/// borrowed when the stored representation already matches what the row
4699/// codec would write, or the normalized scalar (boolean → 0/1, float from
4700/// INTEGER → REAL) otherwise — the local write is byte-identical to the
4701/// old convert-then-insert path without allocating per cell.
4702fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
4703    use ssp2::segment::ColumnType;
4704    let mismatch = || {
4705        Err(format!(
4706            "image column {:?} holds a value of the wrong type",
4707            column.name
4708        ))
4709    };
4710    match value {
4711        ValueRef::Null => {
4712            if !column.nullable {
4713                return Err(format!(
4714                    "image column {:?} is NULL but not nullable",
4715                    column.name
4716                ));
4717            }
4718            Ok(ToSqlOutput::Owned(SqlValue::Null))
4719        }
4720        ValueRef::Integer(i) => match column.ty {
4721            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
4722            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
4723            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
4724            _ => mismatch(),
4725        },
4726        ValueRef::Real(_) => match column.ty {
4727            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
4728            _ => mismatch(),
4729        },
4730        ValueRef::Text(t) => {
4731            std::str::from_utf8(t)
4732                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
4733            match column.ty {
4734                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
4735                    Ok(ToSqlOutput::Borrowed(value))
4736                }
4737                _ => mismatch(),
4738            }
4739        }
4740        ValueRef::Blob(_) => match column.ty {
4741            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
4742            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
4743            _ => mismatch(),
4744        },
4745    }
4746}
4747
4748pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
4749    use rusqlite::types::ValueRef;
4750    match value {
4751        ValueRef::Null => Value::Null,
4752        ValueRef::Integer(i) => match column.ty {
4753            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
4754            ssp2::segment::ColumnType::Float => {
4755                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
4756            }
4757            _ => Value::from(i),
4758        },
4759        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
4760        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
4761        ValueRef::Blob(b) => {
4762            let mut map = Map::new();
4763            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
4764            Value::Object(map)
4765        }
4766    }
4767}
4768
4769/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
4770/// Objects are accepted in lossless `{"$bytes": hex}` and
4771/// `{"$bigint": decimal}` envelope forms.
4772fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
4773    Ok(match value {
4774        Value::Null => SqlValue::Null,
4775        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
4776        Value::Number(n) => {
4777            if let Some(i) = n.as_i64() {
4778                SqlValue::Integer(i)
4779            } else if let Some(f) = n.as_f64() {
4780                SqlValue::Real(f)
4781            } else {
4782                return Err(format!("query param number {n} is out of range"));
4783            }
4784        }
4785        Value::String(s) => SqlValue::Text(s.clone()),
4786        Value::Object(_) => {
4787            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
4788                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
4789            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
4790                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
4791                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
4792                })?)
4793            } else {
4794                return Err(
4795                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
4796                        .to_owned(),
4797                );
4798            }
4799        }
4800        Value::Array(_) => return Err("query array params are not supported".to_owned()),
4801    })
4802}
4803
4804/// Map a rusqlite value with no schema column to consult (arbitrary query
4805/// output): integers/reals/text pass through by stored affinity, blobs ride
4806/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
4807/// schema column type to recover booleans/floats/json.
4808fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
4809    use rusqlite::types::ValueRef;
4810    match value {
4811        ValueRef::Null => Value::Null,
4812        ValueRef::Integer(i) => {
4813            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
4814            // ordinary UI-sized integers ergonomic and envelope only values
4815            // beyond JavaScript's safe range.
4816            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
4817            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
4818                Value::from(i)
4819            } else {
4820                let mut map = Map::new();
4821                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
4822                Value::Object(map)
4823            }
4824        }
4825        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
4826        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
4827        ValueRef::Blob(b) => {
4828            let mut map = Map::new();
4829            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
4830            Value::Object(map)
4831        }
4832    }
4833}
4834
4835fn query_connection(
4836    conn: &Connection,
4837    sql: &str,
4838    params: &[QueryValue],
4839) -> Result<Vec<QueryRow>, String> {
4840    crate::query_guard::assert_read_only_query(sql)?;
4841    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
4842    let bound: Vec<SqlValue> = params
4843        .iter()
4844        .map(json_param_to_sql)
4845        .collect::<Result<_, _>>()?;
4846    let mut stmt = conn.prepare(&lowered_sql).map_err(|e| e.to_string())?;
4847    let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
4848    let bound_refs: Vec<&dyn rusqlite::ToSql> =
4849        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
4850    let mut sql_rows = stmt
4851        .query(bound_refs.as_slice())
4852        .map_err(|e| e.to_string())?;
4853    let mut out = Vec::new();
4854    while let Some(row) = sql_rows.next().map_err(|e| e.to_string())? {
4855        let mut record = Map::new();
4856        for (i, name) in column_names.iter().enumerate() {
4857            let value = row.get_ref(i).map_err(|e| e.to_string())?;
4858            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
4859        }
4860        out.push(record);
4861    }
4862    Ok(out)
4863}
4864
4865fn persisted_window_state(conn: &Connection, base: &WindowBase) -> Result<WindowState, String> {
4866    let mut stmt = conn
4867        .prepare(
4868            "SELECT windows.unit, subscriptions.state_json
4869               FROM _syncular_windows AS windows
4870               JOIN _syncular_subscriptions AS subscriptions
4871                 ON subscriptions.id = windows.sub_id
4872              WHERE windows.base = ?1
4873              ORDER BY windows.unit ASC",
4874        )
4875        .map_err(|error| error.to_string())?;
4876    let rows = stmt
4877        .query_map(rusqlite::params![window_base_key(base)], |row| {
4878            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
4879        })
4880        .map_err(|error| error.to_string())?;
4881    let mut units = Vec::new();
4882    let mut pending = Vec::new();
4883    for row in rows {
4884        let (unit, raw) = row.map_err(|error| error.to_string())?;
4885        let state: Value = serde_json::from_str(&raw)
4886            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
4887        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
4888            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
4889            || state
4890                .get("bootstrapState")
4891                .is_some_and(|value| !value.is_null());
4892        if is_pending {
4893            pending.push(unit.clone());
4894        }
4895        units.push(unit);
4896    }
4897    Ok(WindowState { units, pending })
4898}
4899
4900fn snapshot_connection(
4901    conn: &Connection,
4902    sql: &str,
4903    params: &[Value],
4904    coverage: &[WindowCoverage],
4905) -> Result<QuerySnapshot, String> {
4906    conn.execute_batch("SAVEPOINT syncular_snapshot_read")
4907        .map_err(|error| error.to_string())?;
4908    let result = (|| {
4909        let revision = conn
4910            .query_row(
4911                "SELECT value FROM _syncular_meta WHERE key = ?1",
4912                rusqlite::params![LOCAL_REVISION_KEY],
4913                |row| row.get::<_, String>(0),
4914            )
4915            .ok()
4916            .and_then(|value| value.parse::<u64>().ok())
4917            .unwrap_or(0);
4918        let rows = query_connection(conn, sql, params)?;
4919        let mut pending = Vec::new();
4920        let mut missing = Vec::new();
4921        for requested in coverage {
4922            let base_key = window_base_key(&requested.base);
4923            let state = persisted_window_state(conn, &requested.base)?;
4924            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
4925                let reference = WindowUnitRef {
4926                    base_key: base_key.clone(),
4927                    unit: unit.clone(),
4928                };
4929                if !state.units.iter().any(|held| held == &unit) {
4930                    missing.push(reference);
4931                } else if state.pending.iter().any(|held| held == &unit) {
4932                    pending.push(reference);
4933                }
4934            }
4935        }
4936        Ok(QuerySnapshot {
4937            revision: revision.to_string(),
4938            rows,
4939            coverage: CoverageSnapshot {
4940                complete: pending.is_empty() && missing.is_empty(),
4941                pending,
4942                missing,
4943            },
4944        })
4945    })();
4946    match result {
4947        Ok(snapshot) => {
4948            conn.execute_batch("RELEASE syncular_snapshot_read")
4949                .map_err(|error| error.to_string())?;
4950            Ok(snapshot)
4951        }
4952        Err(error) => {
4953            let _ = conn.execute_batch(
4954                "ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read",
4955            );
4956            Err(error)
4957        }
4958    }
4959}
4960
4961/// A long-lived read-only SQLite sidecar for latency-critical native views.
4962/// Network rounds stay serialized on the mutable core owner, while atomic
4963/// query snapshots use this independent connection and therefore never queue
4964/// behind HTTP/WebSocket latency.
4965pub struct FileQuerySnapshotReader {
4966    path: String,
4967    conn: Option<Connection>,
4968}
4969
4970impl FileQuerySnapshotReader {
4971    #[must_use]
4972    pub fn new(path: impl Into<String>) -> Self {
4973        Self {
4974            path: path.into(),
4975            conn: None,
4976        }
4977    }
4978
4979    fn connection(&mut self) -> Result<&Connection, String> {
4980        if self.conn.is_none() {
4981            let conn = Connection::open_with_flags(
4982                &self.path,
4983                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
4984            )
4985            .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
4986            conn.busy_timeout(std::time::Duration::from_millis(250))
4987                .map_err(|error| error.to_string())?;
4988            self.conn = Some(conn);
4989        }
4990        self.conn
4991            .as_ref()
4992            .ok_or_else(|| "read sidecar connection missing".to_owned())
4993    }
4994
4995    pub fn query_snapshot(
4996        &mut self,
4997        sql: &str,
4998        params: &[Value],
4999        coverage: &[WindowCoverage],
5000    ) -> Result<QuerySnapshot, String> {
5001        snapshot_connection(self.connection()?, sql, params, coverage)
5002    }
5003}
5004
5005impl SyncClient {
5006    pub fn new_with_identity(
5007        client_id: Option<String>,
5008        schema_json: &Value,
5009        limits: ClientLimits,
5010    ) -> Result<Self, String> {
5011        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5012        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5013        Self::with_connection(resolved, schema_json, limits, conn)
5014    }
5015
5016    pub fn new(
5017        client_id: String,
5018        schema_json: &Value,
5019        limits: ClientLimits,
5020    ) -> Result<Self, String> {
5021        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5022        Self::with_connection(client_id, schema_json, limits, conn)
5023    }
5024
5025    /// Build a client backed by an on-disk SQLite database at `path` — the
5026    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
5027    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
5028    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
5029    /// of the command router's dependency set (the router only holds a path).
5030    pub fn open_path(
5031        client_id: String,
5032        schema_json: &Value,
5033        limits: ClientLimits,
5034        path: &str,
5035    ) -> Result<Self, String> {
5036        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5037        Self::with_connection(client_id, schema_json, limits, conn)
5038    }
5039
5040    pub fn open_path_with_identity(
5041        client_id: Option<String>,
5042        schema_json: &Value,
5043        limits: ClientLimits,
5044        path: &str,
5045    ) -> Result<Self, String> {
5046        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5047        // File-backed native clients use an independent read connection for
5048        // latency-critical snapshots. WAL is SQLite's intended reader/writer
5049        // concurrency mode: a view read never holds a rollback-journal lock
5050        // that delays the mutable client's next commit, and a short busy
5051        // timeout absorbs the tiny checkpoint/schema-lock windows.
5052        conn.busy_timeout(std::time::Duration::from_millis(250))
5053            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
5054        conn.pragma_update(None, "journal_mode", "WAL")
5055            .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
5056        let persisted = conn
5057            .query_row(
5058                "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
5059                [],
5060                |row| row.get::<_, String>(0),
5061            )
5062            .ok();
5063        let resolved = persisted
5064            .clone()
5065            .or(client_id.clone())
5066            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5067        if let (Some(existing), Some(requested)) = (persisted, client_id) {
5068            if existing != requested {
5069                return Err(format!(
5070                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
5071                ));
5072            }
5073        }
5074        Self::with_connection(resolved, schema_json, limits, conn)
5075    }
5076
5077    #[must_use]
5078    pub fn client_id(&self) -> &str {
5079        &self.client_id
5080    }
5081
5082    /// Build a client over a caller-supplied rusqlite connection — the seam a
5083    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
5084    /// with an on-disk database (`Connection::open(path)`) rather than the
5085    /// default `:memory:`. The connection MUST be fresh (no pre-existing
5086    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
5087    /// the same file across process restarts reuses the persisted rows.
5088    pub fn with_connection(
5089        client_id: String,
5090        schema_json: &Value,
5091        limits: ClientLimits,
5092        conn: Connection,
5093    ) -> Result<Self, String> {
5094        if limits.outcome_retention_max_entries == Some(0) {
5095            return Err(
5096                "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
5097            );
5098        }
5099        let schema = parse_schema_json(schema_json)?;
5100        // RFC 0005 D8: resolve the previous-version config before the opening
5101        // reset runs — the capture happens inside it. Bad bounds fail loud.
5102        let previous_version = match limits.previous_version_context {
5103            Some(config) => {
5104                config.validate()?;
5105                Some(config)
5106            }
5107            None => None,
5108        };
5109        let mut client = SyncClient {
5110            #[cfg(feature = "bench-internals")]
5111            benchmark_phases: Recorder::default(),
5112            conn,
5113            schema,
5114            client_id,
5115            limits,
5116            subs: Vec::new(),
5117            outbox: Vec::new(),
5118            conflicts: Vec::new(),
5119            rejections: Vec::new(),
5120            schema_floor: None,
5121            lease_state: None,
5122            stopped: false,
5123            upgrading: false,
5124            sync_needed: false,
5125            realtime_connected: false,
5126            presence: HashMap::new(),
5127            now_ms: None,
5128            encryption: crate::values::EncryptionConfig::default(),
5129            security_preflight: false,
5130            previous_version,
5131            insert_sql: RefCell::new(HashMap::new()),
5132            overlay_dirty: Cell::new(false),
5133            #[cfg(test)]
5134            overlay_rebuild_count: Cell::new(0),
5135            #[cfg(test)]
5136            outcome_prune_count: Cell::new(0),
5137            progress: ProgressObserver::default(),
5138            change_queue: VecDeque::new(),
5139            sync_intent_queue: VecDeque::new(),
5140            retry_delay_ms: 250,
5141            last_round: None,
5142            last_change: None,
5143        };
5144        // The row write path leans on the prepared-statement cache (two
5145        // insert statements per synced table, plus the bookkeeping
5146        // statements); size it so a multi-table schema never thrashes.
5147        client
5148            .conn
5149            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
5150        // Protected bookkeeping must exist before inspecting the persisted
5151        // schema marker. New app indexes may reference columns that only
5152        // exist after a version-bump reset.
5153        client.create_bookkeeping_tables()?;
5154        match client.get_meta(CLIENT_ID_KEY) {
5155            Some(existing) if existing != client.client_id => {
5156                return Err(format!(
5157                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
5158                    client.client_id
5159                ));
5160            }
5161            None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
5162            _ => {}
5163        }
5164        client.restore_persisted_state()?;
5165        let marker = client
5166            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
5167            .and_then(|value| value.parse::<i32>().ok());
5168        match marker {
5169            None => {
5170                client.create_synced_tables()?;
5171                client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
5172                // D1: the descriptor is written beside every marker write.
5173                set_local_schema_descriptor(&client.conn, &client.schema);
5174            }
5175            Some(version) if version == client.schema.version => {
5176                client.create_synced_tables()?;
5177                // RFC 0005 D1: the same-version open backfills the descriptor
5178                // for a database first opened by an unaware binary, so the
5179                // NEXT bump can capture without needing a schema bump first.
5180                set_local_schema_descriptor(&client.conn, &client.schema);
5181            }
5182            Some(_) => client.run_schema_reset()?,
5183        }
5184        // RFC 0005 D9: the reset sweep cannot see a container whose capture
5185        // committed in its OWN file while the replica's savepoint never
5186        // released — a crash at that seam leaves a container beside a matching
5187        // OLD marker, and the next open at that version runs no reset. Discard
5188        // it before anything can read it. No-op when no container exists.
5189        client.reconcile_previous_version_at_boot()?;
5190        client.clear_satisfied_persisted_schema_floor();
5191        client.prune_unknown_subscriptions()?;
5192        if marker == Some(client.schema.version) && !client.outbox.is_empty() {
5193            // Reconstruct the visible optimistic overlay from the durable base
5194            // plus outbox instead of trusting a process-interrupted mirror.
5195            client.overlay_dirty.set(true);
5196            client.rebuild_overlay();
5197        }
5198        // Every persisted active subscription needs one catch-up pull on open:
5199        // realtime only covers changes after connection, while an idempotent
5200        // setWindow correctly creates no fresh command effect. Pending outbox
5201        // work has the same restart requirement. The core owns this intent so
5202        // native hosts never poll or require an application-issued sync().
5203        client.enqueue_startup_sync_if_needed()?;
5204        Ok(client)
5205    }
5206
5207    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
5208    /// against this instead of system time (conformance virtual clock).
5209    pub fn set_now_ms(&mut self, now_ms: i64) {
5210        self.now_ms = Some(now_ms);
5211    }
5212
5213    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
5214    /// The command router parses these from the `create` command's
5215    /// `encryption` config (keys as `{$bytes: hex}`).
5216    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
5217        self.encryption = encryption;
5218    }
5219
5220    #[must_use]
5221    pub fn security_lifecycle(&self) -> &'static str {
5222        if self.security_preflight {
5223            "preflight"
5224        } else {
5225            "active"
5226        }
5227    }
5228
5229    #[must_use]
5230    pub fn security_preflight(&self) -> bool {
5231        self.security_preflight
5232    }
5233
5234    /// Quarantine this replica: enter the fail-closed gate, release all
5235    /// core-owned key material, and record the gate in the database so
5236    /// reopening re-enters preflight until `activate_security` clears it.
5237    ///
5238    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
5239    /// which must NOT leave that durable mark.
5240    pub fn begin_security_preflight(&mut self) {
5241        self.seal_security_on_teardown();
5242        // Persist the quarantine so it survives handle teardown and restart:
5243        // reopening this replica re-enters preflight until activation clears it.
5244        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
5245    }
5246
5247    /// Release core-owned key material as a host tears the client down,
5248    /// WITHOUT recording a quarantine.
5249    ///
5250    /// Shutting an activated client down is not a quarantine event. Persisting
5251    /// the gate here marks every cleanly closed replica as pending, and the
5252    /// next plain `create` is then refused permanently: the reopen path
5253    /// restores the flag from the marker and the create guard rejects it. The
5254    /// in-memory flag still closes the gate for anything still holding this
5255    /// instance, which is all a teardown barrier needs.
5256    pub fn seal_security_on_teardown(&mut self) {
5257        self.security_preflight = true;
5258        self.encryption = crate::values::EncryptionConfig::default();
5259        self.sync_intent_queue.clear();
5260    }
5261
5262    /// Install the post-authentication keyring and release the host loop.
5263    pub fn activate_security(
5264        &mut self,
5265        encryption: crate::values::EncryptionConfig,
5266    ) -> Result<(), String> {
5267        if !self.security_preflight {
5268            return Err(
5269                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
5270            );
5271        }
5272        self.enqueue_startup_sync_if_needed()?;
5273        self.encryption = encryption;
5274        self.security_preflight = false;
5275        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
5276        Ok(())
5277    }
5278
5279    fn clock_now_ms(&self) -> i64 {
5280        self.now_ms.unwrap_or_else(|| {
5281            std::time::SystemTime::now()
5282                .duration_since(std::time::UNIX_EPOCH)
5283                .map(|d| d.as_millis() as i64)
5284                .unwrap_or(0)
5285        })
5286    }
5287
5288    fn create_bookkeeping_tables(&self) -> Result<(), String> {
5289        // Durable client bookkeeping (outbox + subscription + meta).
5290        self.conn
5291            .execute_batch(
5292                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
5293                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
5294                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
5295                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
5296                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
5297                   client_commit_id TEXT NOT NULL UNIQUE,
5298                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
5299                   recorded_at_ms INTEGER NOT NULL,
5300                   results_json TEXT NOT NULL,
5301                   operations_json TEXT,
5302                   resolution TEXT NOT NULL DEFAULT 'active'
5303                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
5304                   resolved_at_ms INTEGER,
5305                   replacement_client_commit_id TEXT);
5306                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
5307                   ON _syncular_commit_outcomes(resolution, seq);
5308                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
5309                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
5310                 CREATE TABLE IF NOT EXISTS _syncular_meta (
5311                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
5312                 CREATE TABLE IF NOT EXISTS _syncular_windows (
5313                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
5314                   PRIMARY KEY (base, unit));
5315                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
5316                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
5317                   effective_scopes TEXT NOT NULL);
5318                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
5319                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
5320                   PRIMARY KEY(commit_id, blob_id));
5321                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
5322                   ON _syncular_blob_commit_refs(blob_id);",
5323            )
5324            .map_err(|e| e.to_string())?;
5325        // Migrate an outcome journal created before failed aggregate
5326        // envelopes were retained. Historical rows intentionally stay NULL.
5327        let _ = self
5328            .conn
5329            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
5330        if self.get_meta(LOCAL_REVISION_KEY).is_none() {
5331            self.set_meta(LOCAL_REVISION_KEY, "0");
5332        }
5333        // §5.9.7 blob cache. Mutable upload state stays off the body row so
5334        // completing a large upload does not rewrite its BLOB record.
5335        if self.schema_has_blobs() {
5336            let exists = self
5337                .conn
5338                .query_row(
5339                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
5340                    [],
5341                    |row| row.get::<_, bool>(0),
5342                )
5343                .map_err(|e| e.to_string())?;
5344            if exists {
5345                let mut statement = self
5346                    .conn
5347                    .prepare("PRAGMA table_info(_syncular_blobs)")
5348                    .map_err(|e| e.to_string())?;
5349                let mut columns = statement
5350                    .query_map([], |row| row.get::<_, String>(1))
5351                    .map_err(|e| e.to_string())?
5352                    .collect::<Result<Vec<_>, _>>()
5353                    .map_err(|e| e.to_string())?;
5354                columns.sort();
5355                let mut expected = vec![
5356                    "blob_id",
5357                    "byte_length",
5358                    "bytes",
5359                    "created_at_ms",
5360                    "media_type",
5361                ];
5362                expected.sort_unstable();
5363                if columns != expected {
5364                    return Err(
5365                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
5366                    );
5367                }
5368            } else {
5369                self.conn
5370                    .execute_batch(
5371                        "CREATE TABLE _syncular_blobs(
5372                           blob_id TEXT PRIMARY KEY,
5373                           bytes BLOB NOT NULL,
5374                           byte_length INTEGER NOT NULL,
5375                           media_type TEXT,
5376                           created_at_ms INTEGER NOT NULL);",
5377                    )
5378                    .map_err(|e| e.to_string())?;
5379            }
5380            self.conn
5381                .execute_batch(
5382                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
5383                       blob_id TEXT PRIMARY KEY,
5384                       media_type TEXT,
5385                       created_at_ms INTEGER NOT NULL);",
5386                )
5387                .map_err(|e| e.to_string())?;
5388        }
5389        Ok(())
5390    }
5391
5392    /// True iff any synced table declares a `blob_ref` column (§5.9).
5393    fn schema_has_blobs(&self) -> bool {
5394        self.schema
5395            .tables
5396            .iter()
5397            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
5398    }
5399
5400    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
5401
5402    fn get_meta(&self, key: &str) -> Option<String> {
5403        meta_get(&self.conn, key)
5404    }
5405
5406    fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
5407        self.conn
5408            .query_row(
5409                "SELECT value FROM _syncular_meta WHERE key = ?1",
5410                rusqlite::params![key],
5411                |row| row.get::<_, String>(0),
5412            )
5413            .optional()
5414            .map_err(|_| {
5415                "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
5416            })
5417    }
5418
5419    fn set_meta(&self, key: &str, value: &str) {
5420        meta_set(&self.conn, key, value);
5421    }
5422
5423    fn delete_meta(&self, key: &str) {
5424        meta_delete(&self.conn, key);
5425    }
5426
5427    fn restore_persisted_state(&mut self) -> Result<(), String> {
5428        self.subs = {
5429            let mut stmt = self
5430                .conn
5431                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
5432                .map_err(|error| error.to_string())?;
5433            let rows = stmt
5434                .query_map([], |row| {
5435                    Ok((
5436                        row.get::<_, String>(0)?,
5437                        row.get::<_, String>(1)?,
5438                        row.get::<_, String>(2)?,
5439                    ))
5440                })
5441                .map_err(|error| error.to_string())?;
5442            let mut subscriptions = Vec::new();
5443            for row in rows {
5444                let (id, table, raw) = row.map_err(|error| error.to_string())?;
5445                let state: Value = serde_json::from_str(&raw)
5446                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
5447                let requested = json_to_scope_map(
5448                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
5449                )?;
5450                let effective = state
5451                    .get("effectiveScopes")
5452                    .filter(|value| !value.is_null())
5453                    .map(json_to_scope_map)
5454                    .transpose()?;
5455                subscriptions.push(Subscription {
5456                    id,
5457                    table,
5458                    requested,
5459                    params: state
5460                        .get("params")
5461                        .and_then(Value::as_str)
5462                        .map(str::to_owned),
5463                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
5464                    bootstrap_state: state
5465                        .get("bootstrapState")
5466                        .and_then(Value::as_str)
5467                        .map(str::to_owned),
5468                    state: SubState::parse(
5469                        state
5470                            .get("status")
5471                            .and_then(Value::as_str)
5472                            .unwrap_or("active"),
5473                    ),
5474                    reason_code: state
5475                        .get("reasonCode")
5476                        .and_then(Value::as_str)
5477                        .map(str::to_owned),
5478                    effective,
5479                    synced_once: state
5480                        .get("syncedOnce")
5481                        .and_then(Value::as_bool)
5482                        .unwrap_or(false),
5483                });
5484            }
5485            subscriptions
5486        };
5487
5488        self.outbox = {
5489            let mut stmt = self
5490                .conn
5491                .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
5492                .map_err(|error| error.to_string())?;
5493            let rows = stmt
5494                .query_map([], |row| {
5495                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5496                })
5497                .map_err(|error| error.to_string())?;
5498            let mut commits = Vec::new();
5499            for row in rows {
5500                let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
5501                let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
5502                    format!("invalid persisted outbox {client_commit_id:?}: {error}")
5503                })?;
5504                let mut ops = Vec::with_capacity(entries.len());
5505                for entry in entries {
5506                    let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
5507                    ops.push(OutboxOp {
5508                        upsert: op == "upsert",
5509                        table: entry
5510                            .get("table")
5511                            .and_then(Value::as_str)
5512                            .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
5513                            .to_owned(),
5514                        row_id: entry
5515                            .get("rowId")
5516                            .and_then(Value::as_str)
5517                            .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
5518                            .to_owned(),
5519                        base_version: entry.get("baseVersion").and_then(Value::as_i64),
5520                        // A legacy entry's `changedFields` key is ignored:
5521                        // the values map is the presence set (§6.1).
5522                        values: entry.get("values").and_then(Value::as_object).cloned(),
5523                    });
5524                }
5525                commits.push(OutboxCommit {
5526                    client_commit_id,
5527                    ops,
5528                });
5529            }
5530            commits
5531        };
5532
5533        self.prune_commit_outcomes()?;
5534        let active = self.commit_outcomes(CommitOutcomeQuery {
5535            active_only: true,
5536            ..CommitOutcomeQuery::default()
5537        })?;
5538        self.conflicts = active
5539            .iter()
5540            .flat_map(|outcome| outcome.results.iter())
5541            .filter_map(|result| match result {
5542                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
5543                _ => None,
5544            })
5545            .collect();
5546        self.rejections = active
5547            .iter()
5548            .flat_map(|outcome| outcome.results.iter())
5549            .filter_map(|result| match result {
5550                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
5551                _ => None,
5552            })
5553            .collect();
5554
5555        self.lease_state = self
5556            .get_meta(LEASE_STATE_KEY)
5557            .map(|raw| serde_json::from_str(&raw))
5558            .transpose()
5559            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
5560        self.schema_floor = self
5561            .get_meta(SCHEMA_FLOOR_KEY)
5562            .map(|raw| serde_json::from_str(&raw))
5563            .transpose()
5564            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
5565        self.stopped = self.schema_floor.is_some();
5566        // A replica left in unactivated preflight reopens gated: the quarantine
5567        // decision persists with the data across handle teardown and restart.
5568        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
5569            self.security_preflight = true;
5570        }
5571        Ok(())
5572    }
5573
5574    /// Remove registrations for tables the running schema no longer knows.
5575    /// Otherwise the server rejects every pull with `sync.unknown_table`.
5576    fn prune_unknown_subscriptions(&mut self) -> Result<(), String> {
5577        let valid_tables: BTreeSet<String> = self
5578            .schema
5579            .tables
5580            .iter()
5581            .map(|table| table.name.clone())
5582            .collect();
5583        let stale_ids: Vec<String> = self
5584            .subs
5585            .iter()
5586            .filter(|sub| !valid_tables.contains(&sub.table))
5587            .map(|sub| sub.id.clone())
5588            .collect();
5589        for id in &stale_ids {
5590            self.conn
5591                .execute(
5592                    "DELETE FROM _syncular_windows WHERE sub_id = ?1",
5593                    rusqlite::params![id],
5594                )
5595                .map_err(|error| error.to_string())?;
5596            self.conn
5597                .execute(
5598                    "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
5599                    rusqlite::params![id],
5600                )
5601                .map_err(|error| error.to_string())?;
5602            self.conn
5603                .execute(
5604                    "DELETE FROM _syncular_subscriptions WHERE id = ?1",
5605                    rusqlite::params![id],
5606                )
5607                .map_err(|error| error.to_string())?;
5608        }
5609        self.subs.retain(|sub| valid_tables.contains(&sub.table));
5610        Ok(())
5611    }
5612
5613    #[must_use]
5614    pub fn local_revision(&self) -> u64 {
5615        self.get_meta(LOCAL_REVISION_KEY)
5616            .and_then(|value| value.parse().ok())
5617            .unwrap_or(0)
5618    }
5619
5620    #[must_use]
5621    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
5622        SyncStatusSnapshot {
5623            current_schema_version: self.schema.version,
5624            outbox: self.outbox.len(),
5625            upgrading: self.upgrading,
5626            lease_state: self.lease_state.clone(),
5627            schema_floor: self.schema_floor.clone(),
5628            sync_needed: self.sync_needed,
5629            previous_version_context: self.previous_version_status(),
5630        }
5631    }
5632
5633    /// RFC 0005 A2: container presence for a host that must act on it (Diego's
5634    /// rollback precondition), read without creating the file.
5635    fn previous_version_status(&self) -> PreviousVersionStatus {
5636        let Some(replica_path) = self.previous_version_replica_path() else {
5637            return PreviousVersionStatus {
5638                present: false,
5639                created_at_ms: None,
5640            };
5641        };
5642        match crate::previous_version::read_previous_version_record(&replica_path) {
5643            Ok(Some(record)) => PreviousVersionStatus {
5644                present: true,
5645                created_at_ms: Some(record.created_at_ms),
5646            },
5647            _ => PreviousVersionStatus {
5648                present: std::path::Path::new(
5649                    &crate::previous_version::previous_version_container_path(&replica_path),
5650                )
5651                .exists(),
5652                created_at_ms: None,
5653            },
5654        }
5655    }
5656
5657    /// RFC 0005 D7: read captured rows from the previous local schema. `state`
5658    /// is always `previousVersion`; the container never makes
5659    /// `query_snapshot()` coverage complete. Lease stop/expiry, scope
5660    /// revocation, coverage completion and the TTL DISCARD the container and
5661    /// close the read with their own reason.
5662    pub fn previous_version_snapshot(
5663        &self,
5664        spec: &PreviousVersionReadSpec,
5665    ) -> Result<PreviousVersionSnapshot, String> {
5666        let now_ms = self.clock_now_ms();
5667        crate::previous_version::previous_version_snapshot(
5668            &self.conn,
5669            self.previous_version_replica_path().as_deref(),
5670            self.previous_version.as_ref(),
5671            PreviousVersionLifecycle {
5672                lease_inactive: self.previous_version_lease_inactive(now_ms),
5673                scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
5674                coverage_complete: self.previous_version_coverage_complete(),
5675            },
5676            now_ms,
5677            self.schema.version,
5678            spec,
5679        )
5680    }
5681
5682    /// RFC 0005 D6: the advisory pre-reset compatibility audit.
5683    #[must_use]
5684    pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
5685        crate::previous_version::stored_previous_version_audit(&self.conn)
5686    }
5687
5688    /// RFC 0005 A2/D9: the executable downgrade step. Drop the container file
5689    /// and both metadata records, and report whether anything was present.
5690    /// Idempotent: absence is a successful no-op.
5691    pub fn previous_version_discard(
5692        &self,
5693    ) -> crate::previous_version::PreviousVersionDiscardOutcome {
5694        self.drop_previous_version()
5695    }
5696
5697    /// The module's discard has no failure path (it removes the file even when
5698    /// the container cannot be opened), so the only error a caller could ever
5699    /// see is a bug; report absence rather than claiming a discard happened.
5700    fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
5701        let absent = crate::previous_version::PreviousVersionDiscardOutcome {
5702            present: false,
5703            discarded: false,
5704        };
5705        let Some(replica_path) = self.previous_version_replica_path() else {
5706            return absent;
5707        };
5708        crate::previous_version::discard_previous_version(&self.conn, &replica_path)
5709            .unwrap_or(absent)
5710    }
5711
5712    /// RFC 0005 D7: `leaseState.errorCode` or an expired lease.
5713    fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
5714        let Some(lease) = &self.lease_state else {
5715            return false;
5716        };
5717        if lease.error_code.is_some() {
5718            return true;
5719        }
5720        lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
5721    }
5722
5723    /// RFC 0005 D7: §7.4.5 replacement coverage — every ACTIVE subscription
5724    /// has `cursor >= 0` and no resume token. An empty active set is not
5725    /// completion (mirrors the TS predicate exactly).
5726    fn previous_version_coverage_complete(&self) -> bool {
5727        let active = self
5728            .subs
5729            .iter()
5730            .filter(|sub| sub.state == SubState::Active)
5731            .collect::<Vec<_>>();
5732        if active.is_empty() {
5733            return false;
5734        }
5735        active
5736            .iter()
5737            .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
5738    }
5739
5740    /// RFC 0005 D7: lifetime triggers evaluated once per sync round, after the
5741    /// apply. A container that no longer has a reason to exist is discarded so
5742    /// the rows do not outlive the window they were kept for.
5743    fn previous_version_lifetime_check(&self) {
5744        let Some(config) = self.previous_version.filter(|config| config.enabled) else {
5745            return;
5746        };
5747        let now_ms = self.clock_now_ms();
5748        if self.previous_version_lease_inactive(now_ms) {
5749            let _ = self.drop_previous_version();
5750            return;
5751        }
5752        let Some(replica_path) = self.previous_version_replica_path() else {
5753            return;
5754        };
5755        let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
5756        else {
5757            return;
5758        };
5759        if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
5760            let _ = self.drop_previous_version();
5761            return;
5762        }
5763        if now_ms - record.created_at_ms > config.max_age_ms {
5764            let _ = self.drop_previous_version();
5765            return;
5766        }
5767        if self.previous_version_coverage_complete() {
5768            let _ = self.drop_previous_version();
5769        }
5770    }
5771
5772    pub fn diagnostics_snapshot(
5773        &self,
5774        request: &ClientDiagnosticsRequest,
5775    ) -> Result<ClientDiagnosticsSnapshot, String> {
5776        if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
5777            return Err(format!(
5778                "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
5779            ));
5780        }
5781        let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
5782        for sub in &self.subs {
5783            let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
5784            let complete =
5785                sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
5786            let state = match sub.state {
5787                SubState::Revoked => "revoked",
5788                SubState::Failed => "failed",
5789                SubState::Active if reset => "reset",
5790                SubState::Active if complete => "complete",
5791                SubState::Active => "bootstrapping",
5792            };
5793            subscriptions.insert(
5794                sub.id.clone(),
5795                DiagnosticSubscription {
5796                    id: sub.id.clone(),
5797                    table: sub.table.clone(),
5798                    state: state.to_owned(),
5799                    complete,
5800                    cursor: Some(sub.cursor),
5801                    reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
5802                },
5803            );
5804        }
5805        for expected in &request.expected_subscriptions {
5806            if expected.id.is_empty() || expected.table.is_empty() {
5807                return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
5808            }
5809            if subscriptions
5810                .get(&expected.id)
5811                .is_some_and(|registered| registered.table != expected.table)
5812            {
5813                subscriptions.insert(
5814                    expected.id.clone(),
5815                    DiagnosticSubscription {
5816                        id: expected.id.clone(),
5817                        table: expected.table.clone(),
5818                        state: "failed".to_owned(),
5819                        complete: false,
5820                        cursor: None,
5821                        reason_code: Some("client.subscription_intent_mismatch".to_owned()),
5822                    },
5823                );
5824            } else {
5825                subscriptions.entry(expected.id.clone()).or_insert_with(|| {
5826                    DiagnosticSubscription {
5827                        id: expected.id.clone(),
5828                        table: expected.table.clone(),
5829                        state: "unregistered".to_owned(),
5830                        complete: false,
5831                        cursor: None,
5832                        reason_code: None,
5833                    }
5834                });
5835            }
5836        }
5837        let mut ordered_subscriptions = Vec::new();
5838        let mut included = BTreeSet::new();
5839        for expected in &request.expected_subscriptions {
5840            if included.insert(expected.id.clone()) {
5841                if let Some(subscription) = subscriptions.get(&expected.id) {
5842                    ordered_subscriptions.push(subscription.clone());
5843                }
5844            }
5845        }
5846        for (id, subscription) in &subscriptions {
5847            if included.insert(id.clone()) {
5848                ordered_subscriptions.push(subscription.clone());
5849            }
5850        }
5851        let subscriptions_truncated =
5852            ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
5853        ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
5854        let captured_at_ms = self.clock_now_ms();
5855        let lease = if let Some(error_code) = self
5856            .lease_state
5857            .as_ref()
5858            .and_then(|state| state.error_code.clone())
5859        {
5860            ClientDiagnosticsLease {
5861                state: "stopped".to_owned(),
5862                expires_at_ms: self
5863                    .lease_state
5864                    .as_ref()
5865                    .and_then(|state| state.expires_at_ms),
5866                error_code: Some(Self::diagnostic_code(&error_code)),
5867            }
5868        } else if let Some(expires_at_ms) = self
5869            .lease_state
5870            .as_ref()
5871            .and_then(|state| state.expires_at_ms)
5872        {
5873            ClientDiagnosticsLease {
5874                state: if expires_at_ms <= captured_at_ms {
5875                    "expired".to_owned()
5876                } else {
5877                    "active".to_owned()
5878                },
5879                expires_at_ms: Some(expires_at_ms),
5880                error_code: None,
5881            }
5882        } else {
5883            ClientDiagnosticsLease {
5884                state: "none".to_owned(),
5885                expires_at_ms: None,
5886                error_code: None,
5887            }
5888        };
5889        let connectivity = match self.last_round.as_ref() {
5890            Some(round) if round.status == "succeeded" => "online",
5891            Some(round)
5892                if round.status == "failed"
5893                    && round
5894                        .error_code
5895                        .as_deref()
5896                        .is_some_and(Self::retryable_transport_code) =>
5897            {
5898                "offline"
5899            }
5900            _ => "unknown",
5901        };
5902        Ok(ClientDiagnosticsSnapshot {
5903            version: CLIENT_DIAGNOSTICS_VERSION,
5904            captured_at_ms,
5905            host: ClientDiagnosticsHost {
5906                kind: "direct".to_owned(),
5907                role: "single".to_owned(),
5908                connectivity: connectivity.to_owned(),
5909                realtime: if self.realtime_connected {
5910                    "connected".to_owned()
5911                } else {
5912                    "disconnected".to_owned()
5913                },
5914            },
5915            security_lifecycle: self.security_lifecycle().to_owned(),
5916            schema: ClientDiagnosticsSchema {
5917                current_version: self.schema.version,
5918                upgrading: self.upgrading,
5919                required_version: self
5920                    .schema_floor
5921                    .as_ref()
5922                    .and_then(|floor| floor.required_schema_version),
5923                latest_version: self
5924                    .schema_floor
5925                    .as_ref()
5926                    .and_then(|floor| floor.latest_schema_version),
5927            },
5928            replica: ClientDiagnosticsReplica {
5929                local_revision: self.local_revision().to_string(),
5930                sync_needed: self.sync_needed,
5931                pending_outbox: self.outbox.len(),
5932            },
5933            lease,
5934            subscriptions: ordered_subscriptions,
5935            subscriptions_truncated,
5936            last_round: self.last_round.clone(),
5937            last_change: self.last_change.clone(),
5938            storage: self.diagnostics_storage(),
5939        })
5940    }
5941
5942    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
5943        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
5944            let page_count: i64 = self
5945                .conn
5946                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
5947            let page_size: i64 = self
5948                .conn
5949                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
5950            // Reuse compiled aggregates; every invocation still reads current storage.
5951            let outbox_bytes: i64 = self
5952                .conn
5953                .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
5954                .query_row([], |row| row.get(0))?;
5955            let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
5956                "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
5957            )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
5958            let blob_bytes = if self.schema_has_blobs() {
5959                self.conn
5960                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
5961                    .query_row([], |row| row.get(0))?
5962            } else {
5963                0
5964            };
5965            let pressure = self
5966                .limits
5967                .blob_cache_max_bytes
5968                .is_some_and(|limit| blob_bytes > limit);
5969            Ok(ClientDiagnosticsStorage {
5970                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
5971                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
5972                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
5973                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
5974                retained_outcome_entries: Some(outcome_entries.max(0)),
5975                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
5976                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
5977            })
5978        };
5979        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
5980            status: "unreadable".to_owned(),
5981            database_bytes_approx: None,
5982            pending_outbox_bytes_approx: None,
5983            retained_outcome_bytes_approx: None,
5984            retained_outcome_entries: None,
5985            blob_cache_bytes_approx: None,
5986            pressure_reason_code: None,
5987        })
5988    }
5989
5990    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
5991        self.change_queue.drain(..).collect()
5992    }
5993
5994    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
5995        self.sync_intent_queue.drain(..).collect()
5996    }
5997
5998    fn schedule_background_retry(&mut self) {
5999        self.sync_intent_queue.push_back(SyncIntent::Background {
6000            delay_ms: self.retry_delay_ms,
6001        });
6002        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
6003    }
6004
6005    fn reset_background_retry(&mut self) {
6006        self.retry_delay_ms = 250;
6007    }
6008
6009    fn retryable_transport_code(code: &str) -> bool {
6010        code == "transport.failed"
6011            || code == "transport.unavailable"
6012            || code == "sync.transport_failed"
6013    }
6014
6015    fn diagnostic_code(code: &str) -> String {
6016        let valid = !code.is_empty()
6017            && code.len() <= 96
6018            && code.contains('.')
6019            && code
6020                .bytes()
6021                .next()
6022                .is_some_and(|byte| byte.is_ascii_lowercase())
6023            && code.bytes().all(|byte| {
6024                byte.is_ascii_lowercase()
6025                    || byte.is_ascii_digit()
6026                    || matches!(byte, b'.' | b'_' | b'-')
6027            });
6028        if valid {
6029            code.to_owned()
6030        } else {
6031            "client.unknown_failure".to_owned()
6032        }
6033    }
6034
6035    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
6036        if self.sync_needed != value {
6037            if self.begin_observation("syncular_status").is_ok() {
6038                self.sync_needed = value;
6039                let batch = ChangeAccumulator {
6040                    status: true,
6041                    ..ChangeAccumulator::default()
6042                };
6043                if self.finish_observation("syncular_status", batch).is_err() {
6044                    self.rollback_observation("syncular_status");
6045                }
6046            } else {
6047                self.sync_needed = value;
6048            }
6049        }
6050        if value && interactive {
6051            self.sync_intent_queue.push_back(SyncIntent::Interactive);
6052        }
6053    }
6054
6055    fn begin_observation(&self, name: &str) -> Result<(), String> {
6056        self.conn
6057            .execute_batch(&format!("SAVEPOINT {name}"))
6058            .map_err(|error| error.to_string())
6059    }
6060
6061    fn rollback_observation(&self, name: &str) {
6062        let _ = self
6063            .conn
6064            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"));
6065    }
6066
6067    fn finish_observation(&mut self, name: &str, batch: ChangeAccumulator) -> Result<(), String> {
6068        #[cfg(feature = "bench-internals")]
6069        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
6070        if !batch.touched() {
6071            self.conn
6072                .execute_batch(&format!("RELEASE {name}"))
6073                .map_err(|error| error.to_string())?;
6074            return Ok(());
6075        }
6076        let revision = self
6077            .local_revision()
6078            .checked_add(1)
6079            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
6080        self.conn
6081            .execute(
6082                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
6083                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
6084            )
6085            .map_err(|error| error.to_string())?;
6086        let status = batch.status.then(|| self.status_snapshot());
6087        let event = ClientChangeBatch {
6088            revision: revision.to_string(),
6089            tables: batch
6090                .tables
6091                .into_iter()
6092                .map(|(table, scope_keys)| TableChange {
6093                    table,
6094                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
6095                })
6096                .collect(),
6097            windows: batch
6098                .windows
6099                .into_iter()
6100                .map(|((base_key, table), units)| WindowChange {
6101                    base_key,
6102                    table,
6103                    units: units.into_iter().collect(),
6104                })
6105                .collect(),
6106            status,
6107            conflicts_changed: batch.conflicts,
6108            rejections_changed: batch.rejections,
6109            outcomes_changed: batch.outcomes,
6110        };
6111        self.conn
6112            .execute_batch(&format!("RELEASE {name}"))
6113            .map_err(|error| error.to_string())?;
6114        let mut diagnostic_tables = event
6115            .tables
6116            .iter()
6117            .map(|entry| entry.table.clone())
6118            .collect::<BTreeSet<_>>()
6119            .into_iter()
6120            .collect::<Vec<_>>();
6121        let mut diagnostic_windows = event
6122            .windows
6123            .iter()
6124            .map(|entry| entry.table.clone())
6125            .collect::<BTreeSet<_>>()
6126            .into_iter()
6127            .collect::<Vec<_>>();
6128        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
6129            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
6130        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
6131        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
6132        self.last_change = Some(DiagnosticLastChange {
6133            revision: event.revision.clone(),
6134            recorded_at_ms: self.clock_now_ms(),
6135            tables: diagnostic_tables,
6136            windows: diagnostic_windows,
6137            domains_truncated,
6138            status_changed: event.status.is_some(),
6139            conflicts_changed: event.conflicts_changed,
6140            rejections_changed: event.rejections_changed,
6141            outcomes_changed: event.outcomes_changed,
6142        });
6143        self.change_queue.push_back(event);
6144        Ok(())
6145    }
6146
6147    fn record_scope_map(
6148        &self,
6149        batch: &mut ChangeAccumulator,
6150        table_name: &str,
6151        scopes: &[(String, Vec<String>)],
6152    ) {
6153        let Some(table) = self.schema.table(table_name) else {
6154            return;
6155        };
6156        for (variable, values) in scopes {
6157            let Some(scope) = table
6158                .scope_variables
6159                .iter()
6160                .find(|scope| &scope.variable == variable)
6161            else {
6162                continue;
6163            };
6164            for value in values {
6165                batch.scope(table_name, format!("{}:{value}", scope.prefix));
6166            }
6167        }
6168    }
6169
6170    /// Record a row's current scope keys from the base or visible table.
6171    fn record_row_scopes(
6172        &self,
6173        batch: &mut ChangeAccumulator,
6174        table_name: &str,
6175        row_id: &str,
6176        base: bool,
6177    ) -> bool {
6178        let Some(table) = self.schema.table(table_name) else {
6179            return false;
6180        };
6181        if table.scope_variables.is_empty() {
6182            return false;
6183        }
6184        let columns = table
6185            .scope_variables
6186            .iter()
6187            .map(|scope| quote_ident(&scope.column))
6188            .collect::<Vec<_>>()
6189            .join(", ");
6190        let full_table = if base {
6191            base_table(table_name)
6192        } else {
6193            visible_table(table_name)
6194        };
6195        let sql = format!(
6196            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
6197            row_id_predicate(table)
6198        );
6199        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
6200            return false;
6201        };
6202        let values = stmt.query_row(rusqlite::params![row_id], |row| {
6203            let mut values = Vec::with_capacity(table.scope_variables.len());
6204            for index in 0..table.scope_variables.len() {
6205                values.push(row.get::<_, Option<String>>(index)?);
6206            }
6207            Ok(values)
6208        });
6209        let Ok(values) = values else {
6210            return false;
6211        };
6212        let mut recorded = false;
6213        for (scope, value) in table.scope_variables.iter().zip(values) {
6214            if let Some(value) = value {
6215                batch.scope(table_name, format!("{}:{value}", scope.prefix));
6216                recorded = true;
6217            }
6218        }
6219        recorded
6220    }
6221
6222    fn record_commit_changes(
6223        &self,
6224        batch: &mut ChangeAccumulator,
6225        tables: &[String],
6226        changes: &[ssp2::model::Change],
6227    ) {
6228        #[cfg(feature = "bench-internals")]
6229        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
6230        for change in changes {
6231            let Some(table_name) = tables.get(change.table_index as usize) else {
6232                continue;
6233            };
6234            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
6235            if let Some(table) = self.schema.table(table_name) {
6236                for (variable, value) in &change.scopes {
6237                    if let Some(scope) = table
6238                        .scope_variables
6239                        .iter()
6240                        .find(|scope| &scope.variable == variable)
6241                    {
6242                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
6243                        precise = true;
6244                    }
6245                }
6246            }
6247            if !precise {
6248                batch.table(table_name);
6249            }
6250        }
6251    }
6252
6253    fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
6254        if effective.is_empty() {
6255            return false;
6256        }
6257        let Some(table) = self.schema.table(table_name) else {
6258            return false;
6259        };
6260        let mut clauses = Vec::new();
6261        let mut params = Vec::new();
6262        for (variable, values) in effective {
6263            let Some(column) = table.scope_column(variable) else {
6264                return false;
6265            };
6266            if values.is_empty() {
6267                return false;
6268            }
6269            let placeholders = values
6270                .iter()
6271                .map(|value| {
6272                    params.push(SqlValue::Text(value.clone()));
6273                    "?"
6274                })
6275                .collect::<Vec<_>>()
6276                .join(", ");
6277            clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
6278        }
6279        let sql = format!(
6280            "SELECT 1 FROM {} WHERE {} LIMIT 1",
6281            base_table(table_name),
6282            clauses.join(" AND ")
6283        );
6284        self.conn
6285            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
6286            .is_ok()
6287    }
6288
6289    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
6290    pub fn upgrading(&self) -> bool {
6291        self.upgrading
6292    }
6293
6294    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
6295    /// keeping this client's local database (identity, outbox, tables). The
6296    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
6297    /// version changed. Mirrors the TS client's boot-time detection —
6298    /// the Rust core has no persistent restart, so recreation IS the boot.
6299    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
6300        let new_schema = parse_schema_json(schema_json)?;
6301        let marker: Option<i32> = self
6302            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6303            .and_then(|v| v.parse().ok());
6304        self.schema = new_schema;
6305        self.create_bookkeeping_tables()?;
6306        if marker != Some(self.schema.version) {
6307            self.run_schema_reset()?;
6308        }
6309        self.prune_unknown_subscriptions()?;
6310        // The conformance recreate is the in-memory equivalent of reopening a
6311        // durable client. Apply the same startup catch-up contract even when
6312        // the schema itself did not change.
6313        self.enqueue_startup_sync_if_needed()?;
6314        Ok(())
6315    }
6316
6317    fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
6318        let startup_work = !self.stopped
6319            && (!self.outbox.is_empty()
6320                || !self.load_pending_evictions()?.is_empty()
6321                || self.subs.iter().any(|sub| sub.state == SubState::Active));
6322        if startup_work {
6323            self.sync_needed = true;
6324            self.sync_intent_queue.push_back(SyncIntent::Interactive);
6325        }
6326        Ok(())
6327    }
6328
6329    /// A native client persists schema-floor stops across process restarts.
6330    /// Once the running app already satisfies that floor, the persisted stop
6331    /// is only stale evidence from an older server round (for example, the
6332    /// server was restarted after catching up to an app that was ahead).
6333    /// Clear it and let the normal startup pull re-negotiate. If the server is
6334    /// still incompatible it will return the floor again in that first round.
6335    fn clear_satisfied_persisted_schema_floor(&mut self) {
6336        let satisfied = self
6337            .schema_floor
6338            .as_ref()
6339            .and_then(|floor| floor.required_schema_version)
6340            .is_some_and(|required| self.schema.version >= required);
6341        if !satisfied {
6342            return;
6343        }
6344        self.schema_floor = None;
6345        self.stopped = false;
6346        self.delete_meta(SCHEMA_FLOOR_KEY);
6347    }
6348
6349    /// RFC 0005 D1: discard an orphan or stale container at boot. The reset
6350    /// path already swept it; this covers a same-version or fresh-install open,
6351    /// which runs no reset and therefore no sweep — plus the D7 TTL, which only
6352    /// an aware binary can apply.
6353    fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
6354        let Some(replica_path) = self.previous_version_replica_path() else {
6355            return Ok(());
6356        };
6357        let max_age_ms = self
6358            .previous_version
6359            .filter(|config| config.enabled)
6360            .map(|config| config.max_age_ms);
6361        reconcile_previous_version_at_boot(
6362            &self.conn,
6363            &replica_path,
6364            self.schema.version,
6365            max_age_ms,
6366            self.clock_now_ms(),
6367        )
6368    }
6369
6370    /// RFC 0005 D3: the sibling container path, or `None` when the replica has
6371    /// no file — an in-memory replica returns an empty path, and a `file:` URI
6372    /// has no plain sibling path — so the feature resolves as off there.
6373    fn previous_version_replica_path(&self) -> Option<String> {
6374        let path = self.conn.path()?;
6375        if path.is_empty() || path.starts_with("file:") {
6376            return None;
6377        }
6378        Some(path.to_owned())
6379    }
6380
6381    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
6382    /// and leaseState. Drops/recreates every synced table from the new
6383    /// schema, resets subscription sync-state (keeping registrations), clears
6384    /// the schema-floor stop state, rewrites the marker, drops outbox commits
6385    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
6386    fn run_schema_reset(&mut self) -> Result<(), String> {
6387        self.begin_observation("syncular_schema_reset")?;
6388        let mut batch = ChangeAccumulator::default();
6389        let result = self.run_schema_reset_observed(&mut batch, true, true);
6390        if let Err(error) = result {
6391            self.rollback_observation("syncular_schema_reset");
6392            return Err(error);
6393        }
6394        if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
6395            self.rollback_observation("syncular_schema_reset");
6396            return Err(error);
6397        }
6398        Ok(())
6399    }
6400
6401    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
6402        let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
6403        self.begin_observation("syncular_log_epoch_reset")?;
6404        let mut batch = ChangeAccumulator::default();
6405        let result = self.run_schema_reset_observed(&mut batch, false, false);
6406        if let Err(error) = result {
6407            self.rollback_observation("syncular_log_epoch_reset");
6408            return Err(error);
6409        }
6410        self.set_meta(LOG_EPOCH_KEY, log_epoch);
6411        self.sync_needed = true;
6412        batch.status = true;
6413        if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
6414            self.rollback_observation("syncular_log_epoch_reset");
6415            return Err(error);
6416        }
6417        self.sync_intent_queue.push_back(SyncIntent::Interactive);
6418        Ok(resets)
6419    }
6420
6421    fn run_schema_reset_observed(
6422        &mut self,
6423        batch: &mut ChangeAccumulator,
6424        drop_incompatible: bool,
6425        capture: bool,
6426    ) -> Result<(), String> {
6427        self.upgrading = true;
6428        batch.status = true;
6429        // RFC 0005 D6: classify the pending outbox against the NEW compiled
6430        // schema BEFORE the wipe, and record the advisory audit. It drops
6431        // nothing — the §7.4.4 send-time drop stays the only path that removes
6432        // a commit. The classification is shared with that drop so the two can
6433        // never disagree about what is incompatible.
6434        if self.previous_version.is_some_and(|config| config.enabled) {
6435            let previous_version = self
6436                .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6437                .and_then(|value| value.parse::<i32>().ok())
6438                .unwrap_or(self.schema.version);
6439            let pending = self
6440                .outbox
6441                .iter()
6442                .map(|commit| PendingCommitAudit {
6443                    commit_id: &commit.client_commit_id,
6444                    operations: commit_audit_operations(commit),
6445                })
6446                .collect::<Vec<_>>();
6447            crate::previous_version::write_previous_version_audit(
6448                &self.conn,
6449                &crate::previous_version::build_previous_version_audit(
6450                    &self.schema,
6451                    &pending,
6452                    previous_version,
6453                    self.schema.version,
6454                    self.clock_now_ms(),
6455                ),
6456            );
6457        }
6458        // RFC 0005 D5: the container lives in its own FILE, so the reset can
6459        // never reach it. Sweep first (unconditional — also on the log-epoch
6460        // reset, which captures nothing), then capture BEFORE the wipe drops
6461        // any row. A refused capture records its reason and leaves no file.
6462        if let Some(replica_path) = self.previous_version_replica_path() {
6463            sweep_previous_version_container(&self.conn, &replica_path)?;
6464            if capture {
6465                if let Some(config) = self.previous_version.filter(|config| config.enabled) {
6466                    let previous_version = self
6467                        .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6468                        .and_then(|value| value.parse::<i32>().ok())
6469                        .unwrap_or(self.schema.version);
6470                    capture_previous_version_from_replica(
6471                        &self.conn,
6472                        &replica_path,
6473                        previous_version,
6474                        self.schema.version,
6475                        &config,
6476                        self.clock_now_ms(),
6477                    )?;
6478                }
6479            }
6480        }
6481        for table in &self.schema.tables {
6482            batch.table(&table.name);
6483        }
6484        for (base_key, unit, table) in self.load_registered_window_units() {
6485            batch.window(&base_key, &table, &unit);
6486        }
6487        // The per-table insert SQL is derived from the OLD column lists.
6488        self.insert_sql.borrow_mut().clear();
6489        self.overlay_dirty.set(true);
6490        // Drop every synced table (base + visible) that currently exists —
6491        // discovered from sqlite_master so a bump that adds/removes tables is
6492        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
6493        // and the blob cache) are preserved; base tables are `_syncular_base_*`
6494        // so they are matched explicitly, not by the bookkeeping filter.
6495        // Drop virtual tables first so SQLite removes every FTS shadow table
6496        // atomically instead of the generic discovery tearing it apart.
6497        let virtual_tables: Vec<String> = {
6498            let mut stmt = self
6499                .conn
6500                .prepare(
6501                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
6502                )
6503                .map_err(|e| e.to_string())?;
6504            let rows = stmt
6505                .query_map([], |row| row.get::<_, String>(0))
6506                .map_err(|e| e.to_string())?;
6507            rows.filter_map(Result::ok)
6508                .filter(|name| is_synced_table_name(name))
6509                .collect()
6510        };
6511        for name in virtual_tables {
6512            self.conn
6513                .execute_batch(&format!(
6514                    "DROP TABLE IF EXISTS {}",
6515                    quote_ident(&format!("_syncular_fts_{name}"))
6516                ))
6517                .map_err(|e| e.to_string())?;
6518            self.conn
6519                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
6520                .map_err(|e| e.to_string())?;
6521        }
6522        let existing: Vec<String> = {
6523            let mut stmt = self
6524                .conn
6525                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
6526                .map_err(|e| e.to_string())?;
6527            let rows = stmt
6528                .query_map([], |row| row.get::<_, String>(0))
6529                .map_err(|e| e.to_string())?;
6530            rows.filter_map(Result::ok)
6531                .filter(|name| is_synced_table_name(name))
6532                .collect()
6533        };
6534        for name in &existing {
6535            if let Some(table) = name.strip_prefix("_syncular_base_") {
6536                batch.table(table);
6537            } else if !name.starts_with("_syncular_") {
6538                batch.table(name);
6539            }
6540        }
6541        for name in existing {
6542            self.conn
6543                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
6544                .map_err(|e| e.to_string())?;
6545        }
6546        // Recreate the synced tables from the NEW schema.
6547        self.create_synced_tables()?;
6548        // Reset every subscription's sync-state, keeping the registration.
6549        for sub in &mut self.subs {
6550            sub.cursor = -1;
6551            sub.bootstrap_state = None;
6552            sub.effective = None;
6553            sub.state = SubState::Active;
6554            sub.reason_code = None;
6555            sub.synced_once = false;
6556        }
6557        let subs = self.subs.clone();
6558        for sub in &subs {
6559            self.persist_sub(sub)?;
6560        }
6561        // The stop state is over: this client now ships a servable schema.
6562        self.stopped = false;
6563        self.schema_floor = None;
6564        self.delete_meta(SCHEMA_FLOOR_KEY);
6565        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
6566        self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
6567        // D1: the descriptor follows every marker write. A crash in between
6568        // self-heals on the next same-version open, which backfills it.
6569        set_local_schema_descriptor(&self.conn, &self.schema);
6570        // §7.4.4: drop outbox commits that cannot re-encode under the new
6571        // schema (a referenced column/table the bump removed), surfacing each
6572        // as a `sync.outbox_incompatible` rejection.
6573        if drop_incompatible && self.drop_incompatible_outbox()? {
6574            batch.rejections = true;
6575            batch.status = true;
6576            batch.outcomes = true;
6577        }
6578        // Re-apply the surviving outbox optimistically over the empty tables.
6579        self.rebuild_overlay();
6580        Ok(())
6581    }
6582
6583    /// §7.4.4: a persisted upsert whose values reference a column the current
6584    /// schema lacks (or a removed table) cannot be encoded. Drop the commit
6585    /// and raise a client-local `sync.outbox_incompatible` rejection.
6586    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
6587        let incompatible = self
6588            .outbox
6589            .iter()
6590            .filter(|commit| {
6591                first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
6592            })
6593            .cloned()
6594            .collect::<Vec<_>>();
6595        if incompatible.is_empty() {
6596            return Ok(false);
6597        }
6598        let mut rejections = Vec::new();
6599        for commit in &incompatible {
6600            let results = commit
6601                .ops
6602                .iter()
6603                .enumerate()
6604                .map(|(op_index, operation)| {
6605                    let rejection = RejectionRecord {
6606                        client_commit_id: commit.client_commit_id.clone(),
6607                        op_index: op_index as i32,
6608                        code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
6609                        message: "the persisted commit cannot encode under the current schema"
6610                            .to_owned(),
6611                        retryable: false,
6612                        details: None,
6613                        operation: Some(CommitOperation::from(operation)),
6614                    };
6615                    rejections.push(rejection.clone());
6616                    CommitOperationOutcome::Error { rejection }
6617                })
6618                .collect::<Vec<_>>();
6619            self.persist_commit_outcome(
6620                &commit.client_commit_id,
6621                CommitOutcomeStatus::Rejected,
6622                &results,
6623                Some(&commit.ops),
6624            )?;
6625            self.delete_outbox_persisted(&commit.client_commit_id)?;
6626        }
6627        self.prune_commit_outcomes()?;
6628        let incompatible_ids = incompatible
6629            .iter()
6630            .map(|commit| commit.client_commit_id.as_str())
6631            .collect::<BTreeSet<_>>();
6632        self.outbox
6633            .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
6634        self.rejections.extend(rejections);
6635        Ok(true)
6636    }
6637
6638    /// §7.4.3: (re)create the base + visible table pair for every synced
6639    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
6640    fn create_synced_tables(&self) -> Result<(), String> {
6641        for table in &self.schema.tables {
6642            // The base half + the visible half form the synced-table pair. An
6643            // index name is global in SQLite, so the base half's indexes are
6644            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
6645            for (full, index_prefix) in [
6646                (base_table(&table.name), "_syncular_base_"),
6647                (visible_table(&table.name), ""),
6648            ] {
6649                let mut cols: Vec<String> =
6650                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
6651                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
6652                let sql = format!(
6653                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
6654                    cols.join(", "),
6655                    quote_ident(&table.primary_key)
6656                );
6657                self.conn.execute(&sql, []).map_err(|e| e.to_string())?;
6658                // Local secondary indexes (CREATE INDEX subset). Created on
6659                // both halves so mirror reads hit an index on either. Runs on
6660                // both the initial create and the §7.4.3 reset recreate path.
6661                for index in &table.indexes {
6662                    let unique = if index.unique { "UNIQUE " } else { "" };
6663                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
6664                    let cols_sql = index
6665                        .columns
6666                        .iter()
6667                        .map(|c| quote_ident(c))
6668                        .collect::<Vec<_>>()
6669                        .join(", ");
6670                    let index_sql = format!(
6671                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
6672                    );
6673                    self.conn
6674                        .execute(&index_sql, [])
6675                        .map_err(|e| e.to_string())?;
6676                }
6677            }
6678        }
6679        // FTS exists only on the visible half. It is a local search
6680        // projection, never a synced/base table or a wire-schema member.
6681        for table in &self.schema.tables {
6682            for index in &table.fts_indexes {
6683                self.create_fts_projection(table, index)?;
6684            }
6685        }
6686        Ok(())
6687    }
6688
6689    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
6690        let count: i64 = self
6691            .conn
6692            .query_row(
6693                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
6694                rusqlite::params![index.name],
6695                |row| row.get(0),
6696            )
6697            .map_err(|e| e.to_string())?;
6698        Ok(count > 0)
6699    }
6700
6701    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
6702        for suffix in ["ai", "ad", "au"] {
6703            self.conn
6704                .execute(
6705                    &format!(
6706                        "DROP TRIGGER IF EXISTS {}",
6707                        quote_ident(&format!("{}_{suffix}", index.name))
6708                    ),
6709                    [],
6710                )
6711                .map_err(|e| e.to_string())?;
6712        }
6713        Ok(())
6714    }
6715
6716    fn create_fts_triggers(
6717        &self,
6718        table: &TableSchema,
6719        index: &FtsIndexSchema,
6720    ) -> Result<(), String> {
6721        let fts = quote_ident(&index.name);
6722        let source = visible_table(&table.name);
6723        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
6724        let pk = quote_ident(&table.primary_key);
6725        let projection_columns = std::iter::once(source_id.clone())
6726            .chain(index.columns.iter().map(|column| quote_ident(column)))
6727            .collect::<Vec<_>>()
6728            .join(", ");
6729        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
6730            .chain(
6731                index
6732                    .columns
6733                    .iter()
6734                    .map(|column| format!("new.{}", quote_ident(column))),
6735            )
6736            .collect::<Vec<_>>()
6737            .join(", ");
6738        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
6739        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)");
6740        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)");
6741        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})");
6742        let bi = quote_ident(&format!("{}_bi", index.name));
6743        let ai = quote_ident(&format!("{}_ai", index.name));
6744        let ad = quote_ident(&format!("{}_ad", index.name));
6745        let au = quote_ident(&format!("{}_au", index.name));
6746        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
6747        let sql = format!(
6748            "DROP TRIGGER IF EXISTS {bi};
6749             DROP TRIGGER IF EXISTS {ai};
6750             DROP TRIGGER IF EXISTS {ad};
6751             DROP TRIGGER IF EXISTS {au};
6752             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
6753             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
6754             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
6755             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
6756        );
6757        self.conn.execute_batch(&sql).map_err(|e| e.to_string())
6758    }
6759
6760    fn rebuild_fts_projection(
6761        &self,
6762        table: &TableSchema,
6763        index: &FtsIndexSchema,
6764    ) -> Result<(), String> {
6765        let fts = quote_ident(&index.name);
6766        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
6767        let pk = quote_ident(&table.primary_key);
6768        let indexed_columns = index
6769            .columns
6770            .iter()
6771            .map(|column| quote_ident(column))
6772            .collect::<Vec<_>>();
6773        let projection_columns = std::iter::once(source_id.clone())
6774            .chain(indexed_columns.iter().cloned())
6775            .collect::<Vec<_>>()
6776            .join(", ");
6777        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
6778        let sql = format!(
6779            "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};",
6780            columns = indexed_columns.join(", "),
6781            source = visible_table(&table.name),
6782        );
6783        self.conn.execute_batch(&sql).map_err(|e| e.to_string())
6784    }
6785
6786    fn create_fts_projection(
6787        &self,
6788        table: &TableSchema,
6789        index: &FtsIndexSchema,
6790    ) -> Result<(), String> {
6791        self.begin_observation("syncular_fts_schema")?;
6792        let result = (|| {
6793            let existed = self.fts_projection_exists(index)?;
6794            let tokenizer = index.tokenize.replace('\'', "''");
6795            let columns = index
6796                .columns
6797                .iter()
6798                .map(|column| quote_ident(column))
6799                .collect::<Vec<_>>()
6800                .join(", ");
6801            let sql = format!(
6802            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
6803            fts = quote_ident(&index.name),
6804            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
6805        );
6806            self.conn.execute(&sql, []).map_err(|error| {
6807                format!(
6808                    "cannot create local FTS5 projection {:?}: {error}",
6809                    index.name
6810                )
6811            })?;
6812            let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
6813            let mapped: bool = self
6814                .conn
6815                .query_row(
6816                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
6817                    [format!("_syncular_fts_{}", index.name)],
6818                    |row| row.get(0),
6819                )
6820                .map_err(|e| e.to_string())?;
6821            self.conn.execute_batch(&format!("CREATE TABLE IF NOT EXISTS {mapping}(id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE)")).map_err(|e| e.to_string())?;
6822            if !mapped && existed {
6823                self.conn
6824                    .execute_batch(&format!(
6825                        "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
6826                        quote_ident(FTS_SOURCE_ID_COLUMN),
6827                        quote_ident(&index.name)
6828                    ))
6829                    .map_err(|e| e.to_string())?;
6830            }
6831            self.create_fts_triggers(table, index)?;
6832            if !existed {
6833                self.rebuild_fts_projection(table, index)?;
6834            }
6835            Ok(())
6836        })();
6837        if let Err(error) = result {
6838            self.rollback_observation("syncular_fts_schema");
6839            return Err(error);
6840        }
6841        if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
6842            self.rollback_observation("syncular_fts_schema");
6843            return Err(error.to_string());
6844        }
6845        Ok(())
6846    }
6847
6848    // -- persistence write-through --------------------------------------------
6849
6850    fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
6851        let state = serde_json::json!({
6852            "requested": scope_map_to_json(&sub.requested),
6853            "params": sub.params,
6854            "cursor": sub.cursor,
6855            "bootstrapState": sub.bootstrap_state,
6856            "status": sub.state.name(),
6857            "reasonCode": sub.reason_code,
6858            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
6859            "syncedOnce": sub.synced_once,
6860        });
6861        self.conn.execute(
6862            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
6863            rusqlite::params![sub.id, sub.table, state.to_string()],
6864        ).map(|_| ()).map_err(|error| error.to_string())
6865    }
6866
6867    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
6868        let ops: Vec<Value> = commit
6869            .ops
6870            .iter()
6871            .map(|op| {
6872                serde_json::json!({
6873                    "op": if op.upsert { "upsert" } else { "delete" },
6874                    "table": op.table,
6875                    "rowId": op.row_id,
6876                    "baseVersion": op.base_version,
6877                    "values": op.values.clone().map(Value::Object),
6878                })
6879            })
6880            .collect();
6881        self.conn
6882            .execute(
6883                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
6884                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
6885            )
6886            .map_err(|error| error.to_string())?;
6887        for operation in &commit.ops {
6888            let Some(values) = &operation.values else {
6889                continue;
6890            };
6891            let Some(table) = self.schema.table(&operation.table) else {
6892                continue;
6893            };
6894            for column in table
6895                .columns
6896                .iter()
6897                .filter(|column| column.ty == ColumnType::BlobRef)
6898            {
6899                let Some(Value::String(raw)) = values.get(&column.name) else {
6900                    continue;
6901                };
6902                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
6903                    continue;
6904                };
6905                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
6906                    continue;
6907                };
6908                self.conn
6909                    .execute(
6910                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
6911                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
6912                        rusqlite::params![commit.client_commit_id, blob_id],
6913                    )
6914                    .map_err(|error| error.to_string())?;
6915            }
6916        }
6917        Ok(())
6918    }
6919
6920    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
6921        self.conn
6922            .execute(
6923                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
6924                rusqlite::params![client_commit_id],
6925            )
6926            .map_err(|error| error.to_string())?;
6927        self.conn
6928            .execute(
6929                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
6930                rusqlite::params![client_commit_id],
6931            )
6932            .map(|_| ())
6933            .map_err(|error| error.to_string())
6934    }
6935
6936    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
6937        match status {
6938            CommitOutcomeStatus::Applied => "applied",
6939            CommitOutcomeStatus::Cached => "cached",
6940            CommitOutcomeStatus::Conflict => "conflict",
6941            CommitOutcomeStatus::Rejected => "rejected",
6942        }
6943    }
6944
6945    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
6946        match resolution {
6947            CommitOutcomeResolution::Active => "active",
6948            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
6949            CommitOutcomeResolution::Superseded => "superseded",
6950            CommitOutcomeResolution::Dismissed => "dismissed",
6951        }
6952    }
6953
6954    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
6955        match value {
6956            "applied" => Ok(CommitOutcomeStatus::Applied),
6957            "cached" => Ok(CommitOutcomeStatus::Cached),
6958            "conflict" => Ok(CommitOutcomeStatus::Conflict),
6959            "rejected" => Ok(CommitOutcomeStatus::Rejected),
6960            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
6961        }
6962    }
6963
6964    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
6965        match value {
6966            "active" => Ok(CommitOutcomeResolution::Active),
6967            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
6968            "superseded" => Ok(CommitOutcomeResolution::Superseded),
6969            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
6970            _ => Err(format!(
6971                "invalid persisted commit outcome resolution {value:?}"
6972            )),
6973        }
6974    }
6975
6976    fn persist_commit_outcome(
6977        &self,
6978        client_commit_id: &str,
6979        status: CommitOutcomeStatus,
6980        results: &[CommitOperationOutcome],
6981        operations: Option<&[OutboxOp]>,
6982    ) -> Result<(), String> {
6983        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
6984        let operations_json = operations
6985            .map(|items| {
6986                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
6987            })
6988            .transpose()
6989            .map_err(|error| error.to_string())?;
6990        self.conn
6991            .execute(
6992                "INSERT INTO _syncular_commit_outcomes (
6993                   client_commit_id, status, recorded_at_ms, results_json,
6994                   operations_json, resolution
6995                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
6996                rusqlite::params![
6997                    client_commit_id,
6998                    Self::outcome_status_name(status),
6999                    self.clock_now_ms(),
7000                    results_json,
7001                    operations_json
7002                ],
7003            )
7004            .map(|_| ())
7005            .map_err(|error| error.to_string())
7006    }
7007
7008    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
7009        let StoredCommitOutcomeRow {
7010            sequence,
7011            client_commit_id,
7012            status,
7013            recorded_at_ms,
7014            results_json,
7015            operations_json,
7016            resolution,
7017            resolved_at_ms,
7018            replacement_client_commit_id,
7019        } = row;
7020        Ok(CommitOutcome {
7021            sequence,
7022            client_commit_id,
7023            status: Self::parse_outcome_status(&status)?,
7024            recorded_at_ms,
7025            results: serde_json::from_str(&results_json)
7026                .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
7027            operations: operations_json
7028                .map(|value| {
7029                    serde_json::from_str(&value).map_err(|error| {
7030                        format!("invalid persisted commit outcome operations: {error}")
7031                    })
7032                })
7033                .transpose()?,
7034            resolution: Self::parse_outcome_resolution(&resolution)?,
7035            resolved_at_ms,
7036            replacement_client_commit_id,
7037        })
7038    }
7039
7040    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
7041        let row = self
7042            .conn
7043            .query_row(
7044                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7045                        resolution, resolved_at_ms, replacement_client_commit_id
7046                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
7047                rusqlite::params![client_commit_id],
7048                |row| {
7049                    Ok(StoredCommitOutcomeRow {
7050                        sequence: row.get(0)?,
7051                        client_commit_id: row.get(1)?,
7052                        status: row.get(2)?,
7053                        recorded_at_ms: row.get(3)?,
7054                        results_json: row.get(4)?,
7055                        operations_json: row.get(5)?,
7056                        resolution: row.get(6)?,
7057                        resolved_at_ms: row.get(7)?,
7058                        replacement_client_commit_id: row.get(8)?,
7059                    })
7060                },
7061            )
7062            .optional()
7063            .map_err(|error| error.to_string())?;
7064        row.map(Self::outcome_from_row).transpose()
7065    }
7066
7067    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
7068        if query.limit == Some(0) {
7069            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
7070        }
7071        let mut sql = String::from(
7072            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
7073                    resolution, resolved_at_ms, replacement_client_commit_id
7074               FROM _syncular_commit_outcomes",
7075        );
7076        if query.active_only {
7077            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
7078        }
7079        sql.push_str(" ORDER BY seq DESC");
7080        if let Some(limit) = query.limit {
7081            sql.push_str(&format!(" LIMIT {limit}"));
7082        }
7083        let mut stmt = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
7084        let rows = stmt
7085            .query_map([], |row| {
7086                Ok(StoredCommitOutcomeRow {
7087                    sequence: row.get(0)?,
7088                    client_commit_id: row.get(1)?,
7089                    status: row.get(2)?,
7090                    recorded_at_ms: row.get(3)?,
7091                    results_json: row.get(4)?,
7092                    operations_json: row.get(5)?,
7093                    resolution: row.get(6)?,
7094                    resolved_at_ms: row.get(7)?,
7095                    replacement_client_commit_id: row.get(8)?,
7096                })
7097            })
7098            .map_err(|error| error.to_string())?;
7099        let mut outcomes = Vec::new();
7100        for row in rows {
7101            outcomes.push(Self::outcome_from_row(
7102                row.map_err(|error| error.to_string())?,
7103            )?);
7104        }
7105        Ok(outcomes)
7106    }
7107
7108    fn prune_commit_outcomes(&self) -> Result<(), String> {
7109        #[cfg(test)]
7110        self.outcome_prune_count
7111            .set(self.outcome_prune_count.get() + 1);
7112        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
7113        let count = self
7114            .conn
7115            .query_row(
7116                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
7117                [],
7118                |row| row.get::<_, i64>(0),
7119            )
7120            .map_err(|error| error.to_string())? as usize;
7121        let excess = count.saturating_sub(max_entries);
7122        if excess == 0 {
7123            return Ok(());
7124        }
7125        let mut stmt = self
7126            .conn
7127            .prepare(
7128                "SELECT seq FROM _syncular_commit_outcomes
7129                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
7130                  ORDER BY seq ASC LIMIT ?1",
7131            )
7132            .map_err(|error| error.to_string())?;
7133        let rows = stmt
7134            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
7135            .map_err(|error| error.to_string())?;
7136        let sequences = rows
7137            .collect::<Result<Vec<_>, _>>()
7138            .map_err(|error| error.to_string())?;
7139        drop(stmt);
7140        for sequence in sequences {
7141            self.conn
7142                .execute(
7143                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
7144                    rusqlite::params![sequence],
7145                )
7146                .map_err(|error| error.to_string())?;
7147        }
7148        Ok(())
7149    }
7150
7151    // -- driver surface ---------------------------------------------------------
7152
7153    pub fn subscribe(
7154        &mut self,
7155        id: String,
7156        table: String,
7157        scopes: Vec<(String, Vec<String>)>,
7158        params: Option<String>,
7159    ) -> Result<(), String> {
7160        if self.schema.table(&table).is_none() {
7161            return Err(format!("unknown table {table:?}"));
7162        }
7163        if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
7164            let same_intent = existing.table == table
7165                && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
7166                && existing.params == params;
7167            if same_intent {
7168                return Ok(());
7169            }
7170            return Err(
7171                "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
7172                    .to_owned(),
7173            );
7174        }
7175        let sub = Subscription {
7176            id: id.clone(),
7177            table,
7178            requested: scopes,
7179            params,
7180            cursor: -1,
7181            bootstrap_state: None,
7182            state: SubState::Active,
7183            reason_code: None,
7184            effective: None,
7185            synced_once: false,
7186        };
7187        self.persist_sub(&sub)?;
7188        self.subs.push(sub);
7189        Ok(())
7190    }
7191
7192    pub fn unsubscribe(&mut self, id: &str) {
7193        self.subs.retain(|s| s.id != id);
7194        let _ = self.conn.execute(
7195            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7196            rusqlite::params![id],
7197        );
7198    }
7199
7200    // -- windowed subscriptions (§4.8) ------------------------------------------
7201
7202    /// §4.8: set the live window units for a base — a value-sharded family
7203    /// of subscriptions, one per unit. Added units get fresh subscriptions
7204    /// (image-lane bootstrap on the next sync); removed units are
7205    /// unsubscribed with their first eviction chunk in one transaction (E1–E4).
7206    /// Idempotent; re-entry cancels any deferred eviction.
7207    pub fn set_window(
7208        &mut self,
7209        base: &WindowBase,
7210        units: &[String],
7211    ) -> Result<CommandEffects, String> {
7212        let table = self
7213            .schema
7214            .table(&base.table)
7215            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
7216        if table.scope_column(&base.variable).is_none() {
7217            return Err(format!(
7218                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
7219                base.table, base.variable
7220            ));
7221        }
7222        let base_key = window_base_key(base);
7223        let wanted: std::collections::HashSet<&String> = units.iter().collect();
7224        let live = self.load_window_units(&base_key);
7225        self.begin_observation("syncular_window")?;
7226        let mut batch = ChangeAccumulator::default();
7227        let mut changed = false;
7228
7229        let prior_subs = self.subs.clone();
7230        let widened = (|| {
7231            // Widen: units wanted but not live → fresh subscription + registry row.
7232            for unit in units {
7233                if live.iter().any(|(u, _)| u == unit) {
7234                    continue;
7235                }
7236                let sub_id = derive_sub_id(base, unit);
7237                self.delete_pending_evict(&sub_id)?;
7238                self.insert_window_unit(&base_key, unit, &sub_id)?;
7239                self.subscribe(
7240                    sub_id,
7241                    base.table.clone(),
7242                    unit_scopes(base, unit),
7243                    base.params.clone(),
7244                )?;
7245                batch.window(&base_key, &base.table, unit);
7246                changed = true;
7247            }
7248
7249            self.finish_observation("syncular_window", batch)
7250        })();
7251        if let Err(error) = widened {
7252            self.rollback_observation("syncular_window");
7253            self.subs = prior_subs;
7254            return Err(error);
7255        }
7256        for (unit, sub_id) in live {
7257            if wanted.contains(&unit) {
7258                continue;
7259            }
7260            self.rebuild_overlay_if_dirty();
7261            let prior_subs = self.subs.clone();
7262            self.begin_observation("syncular_window")?;
7263            let effective = self
7264                .subs
7265                .iter()
7266                .find(|sub| sub.id == sub_id)
7267                .and_then(|sub| sub.effective.clone())
7268                .unwrap_or_else(|| unit_scopes(base, &unit));
7269            let mut batch = ChangeAccumulator::default();
7270            self.record_scope_map(&mut batch, &base.table, &effective);
7271            batch.window(&base_key, &base.table, &unit);
7272            let result = self.evict_unit(&base_key, base, &unit, &sub_id);
7273            let remaining = match result {
7274                Ok(remaining) => remaining,
7275                Err(error) => {
7276                    self.rollback_observation("syncular_window");
7277                    self.subs = prior_subs;
7278                    return Err(error);
7279                }
7280            };
7281            if let Err(error) = self.finish_observation("syncular_window", batch) {
7282                self.rollback_observation("syncular_window");
7283                self.subs = prior_subs;
7284                return Err(error);
7285            }
7286            if remaining {
7287                self.drain_pending_evictions()?;
7288            }
7289            changed = true;
7290        }
7291        Ok(if changed {
7292            CommandEffects::interactive()
7293        } else {
7294            CommandEffects::none()
7295        })
7296    }
7297
7298    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
7299    /// the subset still bootstrap-pending. Registration alone is not
7300    /// completeness — a unit is pending until its subscription completes a
7301    /// bootstrap round (cursor advances past -1 with no resume token held).
7302    pub fn window_state(&self, base: &WindowBase) -> WindowState {
7303        let mut units = Vec::new();
7304        let mut pending = Vec::new();
7305        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
7306            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
7307                Some(sub) => {
7308                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
7309                }
7310                None => true,
7311            };
7312            if is_pending {
7313                pending.push(unit.clone());
7314            }
7315            units.push(unit);
7316        }
7317        WindowState { units, pending }
7318    }
7319
7320    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
7321        let mut stmt = match self
7322            .conn
7323            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
7324        {
7325            Ok(stmt) => stmt,
7326            Err(_) => return Vec::new(),
7327        };
7328        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
7329            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
7330        });
7331        match rows {
7332            Ok(rows) => rows.filter_map(Result::ok).collect(),
7333            Err(_) => Vec::new(),
7334        }
7335    }
7336
7337    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
7338        let mut stmt = match self.conn.prepare(
7339            "SELECT windows.base, windows.unit, subscriptions.tbl
7340               FROM _syncular_windows AS windows
7341               JOIN _syncular_subscriptions AS subscriptions
7342                 ON subscriptions.id = windows.sub_id
7343               ORDER BY windows.base, windows.unit",
7344        ) {
7345            Ok(stmt) => stmt,
7346            Err(_) => return Vec::new(),
7347        };
7348        let rows = stmt.query_map([], |row| {
7349            Ok((
7350                row.get::<_, String>(0)?,
7351                row.get::<_, String>(1)?,
7352                row.get::<_, String>(2)?,
7353            ))
7354        });
7355        match rows {
7356            Ok(rows) => rows.filter_map(Result::ok).collect(),
7357            Err(_) => Vec::new(),
7358        }
7359    }
7360
7361    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
7362        self.conn
7363            .query_row(
7364                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
7365                rusqlite::params![sub_id],
7366                |row| Ok((row.get(0)?, row.get(1)?)),
7367            )
7368            .ok()
7369    }
7370
7371    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
7372        self.conn
7373            .execute(
7374                "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
7375                rusqlite::params![base_key, unit, sub_id],
7376            )
7377            .map(|_| ())
7378            .map_err(|error| error.to_string())
7379    }
7380
7381    fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
7382        self.conn
7383            .execute(
7384                "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
7385                rusqlite::params![base_key, unit],
7386            )
7387            .map(|_| ())
7388            .map_err(|error| error.to_string())
7389    }
7390
7391    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
7392    /// Deletes the unit's rows except those pinned by a pending outbox
7393    /// commit (E1); records a deferred eviction if any pin remains; discards
7394    /// the subscription's cursor/resume/effective-echo (E3) and version
7395    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
7396    /// nothing.
7397    fn evict_unit(
7398        &mut self,
7399        base_key: &str,
7400        base: &WindowBase,
7401        unit: &str,
7402        sub_id: &str,
7403    ) -> Result<bool, String> {
7404        let effective = self
7405            .subs
7406            .iter()
7407            .find(|s| s.id == sub_id)
7408            .and_then(|s| s.effective.clone())
7409            .unwrap_or_else(|| unit_scopes(base, unit));
7410        let pinned = self.pinned_row_ids(&base.table);
7411        let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
7412        self.delete_window_unit(base_key, unit)?;
7413        self.conn
7414            .execute(
7415                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
7416                [sub_id],
7417            )
7418            .map_err(|error| error.to_string())?;
7419        self.subs.retain(|sub| sub.id != sub_id);
7420        if deferred {
7421            self.save_pending_evict(sub_id, &base.table, &effective)?;
7422        } else {
7423            self.delete_pending_evict(sub_id)?;
7424        }
7425        Ok(remaining)
7426    }
7427
7428    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
7429    /// primary keys. Returns (more unpinned work, any scoped rows remain).
7430    /// A missing scope-column mapping fails without deleting rows.
7431    fn evict_scope_rows(
7432        &self,
7433        table_name: &str,
7434        effective: &[(String, Vec<String>)],
7435        pinned: &std::collections::HashSet<String>,
7436    ) -> Result<(bool, bool), String> {
7437        if effective.is_empty() {
7438            return Ok((false, false));
7439        }
7440        let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
7441        let mut clauses = Vec::new();
7442        let mut params: Vec<SqlValue> = Vec::new();
7443        for (variable, values) in effective {
7444            let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
7445            if values.is_empty() {
7446                return Ok((false, false));
7447            }
7448            let holes = values
7449                .iter()
7450                .map(|v| {
7451                    params.push(SqlValue::Text(v.clone()));
7452                    "?"
7453                })
7454                .collect::<Vec<_>>()
7455                .join(", ");
7456            clauses.push(format!("{} IN ({holes})", quote_ident(column)));
7457        }
7458        let scope = clauses.join(" AND ");
7459        let scope_params = params.len();
7460        let pk = quote_ident(&table.primary_key);
7461        if !pinned.is_empty() {
7462            let holes = pinned
7463                .iter()
7464                .map(|id| {
7465                    params.push(SqlValue::Text(id.clone()));
7466                    "?"
7467                })
7468                .collect::<Vec<_>>()
7469                .join(", ");
7470            clauses.push(format!("{pk} NOT IN ({holes})"));
7471        }
7472        let predicate = clauses.join(" AND ");
7473        let base = base_table(table_name);
7474        let visible = visible_table(table_name);
7475        let mut ids: Vec<SqlValue> = Vec::new();
7476        for source in [&base, &visible] {
7477            let mut stmt = self
7478                .conn
7479                .prepare_cached(&format!(
7480                    "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
7481                ))
7482                .map_err(|e| e.to_string())?;
7483            ids = stmt
7484                .query_map(rusqlite::params_from_iter(&params), |row| row.get(0))
7485                .map_err(|e| e.to_string())?
7486                .collect::<Result<_, _>>()
7487                .map_err(|e| e.to_string())?;
7488            if !ids.is_empty() {
7489                break;
7490            }
7491        }
7492        if !ids.is_empty() {
7493            let holes = vec!["?"; ids.len()].join(", ");
7494            for source in [&base, &visible] {
7495                self.conn
7496                    .execute(
7497                        &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
7498                        rusqlite::params_from_iter(&ids),
7499                    )
7500                    .map_err(|e| e.to_string())?;
7501            }
7502        }
7503        let mut remaining = false;
7504        let mut deferred = false;
7505        for source in [&base, &visible] {
7506            remaining |= self
7507                .conn
7508                .query_row(
7509                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
7510                    rusqlite::params_from_iter(&params),
7511                    |row| row.get::<_, bool>(0),
7512                )
7513                .map_err(|e| e.to_string())?;
7514            deferred |= self
7515                .conn
7516                .query_row(
7517                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
7518                    rusqlite::params_from_iter(&params[..scope_params]),
7519                    |row| row.get::<_, bool>(0),
7520                )
7521                .map_err(|e| e.to_string())?;
7522        }
7523        Ok((remaining, deferred))
7524    }
7525
7526    /// Resume committed eviction chunks, retaining outbox pins and their durable marker.
7527    fn drain_pending_evictions(&mut self) -> Result<(), String> {
7528        let pending = self.load_pending_evictions()?;
7529        if pending.is_empty() {
7530            return Ok(());
7531        }
7532        self.rebuild_overlay_if_dirty();
7533        for (sub_id, table_name, effective) in pending {
7534            if self.schema.table(&table_name).is_none() {
7535                self.delete_pending_evict(&sub_id)?;
7536                continue;
7537            }
7538            loop {
7539                std::thread::yield_now();
7540                self.begin_observation("syncular_evict")?;
7541                let pinned = self.pinned_row_ids(&table_name);
7542                let result = self.evict_scope_rows(&table_name, &effective, &pinned);
7543                let (remaining, deferred) = match result {
7544                    Ok(result) => result,
7545                    Err(error) => {
7546                        self.rollback_observation("syncular_evict");
7547                        return Err(error);
7548                    }
7549                };
7550                if !deferred {
7551                    if let Err(error) = self.delete_pending_evict(&sub_id) {
7552                        self.rollback_observation("syncular_evict");
7553                        return Err(error);
7554                    }
7555                }
7556                let mut batch = ChangeAccumulator::default();
7557                self.record_scope_map(&mut batch, &table_name, &effective);
7558                if let Err(error) = self.finish_observation("syncular_evict", batch) {
7559                    self.rollback_observation("syncular_evict");
7560                    return Err(error);
7561                }
7562                if !remaining {
7563                    break;
7564                }
7565            }
7566        }
7567        Ok(())
7568    }
7569
7570    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
7571    /// commit — rows that MUST NOT be evicted until the commit drains.
7572    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
7573        let mut pinned = std::collections::HashSet::new();
7574        for commit in &self.outbox {
7575            for op in &commit.ops {
7576                if op.table == table {
7577                    pinned.insert(op.row_id.clone());
7578                }
7579            }
7580        }
7581        pinned
7582    }
7583
7584    fn save_pending_evict(
7585        &self,
7586        sub_id: &str,
7587        table: &str,
7588        effective: &[(String, Vec<String>)],
7589    ) -> Result<(), String> {
7590        self.conn.execute(
7591            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
7592            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
7593        ).map(|_| ()).map_err(|error| error.to_string())
7594    }
7595
7596    fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
7597        self.conn
7598            .execute(
7599                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
7600                rusqlite::params![sub_id],
7601            )
7602            .map(|_| ())
7603            .map_err(|error| error.to_string())
7604    }
7605
7606    fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
7607        let mut stmt = self
7608            .conn
7609            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
7610            .map_err(|error| error.to_string())?;
7611        let rows = stmt
7612            .query_map([], |row| {
7613                Ok((
7614                    row.get::<_, String>(0)?,
7615                    row.get::<_, String>(1)?,
7616                    row.get::<_, String>(2)?,
7617                ))
7618            })
7619            .map_err(|error| error.to_string())?;
7620        rows.map(|row| {
7621            let (sub_id, table, json) = row.map_err(|error| error.to_string())?;
7622            let value: Value = serde_json::from_str(&json)
7623                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
7624            let effective = json_to_scope_map(&value)
7625                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
7626            Ok((sub_id, table, effective))
7627        })
7628        .collect()
7629    }
7630
7631    /// Record one atomic local commit (§7.1) and apply it optimistically.
7632    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
7633        if mutations.is_empty() {
7634            return Err("a commit must contain at least one operation (§6.1)".to_owned());
7635        }
7636        let mut ops = Vec::with_capacity(mutations.len());
7637        for mutation in mutations {
7638            match mutation {
7639                Mutation::Upsert {
7640                    table,
7641                    values,
7642                    base_version,
7643                } => {
7644                    let schema_table = self
7645                        .schema
7646                        .table(&table)
7647                        .ok_or_else(|| format!("unknown table {table:?}"))?;
7648                    // §5: value keys are accepted in snake_case AND the
7649                    // generated row types' camelCase; normalize to SQL truth
7650                    // before the pk lookup / codec see them.
7651                    let values = normalize_values_casing(schema_table, values)?;
7652                    let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
7653                    // §6.7 `mutate` marks every column present: fill missing
7654                    // nullable columns with NULL before the payload is built.
7655                    let values = full_row_values(schema_table, values)?;
7656                    // §5.11: validate the payload encodes with the current
7657                    // codec. A key-selection or unknown-key failure is a
7658                    // durable rejection at the push seam (§10.3), never an
7659                    // author-time error. Full-row `mutate` presents every
7660                    // column, so no stored fallback is needed.
7661                    self.validate_author_encode(schema_table, &row_id, &values)?;
7662                    ops.push(OutboxOp {
7663                        upsert: true,
7664                        table,
7665                        row_id,
7666                        base_version,
7667                        values: Some(values),
7668                    });
7669                }
7670                Mutation::Delete {
7671                    table,
7672                    row_id,
7673                    base_version,
7674                } => {
7675                    if self.schema.table(&table).is_none() {
7676                        return Err(format!("unknown table {table:?}"));
7677                    }
7678                    ops.push(OutboxOp {
7679                        upsert: false,
7680                        table,
7681                        row_id,
7682                        base_version,
7683                        values: None,
7684                    });
7685                }
7686            }
7687        }
7688        self.record_outbox_commit(ops)
7689    }
7690
7691    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
7692        let commit = OutboxCommit {
7693            client_commit_id: uuid::Uuid::new_v4().to_string(),
7694            ops,
7695        };
7696        self.begin_observation("syncular_mutation")?;
7697        let mut batch = ChangeAccumulator::default();
7698        for op in &commit.ops {
7699            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
7700            if let Some(values) = &op.values {
7701                if let Some(table) = self.schema.table(&op.table) {
7702                    for scope in &table.scope_variables {
7703                        if let Some(Value::String(value)) = values.get(&scope.column) {
7704                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
7705                            precise = true;
7706                        }
7707                    }
7708                }
7709            }
7710            if !precise {
7711                batch.table(&op.table);
7712            }
7713        }
7714        if let Err(error) = self.persist_outbox_insert(&commit) {
7715            self.rollback_observation("syncular_mutation");
7716            return Err(error);
7717        }
7718        let was_dirty = self.overlay_dirty.get();
7719        // A clean overlay already contains the FIFO fold of every previous
7720        // commit. Appending a commit only needs its own operations applied.
7721        if !was_dirty {
7722            self.apply_outbox_ops(&commit.ops);
7723        }
7724        let id = commit.client_commit_id.clone();
7725        self.outbox.push(commit);
7726        if was_dirty {
7727            self.rebuild_overlay();
7728        }
7729        batch.status = true;
7730        if let Err(error) = self.finish_observation("syncular_mutation", batch) {
7731            self.rollback_observation("syncular_mutation");
7732            self.outbox.pop();
7733            self.overlay_dirty.set(was_dirty);
7734            return Err(error);
7735        }
7736        Ok(id)
7737    }
7738
7739    /// Record one §6.1 sparse upsert: the primary key plus the supplied
7740    /// non-scope columns are present; every other column is absent and stays
7741    /// untouched on the server and in the local overlay. The row need not be
7742    /// locally present — an absent local row leaves the overlay absent (§7.1)
7743    /// and the server answers per §5.2.
7744    pub fn patch(
7745        &mut self,
7746        table: &str,
7747        row_id: &str,
7748        partial: Map<String, Value>,
7749        base_version: Option<i64>,
7750    ) -> Result<String, String> {
7751        let schema_table = self
7752            .schema
7753            .table(table)
7754            .ok_or_else(|| format!("unknown table {table:?}"))?;
7755        let partial = normalize_values_casing(schema_table, partial)?;
7756        if let Some(pk) = partial.get(&schema_table.primary_key) {
7757            if pk.as_str() != Some(row_id) {
7758                return Err(format!(
7759                    "sync.invalid_request: table {table:?}: patch cannot change the primary key"
7760                ));
7761            }
7762        }
7763        // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
7764        // server accepts a present scope column whose value equals the
7765        // stored row and applies it as a no-op, so a decoded envelope
7766        // round-trips. The local row is the only value the client can prove
7767        // equality against; when the row is absent locally the client cannot
7768        // prove equality against the server's stored row and fails closed.
7769        // A primary key that is also a scope column needs no stored-row
7770        // read: the primary key in a sparse payload is by construction the
7771        // row id being patched (§6.1), so its value is proven equal already.
7772        for scope in &schema_table.scope_variables {
7773            if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
7774                continue;
7775            }
7776            let Some(column) = schema_table
7777                .columns
7778                .iter()
7779                .find(|column| column.name == scope.column)
7780            else {
7781                continue;
7782            };
7783            let stored = self
7784                .conn
7785                .query_row(
7786                    &format!(
7787                        "SELECT {} FROM {} WHERE {}",
7788                        quote_ident(&scope.column),
7789                        visible_table(&schema_table.name),
7790                        row_id_predicate(schema_table)
7791                    ),
7792                    rusqlite::params![row_id],
7793                    |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
7794                )
7795                .ok();
7796            // Decode the supplied value through the same column-type path the
7797            // rest of the patch encoding uses, so a value that only equals
7798            // the stored one in another number representation still compares
7799            // equal.
7800            let incoming = json_to_column_value(column, partial.get(&scope.column))
7801                .ok()
7802                .map(|value| column_value_to_json(&value));
7803            // A stored blob never equals a patch value, matching the server's
7804            // scope-column comparison (§3.4): bytes are unstorable as scope
7805            // values.
7806            let equal = stored.as_ref().is_some_and(|stored| {
7807                stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
7808            });
7809            if !equal {
7810                return Err(format!(
7811                    "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
7812                    scope.column
7813                ));
7814            }
7815        }
7816        let mut values = partial;
7817        // Presence-set semantics: a proven-equal scope column is dropped, so
7818        // the patch leaves the stored value untouched (§6.2).
7819        for scope in &schema_table.scope_variables {
7820            values.remove(&scope.column);
7821        }
7822        values.insert(
7823            schema_table.primary_key.clone(),
7824            Value::from(row_id.to_owned()),
7825        );
7826        // §5.11: structural validation only; an absent key-id selector is
7827        // resolved at the push seam from the stored local row, and a
7828        // key-selection or unknown-key failure becomes a durable
7829        // `client.encrypt_failed` rejection there (§10.3), never an
7830        // author-time error.
7831        self.validate_author_encode(schema_table, row_id, &values)?;
7832        self.record_outbox_commit(vec![OutboxOp {
7833            upsert: true,
7834            table: table.to_owned(),
7835            row_id: row_id.to_owned(),
7836            base_version,
7837            values: Some(values),
7838        }])
7839    }
7840
7841    /// §5.11 key-id fallback: the stored local row's selector value as a
7842    /// positional row (only the selector slot filled). Returns None when no
7843    /// selector is configured, the row is locally absent, or the stored
7844    /// value is not a usable key id.
7845    fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
7846        let selector = self.encryption.key_id_columns.get(&table.name)?;
7847        let index = table
7848            .columns
7849            .iter()
7850            .position(|column| &column.name == selector)?;
7851        let sql = format!(
7852            "SELECT {} FROM {} WHERE {}",
7853            quote_ident(selector),
7854            visible_table(&table.name),
7855            row_id_predicate(table)
7856        );
7857        let key: Option<String> = self
7858            .conn
7859            .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
7860            .ok()?;
7861        let key = key.filter(|key| !key.is_empty())?;
7862        let mut fallback: Row = vec![None; table.columns.len()];
7863        fallback[index] = Some(ColumnValue::String(key));
7864        Some(fallback)
7865    }
7866
7867    /// §5.11/§10.3 author-time encode check: a structural failure (a
7868    /// non-nullable NULL, a value the codec rejects) is an author-time
7869    /// error; a key-selection or unknown-key failure is deferred to the push
7870    /// seam, where it becomes a durable `client.encrypt_failed` rejection.
7871    fn validate_author_encode(
7872        &self,
7873        table: &TableSchema,
7874        row_id: &str,
7875        values: &Map<String, Value>,
7876    ) -> Result<(), String> {
7877        match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
7878            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
7879            result => result.map(|_| ()),
7880        }
7881    }
7882
7883    /// §5.11/§10.3: drop every pending commit whose push encode fails with
7884    /// `client.encrypt_failed` (an unresolvable key id or an unknown key) and
7885    /// raise one durable rejection per commit. `sync()` never aborts for an
7886    /// encode failure. Mirrors §7.4.4's `drop_incompatible_outbox`, including
7887    /// undoing the dropped commit's purely-optimistic rows.
7888    fn drop_unencodable_outbox(&mut self) -> Result<bool, String> {
7889        if !self
7890            .schema
7891            .tables
7892            .iter()
7893            .any(|table| table.has_encrypted_columns())
7894        {
7895            return Ok(false);
7896        }
7897        let failures = self
7898            .outbox
7899            .iter()
7900            .filter_map(|commit| {
7901                commit
7902                    .ops
7903                    .iter()
7904                    .find_map(|op| {
7905                        let values = op.values.as_ref().filter(|_| op.upsert)?;
7906                        let table = self.schema.table(&op.table)?;
7907                        let fallback = self.stored_key_fallback(table, &op.row_id);
7908                        match encode_sparse_row_json(
7909                            table,
7910                            &op.row_id,
7911                            values,
7912                            &self.encryption,
7913                            fallback.as_ref(),
7914                        ) {
7915                            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Some(error),
7916                            _ => None,
7917                        }
7918                    })
7919                    .map(|error| (commit.clone(), error))
7920            })
7921            .collect::<Vec<_>>();
7922        if failures.is_empty() {
7923            return Ok(false);
7924        }
7925        for (commit, message) in failures {
7926            let rejection = RejectionRecord {
7927                client_commit_id: commit.client_commit_id.clone(),
7928                op_index: 0,
7929                code: ENCRYPT_FAILED_CODE.to_owned(),
7930                message,
7931                retryable: false,
7932                details: None,
7933                operation: commit.ops.first().map(CommitOperation::from),
7934            };
7935            self.persist_commit_outcome(
7936                &commit.client_commit_id,
7937                CommitOutcomeStatus::Rejected,
7938                &[CommitOperationOutcome::Error {
7939                    rejection: rejection.clone(),
7940                }],
7941                Some(&commit.ops),
7942            )?;
7943            self.delete_outbox_persisted(&commit.client_commit_id)?;
7944            self.outbox
7945                .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
7946            self.rejections.push(rejection);
7947        }
7948        self.prune_commit_outcomes()?;
7949        self.overlay_dirty.set(true);
7950        self.rebuild_overlay();
7951        Ok(true)
7952    }
7953
7954    pub fn pending_commit_ids(&self) -> Vec<String> {
7955        self.outbox
7956            .iter()
7957            .map(|c| c.client_commit_id.clone())
7958            .collect()
7959    }
7960
7961    /// §6.1: the sparse push payload bytes of every pending upsert operation,
7962    /// FIFO — exactly what the next push round would put on the wire. Used by
7963    /// conformance B.21(h) to pin cross-core byte identity.
7964    pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
7965        let mut payloads = Vec::new();
7966        for commit in &self.outbox {
7967            for op in &commit.ops {
7968                let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
7969                    continue;
7970                };
7971                let Some(table) = self.schema.table(&op.table) else {
7972                    continue;
7973                };
7974                let fallback = self.stored_key_fallback(table, &op.row_id);
7975                if let Ok(payload) = encode_sparse_row_json(
7976                    table,
7977                    &op.row_id,
7978                    values,
7979                    &self.encryption,
7980                    fallback.as_ref(),
7981                ) {
7982                    payloads.push(payload);
7983                }
7984            }
7985        }
7986        payloads
7987    }
7988
7989    pub fn conflicts(&self) -> &[ConflictRecord] {
7990        &self.conflicts
7991    }
7992
7993    pub fn rejections(&self) -> &[RejectionRecord] {
7994        &self.rejections
7995    }
7996
7997    pub fn resolve_commit_outcome(
7998        &mut self,
7999        input: ResolveCommitOutcomeInput,
8000    ) -> Result<CommitOutcome, String> {
8001        let current = self
8002            .commit_outcome(&input.client_commit_id)?
8003            .ok_or_else(|| {
8004                format!(
8005                    "sync.outcome_not_found: no durable outcome exists for {:?}",
8006                    input.client_commit_id
8007                )
8008            })?;
8009        if current.resolution != CommitOutcomeResolution::Active {
8010            return Ok(current);
8011        }
8012        if input.resolution == CommitOutcomeResolution::Active {
8013            return Err("sync.invalid_request: resolution must leave active state".to_owned());
8014        }
8015        match input.resolution {
8016            CommitOutcomeResolution::Superseded => {
8017                let replacement = input
8018                    .replacement_client_commit_id
8019                    .as_deref()
8020                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
8021                    .ok_or_else(|| {
8022                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
8023                            .to_owned()
8024                    })?;
8025                let _ = replacement;
8026            }
8027            _ if input.replacement_client_commit_id.is_some() => {
8028                return Err(
8029                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
8030                        .to_owned(),
8031                );
8032            }
8033            _ => {}
8034        }
8035        let allowed = match current.status {
8036            CommitOutcomeStatus::Conflict => matches!(
8037                input.resolution,
8038                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
8039            ),
8040            CommitOutcomeStatus::Rejected => {
8041                input.resolution == CommitOutcomeResolution::Superseded
8042            }
8043            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
8044                input.resolution == CommitOutcomeResolution::Dismissed
8045            }
8046        };
8047        if !allowed {
8048            return Err(format!(
8049                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
8050                input.resolution, current.status
8051            ));
8052        }
8053
8054        self.begin_observation("syncular_outcome_resolution")?;
8055        let result = (|| {
8056            self.conn
8057                .execute(
8058                    "UPDATE _syncular_commit_outcomes
8059                        SET resolution = ?1, resolved_at_ms = ?2,
8060                            replacement_client_commit_id = ?3
8061                      WHERE client_commit_id = ?4 AND resolution = 'active'",
8062                    rusqlite::params![
8063                        Self::outcome_resolution_name(input.resolution),
8064                        self.clock_now_ms(),
8065                        input.replacement_client_commit_id,
8066                        input.client_commit_id
8067                    ],
8068                )
8069                .map_err(|error| error.to_string())?;
8070            let resolved = self
8071                .commit_outcome(&current.client_commit_id)?
8072                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
8073            self.prune_commit_outcomes()?;
8074            Ok(resolved)
8075        })();
8076        let resolved = match result {
8077            Ok(outcome) => outcome,
8078            Err(error) => {
8079                self.rollback_observation("syncular_outcome_resolution");
8080                return Err(error);
8081            }
8082        };
8083        self.conflicts
8084            .retain(|record| record.client_commit_id != current.client_commit_id);
8085        self.rejections
8086            .retain(|record| record.client_commit_id != current.client_commit_id);
8087        let batch = ChangeAccumulator {
8088            conflicts: current.status == CommitOutcomeStatus::Conflict,
8089            rejections: current.status == CommitOutcomeStatus::Rejected,
8090            outcomes: true,
8091            ..ChangeAccumulator::default()
8092        };
8093        if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
8094            self.rollback_observation("syncular_outcome_resolution");
8095            return Err(error);
8096        }
8097        Ok(resolved)
8098    }
8099
8100    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
8101        self.schema_floor.as_ref()
8102    }
8103
8104    /// §7.3.5: the client's opaque auth-lease state, if any.
8105    pub fn lease_state(&self) -> Option<&LeaseState> {
8106        self.lease_state.as_ref()
8107    }
8108
8109    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
8110    /// the two lease codes set it; other errors leave leaseState untouched.
8111    fn record_lease_error(&mut self, code: &str) {
8112        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
8113            return;
8114        }
8115        let mut next = self.lease_state.clone().unwrap_or_default();
8116        next.error_code = Some(code.to_owned());
8117        self.set_lease_state(Some(next));
8118    }
8119
8120    fn set_lease_state(&mut self, next: Option<LeaseState>) {
8121        if self.lease_state == next {
8122            return;
8123        }
8124        if self.begin_observation("syncular_lease").is_err() {
8125            return;
8126        }
8127        self.lease_state = next;
8128        if let Some(lease) = &self.lease_state {
8129            if let Ok(json) = serde_json::to_string(lease) {
8130                self.set_meta(LEASE_STATE_KEY, &json);
8131            }
8132        } else {
8133            self.delete_meta(LEASE_STATE_KEY);
8134        }
8135        let batch = ChangeAccumulator {
8136            status: true,
8137            ..ChangeAccumulator::default()
8138        };
8139        if self.finish_observation("syncular_lease", batch).is_err() {
8140            self.rollback_observation("syncular_lease");
8141        }
8142    }
8143
8144    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
8145        if self.schema_floor == next {
8146            return;
8147        }
8148        if self.begin_observation("syncular_schema_floor").is_err() {
8149            return;
8150        }
8151        self.schema_floor = next;
8152        self.stopped = self.schema_floor.is_some();
8153        if let Some(floor) = &self.schema_floor {
8154            if let Ok(json) = serde_json::to_string(floor) {
8155                self.set_meta(SCHEMA_FLOOR_KEY, &json);
8156            }
8157        } else {
8158            self.delete_meta(SCHEMA_FLOOR_KEY);
8159        }
8160        let batch = ChangeAccumulator {
8161            status: true,
8162            ..ChangeAccumulator::default()
8163        };
8164        if self
8165            .finish_observation("syncular_schema_floor", batch)
8166            .is_err()
8167        {
8168            self.rollback_observation("syncular_schema_floor");
8169        }
8170    }
8171
8172    fn set_upgrading(&mut self, value: bool) {
8173        if self.upgrading == value {
8174            return;
8175        }
8176        if self.begin_observation("syncular_upgrading").is_err() {
8177            return;
8178        }
8179        self.upgrading = value;
8180        let batch = ChangeAccumulator {
8181            status: true,
8182            ..ChangeAccumulator::default()
8183        };
8184        if self
8185            .finish_observation("syncular_upgrading", batch)
8186            .is_err()
8187        {
8188            self.rollback_observation("syncular_upgrading");
8189        }
8190    }
8191
8192    pub fn sync_needed(&self) -> bool {
8193        self.sync_needed
8194    }
8195
8196    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
8197        let sub = self.subs.iter().find(|s| s.id == id)?;
8198        Some(SubscriptionStateView {
8199            id: sub.id.clone(),
8200            table: sub.table.clone(),
8201            status: sub.state.name().to_owned(),
8202            cursor: sub.cursor,
8203            has_resume_token: sub.bootstrap_state.is_some(),
8204            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
8205            reason_code: sub.reason_code.clone(),
8206        })
8207    }
8208
8209    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
8210        let schema_table = self
8211            .schema
8212            .table(table)
8213            .ok_or_else(|| format!("unknown table {table:?}"))?;
8214        let sql = format!(
8215            "SELECT * FROM {} ORDER BY {} ASC",
8216            visible_table(table),
8217            quote_ident(&schema_table.primary_key)
8218        );
8219        let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?;
8220        let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
8221        let mut out = Vec::new();
8222        while let Some(row) = rows.next().map_err(|e| e.to_string())? {
8223            let mut values = Map::new();
8224            for (i, column) in schema_table.columns.iter().enumerate() {
8225                let value = row.get_ref(i).map_err(|e| e.to_string())?;
8226                values.insert(column.name.clone(), sql_ref_to_json(column, value));
8227            }
8228            let version: i64 = row
8229                .get(schema_table.columns.len())
8230                .map_err(|e| e.to_string())?;
8231            let row_id = match values.get(&schema_table.primary_key) {
8232                Some(Value::String(s)) => s.clone(),
8233                Some(Value::Number(n)) => n.to_string(),
8234                Some(Value::Bool(b)) => b.to_string(),
8235                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
8236            };
8237            out.push(RowState {
8238                row_id,
8239                version,
8240                values,
8241            });
8242        }
8243        Ok(out)
8244    }
8245
8246    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
8247    //
8248    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
8249    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
8250    // the op with `yrs`, re-encodes the whole doc state, and records a
8251    // baseVersion-less sparse upsert carrying only the primary key and the
8252    // crdt column (§5.10.3, §6.6). No local merge — merging is server-side;
8253    // the overlay re-materializes the edit immediately (optimistic apply,
8254    // §7.1) and the server-merged bytes arrive on the next pull, idempotently.
8255    // Byte-compatible with `@syncular/crdt-yjs`.
8256
8257    /// The current stored value of a `crdt` column for one row — the visible
8258    /// (optimistic) bytes, or `None` when the row is absent or the column is
8259    /// NULL (the empty document, §5.10.1). Errors if the column is not a
8260    /// `crdt` column (guards the app against a typo'd column name).
8261    #[cfg(feature = "crdt-yjs")]
8262    fn crdt_column_bytes(
8263        &self,
8264        table: &str,
8265        row_id: &str,
8266        column: &str,
8267    ) -> Result<Option<Vec<u8>>, String> {
8268        let schema_table = self
8269            .schema
8270            .table(table)
8271            .ok_or_else(|| format!("unknown table {table:?}"))?;
8272        let col = schema_table
8273            .columns
8274            .iter()
8275            .find(|c| c.name == column)
8276            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
8277        if col.ty != ColumnType::Crdt {
8278            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
8279        }
8280        let sql = format!(
8281            "SELECT {} FROM {} WHERE {}",
8282            quote_ident(column),
8283            visible_table(table),
8284            row_id_predicate(schema_table)
8285        );
8286        let bytes: Option<Vec<u8>> = self
8287            .conn
8288            .prepare_cached(&sql)
8289            .map_err(|error| error.to_string())?
8290            .query_row(rusqlite::params![row_id], |row| {
8291                row.get::<_, Option<Vec<u8>>>(0)
8292            })
8293            .map_err(|e| match e {
8294                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
8295                other => other.to_string(),
8296            })?;
8297        Ok(bytes)
8298    }
8299
8300    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
8301    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
8302    /// An absent row / NULL column is the empty document (empty string).
8303    #[cfg(feature = "crdt-yjs")]
8304    pub fn crdt_text(
8305        &self,
8306        table: &str,
8307        row_id: &str,
8308        column: &str,
8309        name: &str,
8310    ) -> Result<String, String> {
8311        let bytes = self
8312            .crdt_column_bytes(table, row_id, column)?
8313            .unwrap_or_default();
8314        crate::crdt::text(&bytes, name)
8315    }
8316
8317    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and
8318    /// record the resulting crdt-only sparse operation. Returns the enqueued
8319    /// `clientCommitId`.
8320    #[cfg(feature = "crdt-yjs")]
8321    pub fn crdt_insert_text(
8322        &mut self,
8323        table: &str,
8324        row_id: &str,
8325        column: &str,
8326        name: &str,
8327        index: u32,
8328        value: &str,
8329    ) -> Result<String, String> {
8330        let current = self
8331            .crdt_column_bytes(table, row_id, column)?
8332            .unwrap_or_default();
8333        let update = crate::crdt::insert_text(&current, name, index, value)?;
8334        self.crdt_push_update(table, row_id, column, &update)
8335    }
8336
8337    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and
8338    /// record the resulting crdt-only sparse operation. Returns the enqueued
8339    /// `clientCommitId`.
8340    #[cfg(feature = "crdt-yjs")]
8341    pub fn crdt_delete_text(
8342        &mut self,
8343        table: &str,
8344        row_id: &str,
8345        column: &str,
8346        name: &str,
8347        index: u32,
8348        len: u32,
8349    ) -> Result<String, String> {
8350        let current = self
8351            .crdt_column_bytes(table, row_id, column)?
8352            .unwrap_or_default();
8353        let update = crate::crdt::delete_text(&current, name, index, len)?;
8354        self.crdt_push_update(table, row_id, column, &update)
8355    }
8356
8357    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
8358    /// `crdt` column's current state and record the resulting crdt-only sparse
8359    /// operation. The app authored the update with its own `yrs` model.
8360    /// Returns the enqueued `clientCommitId`.
8361    #[cfg(feature = "crdt-yjs")]
8362    pub fn crdt_apply_update(
8363        &mut self,
8364        table: &str,
8365        row_id: &str,
8366        column: &str,
8367        update: &[u8],
8368    ) -> Result<String, String> {
8369        let current = self
8370            .crdt_column_bytes(table, row_id, column)?
8371            .unwrap_or_default();
8372        let next = crate::crdt::apply_update(&current, update)?;
8373        self.crdt_push_update(table, row_id, column, &next)
8374    }
8375
8376    /// Shared tail of the crdt edit methods: record a §6.1 sparse crdt-only
8377    /// upsert — the primary key plus the crdt column, no `baseVersion` — and
8378    /// enqueue it. The server merges the crdt column and leaves every other
8379    /// column untouched (§5.10.3), so a baseVersion-less crdt edit cannot
8380    /// clobber a concurrent edit to another column. A locally absent row
8381    /// records the same partial operation; the server answers per §6.2.
8382    #[cfg(feature = "crdt-yjs")]
8383    fn crdt_push_update(
8384        &mut self,
8385        table: &str,
8386        row_id: &str,
8387        column: &str,
8388        crdt_bytes: &[u8],
8389    ) -> Result<String, String> {
8390        let mut bytes_obj = Map::new();
8391        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
8392        let mut partial = Map::new();
8393        partial.insert(column.to_owned(), Value::Object(bytes_obj));
8394        self.patch(table, row_id, partial, None)
8395    }
8396
8397    /// Run an arbitrary read-only SQL query against the local database and
8398    /// return each row as a `column-name → JSON value` map. This is the seam
8399    /// the React `useSyncQuery` live-query API needs (it takes app-authored
8400    /// SQL over the visible tables/views, not a fixed table read like
8401    /// [`read_rows`]).
8402    ///
8403    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
8404    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
8405    /// same envelope the command surface uses everywhere else. Output BLOB
8406    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
8407    ///
8408    /// The result column typing is dynamic (SQLite's stored affinity), because
8409    /// arbitrary SQL can alias, join, and compute — there is no schema column
8410    /// to consult per output cell, unlike [`read_rows`].
8411    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
8412        query_connection(&self.conn, sql, params)
8413    }
8414
8415    /// Repository benchmark and conformance access, present only with `bench-internals`.
8416    #[cfg(feature = "bench-internals")]
8417    #[doc(hidden)]
8418    pub fn benchmark_connection(&mut self) -> &mut Connection {
8419        &mut self.conn
8420    }
8421
8422    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
8423    #[cfg(feature = "bench-internals")]
8424    #[doc(hidden)]
8425    pub fn benchmark_phases(
8426        &self,
8427        enabled: Option<bool>,
8428        reset: bool,
8429    ) -> Result<Option<Value>, String> {
8430        self.benchmark_phases.configure(enabled, reset)?;
8431        Ok(self.benchmark_phases.snapshot())
8432    }
8433
8434    /// Rows, coverage, and local revision from one SQLite read snapshot.
8435    pub fn query_snapshot(
8436        &mut self,
8437        sql: &str,
8438        params: &[QueryValue],
8439        coverage: &[WindowCoverage],
8440    ) -> Result<QuerySnapshot, String> {
8441        snapshot_connection(&self.conn, sql, params, coverage)
8442    }
8443
8444    // -- request building ---------------------------------------------------------
8445
8446    fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
8447        #[cfg(feature = "bench-internals")]
8448        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
8449        let log_epoch = self.get_meta(LOG_EPOCH_KEY);
8450        let mut frames = vec![Frame::ReqHeader {
8451            client_id: self.client_id.clone(),
8452            schema_version: self.schema.version,
8453            log_epoch: log_epoch.clone(),
8454        }];
8455        let mut pushed_ids = Vec::new();
8456        let mut ops_in_request = 0usize;
8457        let mut deferred_commits = 0usize;
8458        #[cfg(feature = "bench-internals")]
8459        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
8460        for (index, commit) in self
8461            .outbox
8462            .iter()
8463            .take(if log_epoch.is_some() {
8464                self.outbox.len()
8465            } else {
8466                0
8467            })
8468            .enumerate()
8469        {
8470            // §6.1 splitBatch: stop at the operation cap — commits apply in
8471            // order, so everything from the first non-fitting commit on is
8472            // deferred to the next round. A single over-cap commit still goes
8473            // alone (commits are atomic and cannot be split).
8474            if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
8475                deferred_commits = self.outbox.len() - index;
8476                break;
8477            }
8478            ops_in_request += commit.ops.len();
8479            let operations = commit
8480                .ops
8481                .iter()
8482                .map(|op| {
8483                    let payload = op.values.as_ref().and_then(|values| {
8484                        let table = self.schema.table(&op.table)?;
8485                        // §0: outbox entries encode at send time with the
8486                        // current codec (validated at mutate()). §6.1: the
8487                        // values map's keys are the sparse presence set.
8488                        // §5.11: encrypted columns are encrypted here, with
8489                        // an absent key-id selector read from the stored row.
8490                        // `sync_inner` runs `drop_unencodable_outbox` first,
8491                        // so an encrypt failure cannot reach this encode; the
8492                        // author seam validated every other failure.
8493                        let fallback = self.stored_key_fallback(table, &op.row_id);
8494                        encode_sparse_row_json(
8495                            table,
8496                            &op.row_id,
8497                            values,
8498                            &self.encryption,
8499                            fallback.as_ref(),
8500                        )
8501                        .ok()
8502                    });
8503                    ssp2::model::Operation {
8504                        table: op.table.clone(),
8505                        row_id: op.row_id.clone(),
8506                        op: if op.upsert { Op::Upsert } else { Op::Delete },
8507                        base_version: op.base_version,
8508                        payload,
8509                    }
8510                })
8511                .collect();
8512            frames.push(Frame::PushCommit {
8513                client_commit_id: commit.client_commit_id.clone(),
8514                operations,
8515            });
8516            pushed_ids.push(commit.client_commit_id.clone());
8517        }
8518        #[cfg(feature = "bench-internals")]
8519        drop(outbox_phase);
8520        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
8521        // bare URL — capability negotiation, decided per transport.
8522        let accept = self.limits.accept.unwrap_or(if url_capable {
8523            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
8524        } else {
8525            DEFAULT_ACCEPT
8526        });
8527        frames.push(Frame::PullHeader {
8528            limit_commits: self.limits.limit_commits.unwrap_or(0),
8529            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
8530            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
8531            accept,
8532        });
8533        let mut fresh = Vec::new();
8534        for sub in &self.subs {
8535            if sub.state != SubState::Active {
8536                continue;
8537            }
8538            let mut scopes = sub.requested.clone();
8539            sort_scope_map(&mut scopes);
8540            frames.push(Frame::Subscription {
8541                id: sub.id.clone(),
8542                table: sub.table.clone(),
8543                scopes,
8544                params: sub.params.clone().map(RawJson),
8545                cursor: sub.cursor,
8546                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
8547            });
8548            fresh.push((
8549                sub.id.clone(),
8550                sub.cursor < 0 && sub.bootstrap_state.is_none(),
8551            ));
8552        }
8553        let message = Message {
8554            wire_version: WIRE_VERSION,
8555            msg_kind: MsgKind::Request,
8556            frames,
8557        };
8558        (
8559            message,
8560            RequestMeta {
8561                pushed_ids,
8562                fresh,
8563                accept,
8564                deferred_commits,
8565            },
8566        )
8567    }
8568
8569    // -- sync -------------------------------------------------------------------
8570
8571    /// Cloneable observation handle; no client lock is needed to read it.
8572    pub fn progress(&self) -> ProgressObserver {
8573        self.progress.clone()
8574    }
8575
8576    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
8577        self.progress.start();
8578        let started_at_ms = self.clock_now_ms();
8579        let outcome = self.sync_inner(transport);
8580        self.progress.update(|p| match &outcome {
8581            SyncOutcome::Ok(report) if report.failed.is_empty() => {
8582                p.state = ProgressState::Complete
8583            }
8584            SyncOutcome::Ok(_) => {
8585                p.state = ProgressState::Failed;
8586                p.error_code = Some("sync.scope_revoked".into());
8587            }
8588            SyncOutcome::Failed { error_code, .. } => {
8589                p.state = ProgressState::Failed;
8590                p.error_code = Some(Self::diagnostic_code(error_code));
8591            }
8592        });
8593        let completed_at_ms = self.clock_now_ms();
8594        self.last_round = Some(match &outcome {
8595            SyncOutcome::Ok(report) => DiagnosticLastRound {
8596                status: "succeeded".to_owned(),
8597                started_at_ms,
8598                completed_at_ms,
8599                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
8600                counters: Some(DiagnosticRoundCounters {
8601                    pushed: report.pushed,
8602                    applied: report.applied.len(),
8603                    rejected: report.rejected.len(),
8604                    retryable: report.retryable.len(),
8605                    conflicts: report.conflicts,
8606                    commits_applied: report.commits_applied,
8607                    segment_rows_applied: report.segment_rows_applied,
8608                    bootstrapping: report.bootstrapping.len(),
8609                    resets: report.resets.len(),
8610                    revoked: report.revoked.len(),
8611                    failed: report.failed.len(),
8612                    deferred_commits: report.deferred_commits,
8613                }),
8614                error_code: None,
8615            },
8616            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
8617                status: "failed".to_owned(),
8618                started_at_ms,
8619                completed_at_ms,
8620                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
8621                counters: None,
8622                error_code: Some(Self::diagnostic_code(error_code)),
8623            },
8624        });
8625        outcome
8626    }
8627
8628    fn sync_inner(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
8629        if self.stopped {
8630            // §1.6: the client stopped at the schema floor; syncing is inert
8631            // until an upgrade. The outbox is preserved for replay.
8632            return SyncOutcome::Ok(SyncReport {
8633                schema_floor: self.schema_floor.clone(),
8634                ..SyncReport::default()
8635            });
8636        }
8637        // §8.4: the coalesced sync-needed signal clears when a pull round
8638        // BEGINS, so a wake-up landing mid-round survives it.
8639        self.set_sync_needed(false, false);
8640        // §5.9.7 B4: upload pending blobs before pushing the referencing
8641        // rows, so the server-side existence check (§6.6) passes.
8642        if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
8643            if let Err(TransportError { code, message }) = self.flush_blob_uploads(transport) {
8644                if Self::retryable_transport_code(&code) {
8645                    self.schedule_background_retry();
8646                }
8647                return SyncOutcome::Failed {
8648                    error_code: code,
8649                    message,
8650                };
8651            }
8652        }
8653        if let Err(message) = self.drain_pending_evictions() {
8654            return SyncOutcome::Failed {
8655                error_code: "storage.failed".into(),
8656                message,
8657            };
8658        }
8659        // §5.11/§10.3: an unresolvable key id or an unknown key is a durable
8660        // per-commit rejection, never a reason to abort the round. Drop those
8661        // commits before the request is built.
8662        if let Err(message) = self.drop_unencodable_outbox() {
8663            return SyncOutcome::Failed {
8664                error_code: "storage.failed".into(),
8665                message,
8666            };
8667        }
8668        let (message, meta) = self.build_request(transport.supports_url_fetch());
8669        let request_bytes = {
8670            #[cfg(feature = "bench-internals")]
8671            let _phase = self.benchmark_phases.start(Phase::RequestEncode);
8672            encode_message(&message)
8673        };
8674        // §8.7: rounds ride the socket whenever it is connected (one
8675        // loop, no fallback pair); the transport seam stays bytes-in /
8676        // bytes-out either way. Registration-at-round-end is server-side.
8677        let round = if self.realtime_connected {
8678            transport.realtime_sync(&request_bytes)
8679        } else {
8680            transport.sync(&request_bytes)
8681        };
8682        let response_bytes = match round {
8683            Ok(bytes) => bytes,
8684            Err(TransportError { code, message }) => {
8685                // §7.3.5: a request-level lease code stops-and-surfaces —
8686                // record it in leaseState (no local-data purge, §7.3.4).
8687                self.record_lease_error(&code);
8688                if Self::retryable_transport_code(&code) {
8689                    self.schedule_background_retry();
8690                }
8691                return SyncOutcome::Failed {
8692                    error_code: code,
8693                    message,
8694                };
8695            }
8696        };
8697        #[cfg(feature = "bench-internals")]
8698        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
8699        let response = match decode_message(&response_bytes) {
8700            Ok(message) => message,
8701            Err(error) => {
8702                // §1.2 rule 1 / §1.4 rule 5: truncated or malformed
8703                // responses abort without persisting anything.
8704                return SyncOutcome::Failed {
8705                    error_code: error.code.as_str().to_owned(),
8706                    message: error.detail,
8707                };
8708            }
8709        };
8710        #[cfg(feature = "bench-internals")]
8711        drop(decode_phase);
8712        if response.msg_kind != MsgKind::Response {
8713            return SyncOutcome::Failed {
8714                error_code: "sync.invalid_request".to_owned(),
8715                message: "expected a response message".to_owned(),
8716            };
8717        }
8718        let mut outcome = self.process_response(transport, response, &meta);
8719        if let SyncOutcome::Ok(report) = &mut outcome {
8720            report.deferred_commits = meta.deferred_commits;
8721        }
8722        match &outcome {
8723            SyncOutcome::Ok(_) => self.reset_background_retry(),
8724            SyncOutcome::Failed { error_code, .. }
8725                if Self::retryable_transport_code(error_code) =>
8726            {
8727                self.schedule_background_retry();
8728            }
8729            SyncOutcome::Failed { .. } => {}
8730        }
8731        if meta.deferred_commits > 0 {
8732            // §6.1 splitBatch: commits past the operation cap wait for the
8733            // next round — keep the host's sync signal raised until then.
8734            self.set_sync_needed(true, true);
8735        }
8736        // RFC 0005 D7: coverage completion, lease end, scope revocation and the
8737        // TTL are evaluated once per round, after the apply.
8738        self.previous_version_lifetime_check();
8739        outcome
8740    }
8741
8742    pub fn sync_until_idle(
8743        &mut self,
8744        transport: &mut dyn Transport,
8745        max_rounds: Option<u32>,
8746    ) -> SyncOutcome {
8747        let rounds = max_rounds.unwrap_or(20).max(1);
8748        let mut aggregate = SyncReport::default();
8749        let mut spent = 0;
8750        while spent < rounds {
8751            spent += 1;
8752            let before = self
8753                .subs
8754                .iter()
8755                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
8756                .collect::<Vec<_>>();
8757            match self.sync(transport) {
8758                SyncOutcome::Failed {
8759                    error_code,
8760                    message,
8761                } => {
8762                    return SyncOutcome::Failed {
8763                        error_code,
8764                        message,
8765                    };
8766                }
8767                SyncOutcome::Ok(report) => {
8768                    if max_rounds.is_none()
8769                        && report.segment_rows_applied > 0
8770                        && !report.bootstrapping.is_empty()
8771                        && before
8772                            != self
8773                                .subs
8774                                .iter()
8775                                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
8776                                .collect::<Vec<_>>()
8777                    {
8778                        spent = 0;
8779                    }
8780                    aggregate.pushed += report.pushed;
8781                    aggregate.applied.extend(report.applied.iter().cloned());
8782                    aggregate.rejected.extend(report.rejected.iter().cloned());
8783                    aggregate.retryable.extend(report.retryable.iter().cloned());
8784                    aggregate.conflicts += report.conflicts;
8785                    aggregate.commits_applied += report.commits_applied;
8786                    aggregate.segment_rows_applied += report.segment_rows_applied;
8787                    aggregate.bootstrapping = report.bootstrapping.clone();
8788                    aggregate.resets.extend(report.resets.iter().cloned());
8789                    aggregate.revoked.extend(report.revoked.iter().cloned());
8790                    aggregate.failed.extend(report.failed.iter().cloned());
8791                    aggregate.deferred_commits = report.deferred_commits;
8792                    if report.schema_floor.is_some() {
8793                        aggregate.schema_floor = report.schema_floor.clone();
8794                    }
8795                    // §4.5: pull again whenever the response contained
8796                    // commits or segments; resets re-bootstrap; a pending
8797                    // resume token continues paging (§4.7); a raised
8798                    // sync-needed signal covers §6.1 splitBatch remainders
8799                    // (deferred outbox commits push on the next round).
8800                    let more = !report.bootstrapping.is_empty()
8801                        || report.commits_applied > 0
8802                        || report.segment_rows_applied > 0
8803                        || !report.resets.is_empty()
8804                        || self.sync_needed;
8805                    if !more {
8806                        return SyncOutcome::Ok(aggregate);
8807                    }
8808                }
8809            }
8810        }
8811        SyncOutcome::Failed {
8812            error_code: "sync.invalid_request".into(),
8813            message: "sync did not reach idle within the round budget".into(),
8814        }
8815    }
8816
8817    fn process_response(
8818        &mut self,
8819        transport: &mut dyn Transport,
8820        response: Message,
8821        meta: &RequestMeta,
8822    ) -> SyncOutcome {
8823        #[cfg(feature = "bench-internals")]
8824        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
8825        let mut report = SyncReport {
8826            pushed: meta.pushed_ids.len() as u32,
8827            ..SyncReport::default()
8828        };
8829        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
8830            HashMap::new();
8831        let pushed_ids = meta
8832            .pushed_ids
8833            .iter()
8834            .map(String::as_str)
8835            .collect::<HashSet<_>>();
8836        let mut last_final_push_result_id: Option<String> = None;
8837        for frame in &response.frames {
8838            match frame {
8839                Frame::PushResultDetails {
8840                    client_commit_id,
8841                    entries,
8842                } => {
8843                    let details = rejection_details_by_commit
8844                        .entry(client_commit_id.clone())
8845                        .or_default();
8846                    for entry in entries {
8847                        let parsed = match RejectionDetails::parse(&entry.details.0) {
8848                            Ok(value) => value,
8849                            Err(message) => {
8850                                return SyncOutcome::Failed {
8851                                    error_code: "sync.invalid_request".to_owned(),
8852                                    message,
8853                                };
8854                            }
8855                        };
8856                        details.insert(entry.op_index, parsed);
8857                    }
8858                }
8859                Frame::PushResult {
8860                    client_commit_id,
8861                    status,
8862                    results,
8863                    ..
8864                } if pushed_ids.contains(client_commit_id.as_str())
8865                    && Self::push_result_is_final(*status, results) =>
8866                {
8867                    last_final_push_result_id = Some(client_commit_id.clone());
8868                }
8869                _ => {}
8870            }
8871        }
8872        let mut frames = response.frames.into_iter().peekable();
8873        if response.wire_version < 2 {
8874            return SyncOutcome::Failed {
8875                error_code: "client.invalid_host_response".to_owned(),
8876                message: "server response does not carry wire version 2 log-epoch state".to_owned(),
8877            };
8878        }
8879        match frames.next() {
8880            Some(Frame::RespHeader {
8881                required_schema_version,
8882                latest_schema_version,
8883                log_epoch,
8884                reset_required,
8885            }) => {
8886                let Some(log_epoch) = log_epoch else {
8887                    return SyncOutcome::Failed {
8888                        error_code: "client.invalid_host_response".to_owned(),
8889                        message: "response header omits logEpoch".to_owned(),
8890                    };
8891                };
8892                let Some(reset_required) = reset_required else {
8893                    return SyncOutcome::Failed {
8894                        error_code: "client.invalid_host_response".to_owned(),
8895                        message: "response header omits resetRequired".to_owned(),
8896                    };
8897                };
8898                if let Some(required) = required_schema_version {
8899                    // §1.6 schema-floor response: nothing else is processed;
8900                    // stop syncing and surface the upgrade requirement.
8901                    let floor = SchemaFloor {
8902                        required_schema_version: Some(required),
8903                        latest_schema_version,
8904                    };
8905                    self.set_schema_floor(Some(floor.clone()));
8906                    report.schema_floor = Some(floor);
8907                    return SyncOutcome::Ok(report);
8908                }
8909                if reset_required {
8910                    if frames.next().is_some() {
8911                        return SyncOutcome::Failed {
8912                            error_code: "client.invalid_host_response".to_owned(),
8913                            message: "log-epoch reset response contains body frames".to_owned(),
8914                        };
8915                    }
8916                    match self.run_log_epoch_reset(&log_epoch) {
8917                        Ok(resets) => {
8918                            report.resets = resets;
8919                            return SyncOutcome::Ok(report);
8920                        }
8921                        Err(message) => {
8922                            return SyncOutcome::Failed {
8923                                error_code: "sync.local_corrupt".to_owned(),
8924                                message,
8925                            };
8926                        }
8927                    }
8928                }
8929                if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
8930                    return SyncOutcome::Failed {
8931                        error_code: "client.invalid_host_response".to_owned(),
8932                        message: "server changed logEpoch without requiring a reset".to_owned(),
8933                    };
8934                }
8935            }
8936            _ => {
8937                return SyncOutcome::Failed {
8938                    error_code: "sync.invalid_request".to_owned(),
8939                    message: "response does not start with RESP_HEADER".to_owned(),
8940                };
8941            }
8942        }
8943
8944        let mut failure: Option<(String, String)> = None;
8945        while let Some(frame) = frames.next() {
8946            match frame {
8947                frame @ Frame::PushResult { .. } => {
8948                    let successful = matches!(
8949                        &frame,
8950                        Frame::PushResult {
8951                            status: PushStatus::Applied | PushStatus::Cached,
8952                            ..
8953                        }
8954                    );
8955                    let mut results = vec![frame];
8956                    if successful {
8957                        while matches!(
8958                            frames.peek(),
8959                            Some(Frame::PushResult {
8960                                status: PushStatus::Applied | PushStatus::Cached,
8961                                ..
8962                            })
8963                        ) {
8964                            if let Some(next) = frames.next() {
8965                                results.push(next);
8966                            }
8967                        }
8968                    }
8969                    if self
8970                        .handle_push_results(
8971                            &results,
8972                            &pushed_ids,
8973                            &rejection_details_by_commit,
8974                            &mut report,
8975                            last_final_push_result_id.as_deref(),
8976                        )
8977                        .is_err()
8978                    {
8979                        failure = Some((
8980                            "client.outcome_persistence_failed".to_owned(),
8981                            "local commit outcome could not be persisted".to_owned(),
8982                        ));
8983                        break;
8984                    }
8985                }
8986                Frame::PushResultDetails { .. } => {}
8987                Frame::SubStart {
8988                    id,
8989                    status,
8990                    reason_code,
8991                    effective_scopes,
8992                    bootstrap: _,
8993                } => {
8994                    let mut body = Vec::new();
8995                    let mut sub_end: Option<(i64, Option<String>)> = None;
8996                    for inner in frames.by_ref() {
8997                        match inner {
8998                            Frame::SubEnd {
8999                                next_cursor,
9000                                bootstrap_state,
9001                            } => {
9002                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
9003                                break;
9004                            }
9005                            Frame::Unknown { .. } => {}
9006                            other => body.push(other),
9007                        }
9008                    }
9009                    if let Err(SectionError::Abort(code, message)) = self.process_section(
9010                        transport,
9011                        &id,
9012                        status,
9013                        &reason_code,
9014                        effective_scopes,
9015                        body,
9016                        sub_end,
9017                        meta,
9018                        &mut report,
9019                    ) {
9020                        failure = Some((code, message));
9021                        break;
9022                    }
9023                }
9024                Frame::Lease {
9025                    lease_id,
9026                    expires_at_ms,
9027                } => {
9028                    // §7.3.5: persist the opaque lease; a fresh lease clears
9029                    // any prior lease error (the outage/revocation is over).
9030                    self.set_lease_state(Some(LeaseState {
9031                        lease_id: Some(lease_id),
9032                        expires_at_ms: Some(expires_at_ms),
9033                        error_code: None,
9034                    }));
9035                }
9036                Frame::Error { code, message, .. } => {
9037                    // §1.6: the whole request failed; already-completed
9038                    // subscriptions keep their applied data and cursors.
9039                    failure = Some((code, message));
9040                    break;
9041                }
9042                Frame::Unknown { .. } => {}
9043                _ => {
9044                    failure = Some((
9045                        "sync.invalid_request".to_owned(),
9046                        "unexpected frame in response".to_owned(),
9047                    ));
9048                    break;
9049                }
9050            }
9051        }
9052
9053        // §7.1: reconcile the visible overlay once at the response boundary
9054        // after all outbox acknowledgements and server data applied so a
9055        // batch of PUSH_RESULT frames cannot trigger repeated full-table
9056        // rebuilds. This also covers a round that aborted mid-way.
9057        if self.overlay_dirty.get() {
9058            self.rebuild_overlay();
9059        }
9060        if let Some((error_code, message)) = failure {
9061            return SyncOutcome::Failed {
9062                error_code,
9063                message,
9064            };
9065        }
9066        // §4.8 E1: the push half may have drained commits that pinned rows of
9067        // a shrunk window unit — retry any deferred evictions now.
9068        if let Err(message) = self.drain_pending_evictions() {
9069            return SyncOutcome::Failed {
9070                error_code: "storage.failed".into(),
9071                message,
9072            };
9073        }
9074        self.ack_after_pull(transport);
9075        // §7.4.5: the reset is over once the first post-reset pull round
9076        // leaves no subscription mid-bootstrap — the tables are rebuilt.
9077        if self.upgrading && report.bootstrapping.is_empty() {
9078            self.set_upgrading(false);
9079        }
9080        SyncOutcome::Ok(report)
9081    }
9082
9083    // -- push results (§6.3, §7.2) ------------------------------------------------
9084
9085    fn handle_push_results(
9086        &mut self,
9087        frames: &[Frame],
9088        pushed_ids: &HashSet<&str>,
9089        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
9090        report: &mut SyncReport,
9091        last_final_push_result_id: Option<&str>,
9092    ) -> Result<(), String> {
9093        self.begin_observation("syncular_push_results")?;
9094        let conflict_count = self.conflicts.len();
9095        let rejection_count = self.rejections.len();
9096        let applied_count = report.applied.len();
9097        let rejected_count = report.rejected.len();
9098        let retryable_count = report.retryable.len();
9099        let was_dirty = self.overlay_dirty.get();
9100        let mut removed = Vec::new();
9101        let mut batch = ChangeAccumulator::default();
9102        let persisted = (|| {
9103            for frame in frames {
9104                let Frame::PushResult {
9105                    client_commit_id,
9106                    status,
9107                    results,
9108                    ..
9109                } = frame
9110                else {
9111                    return Err("unexpected acknowledgement frame".to_owned());
9112                };
9113                if !pushed_ids.contains(client_commit_id.as_str()) {
9114                    continue;
9115                }
9116                let Some(index) = self
9117                    .outbox
9118                    .iter()
9119                    .position(|commit| commit.client_commit_id == *client_commit_id)
9120                else {
9121                    continue;
9122                };
9123                let status = *status;
9124                let rejection_details = rejection_details_by_commit.get(client_commit_id);
9125                let operations = self.outbox[index].ops.clone();
9126                match status {
9127                    PushStatus::Applied | PushStatus::Cached => {
9128                        // §7.2: a lost ack replays as `cached` — proceed as if the
9129                        // ack had arrived.
9130                        let journal_results = results
9131                            .iter()
9132                            .map(|result| {
9133                                let op_index = match result {
9134                                    OpResult::Applied { op_index }
9135                                    | OpResult::Conflict { op_index, .. }
9136                                    | OpResult::Error { op_index, .. } => *op_index,
9137                                };
9138                                CommitOperationOutcome::Applied { op_index }
9139                            })
9140                            .collect::<Vec<_>>();
9141                        let outcome_status = if status == PushStatus::Applied {
9142                            CommitOutcomeStatus::Applied
9143                        } else {
9144                            CommitOutcomeStatus::Cached
9145                        };
9146                        let persisted = self
9147                            .persist_commit_outcome(
9148                                client_commit_id,
9149                                outcome_status,
9150                                &journal_results,
9151                                None,
9152                            )
9153                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
9154                        persisted?;
9155                        self.overlay_dirty.set(true);
9156                        batch.status = true;
9157                        batch.outcomes = true;
9158                    }
9159                    PushStatus::Rejected => {
9160                        if results.iter().any(|result| {
9161                            matches!(
9162                                result,
9163                                OpResult::Error {
9164                                    code,
9165                                    retryable: true,
9166                                    ..
9167                                } if code == "sync.idempotency_cache_miss"
9168                            )
9169                        }) {
9170                            // §6.3/§7.2: a serving failure, not an outcome — keep the
9171                            // exact commit queued for an identical retry.
9172                            report.retryable.push(client_commit_id.to_owned());
9173                            continue;
9174                        }
9175
9176                        let mut journal_results = Vec::with_capacity(results.len());
9177                        let mut conflicts = Vec::new();
9178                        let mut rejections = Vec::new();
9179                        for result in results {
9180                            match result {
9181                                OpResult::Applied { op_index } => {
9182                                    journal_results.push(CommitOperationOutcome::Applied {
9183                                        op_index: *op_index,
9184                                    });
9185                                }
9186                                OpResult::Conflict {
9187                                    op_index,
9188                                    code,
9189                                    message,
9190                                    server_version,
9191                                    server_row,
9192                                    conflict_columns: conflict_columns_bitmap,
9193                                } => {
9194                                    let operation = operations
9195                                        .get(*op_index as usize)
9196                                        .map(CommitOperation::from);
9197                                    let (table, row_id) = operation
9198                                        .as_ref()
9199                                        .map(|op| (op.table.clone(), op.row_id.clone()))
9200                                        .unwrap_or_default();
9201                                    let server_row_json = self
9202                                        .schema
9203                                        .table(&table)
9204                                        .and_then(|t| {
9205                                            decode_row_bytes(t, server_row, &self.encryption)
9206                                                .ok()
9207                                                .map(|row| (t, row))
9208                                        })
9209                                        .map(|(t, row)| {
9210                                            let mut map = Map::new();
9211                                            for (i, column) in t.columns.iter().enumerate() {
9212                                                map.insert(
9213                                                    column.name.clone(),
9214                                                    column_value_to_json(
9215                                                        row.get(i).unwrap_or(&None),
9216                                                    ),
9217                                                );
9218                                            }
9219                                            map
9220                                        })
9221                                        .unwrap_or_default();
9222                                    // §6.3: decode the presence-layout bitmap
9223                                    // into the marked column names.
9224                                    let conflict_columns = self
9225                                        .schema
9226                                        .table(&table)
9227                                        .map(|t| {
9228                                            t.columns
9229                                                .iter()
9230                                                .enumerate()
9231                                                .filter(|(index, _)| {
9232                                                    conflict_columns_bitmap
9233                                                        .get(index / 8)
9234                                                        .is_some_and(|byte| {
9235                                                            byte & (1 << (index % 8)) != 0
9236                                                        })
9237                                                })
9238                                                .map(|(_, column)| column.name.clone())
9239                                                .collect::<Vec<_>>()
9240                                        })
9241                                        .unwrap_or_default();
9242                                    let conflict = ConflictRecord {
9243                                        client_commit_id: client_commit_id.to_owned(),
9244                                        op_index: *op_index,
9245                                        table,
9246                                        row_id,
9247                                        code: code.clone(),
9248                                        message: message.clone(),
9249                                        server_version: *server_version,
9250                                        server_row: server_row_json,
9251                                        conflict_columns,
9252                                        operation,
9253                                    };
9254                                    journal_results.push(CommitOperationOutcome::Conflict {
9255                                        conflict: conflict.clone(),
9256                                    });
9257                                    conflicts.push(conflict);
9258                                }
9259                                OpResult::Error {
9260                                    op_index,
9261                                    code,
9262                                    message,
9263                                    retryable,
9264                                } => {
9265                                    let rejection = RejectionRecord {
9266                                        client_commit_id: client_commit_id.to_owned(),
9267                                        op_index: *op_index,
9268                                        code: code.clone(),
9269                                        message: message.clone(),
9270                                        retryable: *retryable,
9271                                        details: rejection_details
9272                                            .and_then(|details| details.get(op_index))
9273                                            .cloned(),
9274                                        operation: operations
9275                                            .get(*op_index as usize)
9276                                            .map(CommitOperation::from),
9277                                    };
9278                                    journal_results.push(CommitOperationOutcome::Error {
9279                                        rejection: rejection.clone(),
9280                                    });
9281                                    rejections.push(rejection);
9282                                }
9283                            }
9284                        }
9285                        let outcome_status = if conflicts.is_empty() {
9286                            CommitOutcomeStatus::Rejected
9287                        } else {
9288                            CommitOutcomeStatus::Conflict
9289                        };
9290                        let persisted = self
9291                            .persist_commit_outcome(
9292                                client_commit_id,
9293                                outcome_status,
9294                                &journal_results,
9295                                Some(&operations),
9296                            )
9297                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
9298                        persisted?;
9299                        batch.conflicts = !conflicts.is_empty();
9300                        batch.rejections = !rejections.is_empty();
9301                        batch.status = true;
9302                        batch.outcomes = true;
9303                        self.conflicts.extend(conflicts);
9304                        self.rejections.extend(rejections);
9305                        self.overlay_dirty.set(true);
9306                    }
9307                }
9308                removed.push((index, self.outbox.remove(index)));
9309                if batch.status {
9310                    for operation in &operations {
9311                        if !self.record_row_scopes(
9312                            &mut batch,
9313                            &operation.table,
9314                            &operation.row_id,
9315                            false,
9316                        ) {
9317                            batch.table(&operation.table);
9318                        }
9319                    }
9320                }
9321                match status {
9322                    PushStatus::Applied | PushStatus::Cached => {
9323                        report.applied.push(client_commit_id.to_owned())
9324                    }
9325                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
9326                }
9327            }
9328            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)) {
9329                self.prune_commit_outcomes()?;
9330            }
9331            self.finish_observation("syncular_push_results", batch)
9332        })();
9333        if let Err(error) = persisted {
9334            self.rollback_observation("syncular_push_results");
9335            for (index, commit) in removed.into_iter().rev() {
9336                self.outbox.insert(index, commit);
9337            }
9338            self.conflicts.truncate(conflict_count);
9339            self.rejections.truncate(rejection_count);
9340            self.overlay_dirty.set(was_dirty);
9341            report.applied.truncate(applied_count);
9342            report.rejected.truncate(rejected_count);
9343            report.retryable.truncate(retryable_count);
9344            return Err(error);
9345        }
9346        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
9347        Ok(())
9348    }
9349
9350    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
9351        status != PushStatus::Rejected
9352            || !results.iter().any(|result| {
9353                matches!(
9354                    result,
9355                    OpResult::Error {
9356                        code,
9357                        retryable: true,
9358                        ..
9359                    } if code == "sync.idempotency_cache_miss"
9360                )
9361            })
9362    }
9363
9364    // -- subscription sections ------------------------------------------------------
9365
9366    #[allow(clippy::too_many_arguments)]
9367    fn process_section(
9368        &mut self,
9369        transport: &mut dyn Transport,
9370        id: &str,
9371        status: SubStatus,
9372        reason_code: &str,
9373        effective_scopes: Vec<(String, Vec<String>)>,
9374        body: Vec<Frame>,
9375        sub_end: Option<(i64, Option<String>)>,
9376        meta: &RequestMeta,
9377        report: &mut SyncReport,
9378    ) -> Result<(), SectionError> {
9379        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
9380            return Ok(()); // unknown echo: ignore
9381        };
9382        if status != SubStatus::Active && sub_end.is_none() {
9383            return Err(SectionError::Abort(
9384                "sync.invalid_request".into(),
9385                "subscription section without SUB_END".into(),
9386            ));
9387        }
9388        match status {
9389            SubStatus::Revoked => {
9390                self.begin_observation("syncular_revocation")
9391                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
9392                let previous = self.subs[sub_index].clone();
9393                let was_dirty = self.overlay_dirty.get();
9394                let previous_outbox = self.outbox.clone();
9395                let rejection_count = self.rejections.len();
9396                let revoked_count = report.revoked.len();
9397                let failed_count = report.failed.len();
9398                let result =
9399                    (|| {
9400                        let mut batch = ChangeAccumulator::default();
9401                        let registered = self.window_unit_by_sub_id(id);
9402                        // §3.3: stop pulling, purge exactly the last effective grant.
9403                        let (table, effective) = {
9404                            let sub = &self.subs[sub_index];
9405                            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
9406                        };
9407                        let purged = self.purge_scope_rows(&table, &effective);
9408                        match purged {
9409                            Ok(()) => {
9410                                self.record_scope_map(&mut batch, &table, &effective);
9411                                let sub = &mut self.subs[sub_index];
9412                                sub.state = SubState::Revoked;
9413                                sub.reason_code = Some(if reason_code.is_empty() {
9414                                    "sync.scope_revoked".to_owned()
9415                                } else {
9416                                    reason_code.to_owned()
9417                                });
9418                                report.revoked.push(id.to_owned());
9419                                let doomed_effective = effective;
9420                                let sub_table = table;
9421                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
9422                                    |message| SectionError::Abort("storage.failed".into(), message),
9423                                )?;
9424                                let dropped = self
9425                                    .drop_doomed_outbox(&sub_table, &doomed_effective)
9426                                    .map_err(|message| {
9427                                        SectionError::Abort("storage.failed".to_owned(), message)
9428                                    })?;
9429                                if dropped {
9430                                    batch.status = true;
9431                                    batch.rejections = true;
9432                                    batch.outcomes = true;
9433                                }
9434                                // §5.9.7 B2: revocation deletes now-unauthorized blob
9435                                // bodies (evicted ≠ revoked).
9436                                self.delete_unreferenced_cached_blobs();
9437                            }
9438                            Err(()) => {
9439                                // §3.3 fail closed: no local mapping — never clear by
9440                                // approximation; fatal configuration error.
9441                                let sub = &mut self.subs[sub_index];
9442                                sub.state = SubState::Failed;
9443                                sub.reason_code = Some("sync.scope_revoked".to_owned());
9444                                report.failed.push(id.to_owned());
9445                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
9446                                    |message| SectionError::Abort("storage.failed".into(), message),
9447                                )?;
9448                            }
9449                        }
9450                        if let Some((base_key, unit)) = registered {
9451                            batch.window(&base_key, &self.subs[sub_index].table, &unit);
9452                        }
9453                        self.rebuild_overlay_if_dirty();
9454                        self.finish_observation("syncular_revocation", batch)
9455                            .map_err(|message| {
9456                                SectionError::Abort("storage.failed".to_owned(), message)
9457                            })?;
9458                        Ok(())
9459                    })();
9460                if result.is_err() {
9461                    self.rollback_observation("syncular_revocation");
9462                    self.subs[sub_index] = previous;
9463                    self.overlay_dirty.set(was_dirty);
9464                    self.outbox = previous_outbox;
9465                    self.rejections.truncate(rejection_count);
9466                    report.revoked.truncate(revoked_count);
9467                    report.failed.truncate(failed_count);
9468                }
9469                result
9470            }
9471            SubStatus::Reset => {
9472                self.begin_observation("syncular_reset")
9473                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
9474                let previous = self.subs[sub_index].clone();
9475                let was_dirty = self.overlay_dirty.get();
9476                let reset_count = report.resets.len();
9477                let result = (|| {
9478                    let mut batch = ChangeAccumulator::default();
9479                    let registered = self.window_unit_by_sub_id(id);
9480                    // §4.6: discard cursor + bootstrap state, keep local rows —
9481                    // reset is a staleness signal, not a purge signal.
9482                    let sub = &mut self.subs[sub_index];
9483                    sub.cursor = -1;
9484                    sub.bootstrap_state = None;
9485                    report.resets.push(id.to_owned());
9486                    self.persist_sub(&self.subs[sub_index].clone())
9487                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9488                    if let Some((base_key, unit)) = registered {
9489                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
9490                    }
9491                    self.finish_observation("syncular_reset", batch)
9492                        .map_err(|message| {
9493                            SectionError::Abort("storage.failed".to_owned(), message)
9494                        })?;
9495                    Ok(())
9496                })();
9497                if result.is_err() {
9498                    self.rollback_observation("syncular_reset");
9499                    self.subs[sub_index] = previous;
9500                    self.overlay_dirty.set(was_dirty);
9501                    report.resets.truncate(reset_count);
9502                }
9503                result
9504            }
9505            SubStatus::Active => {
9506                let fresh = meta
9507                    .fresh
9508                    .iter()
9509                    .find(|(fid, _)| fid == id)
9510                    .map(|(_, fresh)| *fresh)
9511                    .unwrap_or(false);
9512                let previous = self.subs[sub_index].clone();
9513                self.subs[sub_index].effective = Some(effective_scopes);
9514                let outcome = self
9515                    .apply_section_body(transport, sub_index, body, fresh, meta, report)
9516                    .and_then(|()| {
9517                        let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
9518                            SectionError::Abort(
9519                                "sync.invalid_request".into(),
9520                                "subscription section without SUB_END".into(),
9521                            )
9522                        })?;
9523                        self.apply_sub_end(sub_index, cursor, bootstrap_state)
9524                            .map_err(|message| {
9525                                SectionError::Abort("storage.failed".into(), message)
9526                            })
9527                    });
9528                match outcome {
9529                    Ok(()) => {
9530                        if self.subs[sub_index].bootstrap_state.is_some() {
9531                            report.bootstrapping.push(id.to_owned());
9532                        }
9533                        Ok(())
9534                    }
9535                    Err(SectionError::FailClosed) => {
9536                        self.subs[sub_index] = previous.clone();
9537                        self.begin_observation("syncular_section_failure")
9538                            .map_err(|message| {
9539                                SectionError::Abort("storage.failed".into(), message)
9540                            })?;
9541                        let persisted = (|| {
9542                            let mut batch = ChangeAccumulator::default();
9543                            if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
9544                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
9545                            }
9546                            self.subs[sub_index].state = SubState::Failed;
9547                            self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
9548                            self.persist_sub(&self.subs[sub_index])?;
9549                            self.finish_observation("syncular_section_failure", batch)
9550                        })();
9551                        if let Err(message) = persisted {
9552                            self.rollback_observation("syncular_section_failure");
9553                            self.subs[sub_index] = previous;
9554                            return Err(SectionError::Abort("storage.failed".into(), message));
9555                        }
9556                        report.failed.push(id.to_owned());
9557                        Ok(())
9558                    }
9559                    Err(error) => {
9560                        // Frame/block transactions already completed. Preserve their
9561                        // rows and revisions, but leave this subscription cursor unchanged.
9562                        self.subs[sub_index] = previous;
9563                        Err(error)
9564                    }
9565                }
9566            }
9567        }
9568    }
9569
9570    fn apply_sub_end(
9571        &mut self,
9572        sub_index: usize,
9573        cursor: i64,
9574        bootstrap_state: Option<String>,
9575    ) -> Result<(), String> {
9576        #[cfg(feature = "bench-internals")]
9577        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
9578        let previous = self.subs[sub_index].clone();
9579        self.begin_observation("syncular_sub_end")?;
9580        let persisted = (|| {
9581            let mut batch = ChangeAccumulator::default();
9582            let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
9583                && cursor >= 0
9584                && bootstrap_state.is_none();
9585            if completed {
9586                if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
9587                    batch.window(&base_key, &previous.table, &unit);
9588                }
9589            }
9590            let sub = &mut self.subs[sub_index];
9591            sub.cursor = cursor;
9592            sub.bootstrap_state = bootstrap_state;
9593            sub.synced_once = true;
9594            self.persist_sub(&self.subs[sub_index])?;
9595            self.finish_observation("syncular_sub_end", batch)
9596        })();
9597        if persisted.is_err() {
9598            self.rollback_observation("syncular_sub_end");
9599            self.subs[sub_index] = previous;
9600        }
9601        persisted
9602    }
9603
9604    fn apply_section_body(
9605        &mut self,
9606        transport: &mut dyn Transport,
9607        sub_index: usize,
9608        body: Vec<Frame>,
9609        fresh: bool,
9610        meta: &RequestMeta,
9611        report: &mut SyncReport,
9612    ) -> Result<(), SectionError> {
9613        let mut saw_segment = false;
9614        for frame in body {
9615            match frame {
9616                Frame::Commit {
9617                    tables, changes, ..
9618                } => {
9619                    self.apply_commit_frame(&tables, &changes)
9620                        .map_err(|(c, m)| SectionError::Abort(c, m))?;
9621                    report.commits_applied += 1;
9622                }
9623                Frame::SegmentInline { payload } => {
9624                    let segment = decode_rows_segment(&payload)
9625                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
9626                    self.progress.update(|p| {
9627                        p.phase = ProgressPhase::Import;
9628                        p.subscription_id = Some(self.subs[sub_index].id.clone());
9629                        p.table = Some(self.subs[sub_index].table.clone());
9630                        p.segment_id = None;
9631                        p.bytes_received = payload.len() as u64;
9632                        p.bytes_total = Some(payload.len() as u64);
9633                        p.rows_processed = 0;
9634                        p.rows_total =
9635                            Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
9636                    });
9637                    let first = !saw_segment;
9638                    saw_segment = true;
9639                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
9640                    report.segment_rows_applied += applied;
9641                }
9642                Frame::SegmentRef {
9643                    segment_id,
9644                    byte_length,
9645                    media_type,
9646                    table,
9647                    row_count,
9648                    as_of_commit_seq,
9649                    scope_digest,
9650                    row_cursor,
9651                    next_row_cursor,
9652                    url,
9653                    url_expires_at_ms,
9654                    ..
9655                } => {
9656                    // §4.2: reject a descriptor whose mediaType was not
9657                    // advertised — never skip or guess.
9658                    let advertised = match media_type {
9659                        MediaType::Rows => {
9660                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
9661                                || meta.accept & ACCEPT_INLINE_ROWS != 0
9662                        }
9663                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
9664                    };
9665                    if !advertised {
9666                        return Err(SectionError::Abort(
9667                            "sync.invalid_request".to_owned(),
9668                            format!(
9669                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
9670                                media_type.name()
9671                            ),
9672                        ));
9673                    }
9674                    self.progress.update(|p| {
9675                        p.phase = ProgressPhase::Download;
9676                        p.subscription_id = Some(self.subs[sub_index].id.clone());
9677                        p.table = Some(table.clone());
9678                        p.segment_id = Some(segment_id.clone());
9679                        p.bytes_received = 0;
9680                        p.bytes_total = u64::try_from(byte_length).ok();
9681                        p.rows_processed = 0;
9682                        p.rows_total = u64::try_from(row_count).ok();
9683                    });
9684                    let observer = self.progress.clone();
9685                    let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
9686                    let bytes = if let Some(url) = url {
9687                        // §5.4: a url-carrying descriptor MUST be fetched
9688                        // from that URL; failure invalidates the whole
9689                        // descriptor (no fall-through to §5.5 — re-pull
9690                        // recovers, §1.4 rule 5).
9691                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
9692                            return Err(SectionError::Abort(
9693                                "sync.invalid_request".to_owned(),
9694                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
9695                                    .to_owned(),
9696                            ));
9697                        }
9698                        // §5.4: MUST NOT start a fetch at/past expiry.
9699                        if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
9700                            return Err(SectionError::Abort(
9701                                "sync.segment_expired".to_owned(),
9702                                format!(
9703                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
9704                                ),
9705                            ));
9706                        }
9707                        transport
9708                            .fetch_url(&url, &mut on_progress)
9709                            .map_err(|e| SectionError::Abort(e.code, e.message))?
9710                    } else {
9711                        let requested_scopes_json =
9712                            canonical_scope_json(&self.subs[sub_index].requested);
9713                        transport
9714                            .download_segment(
9715                                &SegmentRequest {
9716                                    segment_id: segment_id.clone(),
9717                                    table,
9718                                    requested_scopes_json,
9719                                },
9720                                &mut on_progress,
9721                            )
9722                            .map_err(|e| SectionError::Abort(e.code, e.message))?
9723                    };
9724                    // §5.1: verify the content address before applying.
9725                    on_progress(bytes.len() as u64);
9726                    let digest = Sha256::digest(&bytes);
9727                    let expected = segment_id
9728                        .strip_prefix("sha256:")
9729                        .unwrap_or(segment_id.as_str());
9730                    if bytes_to_hex(&digest) != expected {
9731                        return Err(SectionError::Abort(
9732                            "sync.invalid_request".to_owned(),
9733                            "segment bytes do not match the content address (§5.1)".to_owned(),
9734                        ));
9735                    }
9736                    self.progress.update(|p| p.phase = ProgressPhase::Import);
9737                    if media_type == MediaType::Sqlite {
9738                        // §5.3: images are whole-table — a paged sqlite
9739                        // descriptor is invalid.
9740                        if row_cursor.is_some() || next_row_cursor.is_some() {
9741                            return Err(SectionError::Abort(
9742                                "sync.invalid_request".to_owned(),
9743                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
9744                                    .to_owned(),
9745                            ));
9746                        }
9747                        let first = !saw_segment;
9748                        saw_segment = true;
9749                        let applied = self.apply_sqlite_segment(
9750                            sub_index,
9751                            &bytes,
9752                            fresh && first,
9753                            row_count,
9754                            as_of_commit_seq,
9755                            &scope_digest,
9756                        )?;
9757                        report.segment_rows_applied += applied;
9758                    } else {
9759                        let segment = decode_rows_segment(&bytes).map_err(|e| {
9760                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
9761                        })?;
9762                        let first = row_cursor.is_none();
9763                        saw_segment = true;
9764                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
9765                        report.segment_rows_applied += applied;
9766                    }
9767                }
9768                Frame::Unknown { .. } => {}
9769                Frame::Error { code, message, .. } => {
9770                    return Err(SectionError::Abort(code, message))
9771                }
9772                _ => {
9773                    return Err(SectionError::Abort(
9774                        "sync.invalid_request".to_owned(),
9775                        "unexpected frame inside a subscription section".to_owned(),
9776                    ));
9777                }
9778            }
9779        }
9780        Ok(())
9781    }
9782
9783    // Both pull and realtime frames use the same durable observation boundary.
9784    fn apply_commit_frame(
9785        &mut self,
9786        tables: &[String],
9787        changes: &[ssp2::model::Change],
9788    ) -> Result<(), (String, String)> {
9789        #[cfg(feature = "bench-internals")]
9790        let _phase = self.benchmark_phases.start(Phase::CommitApply);
9791        let was_dirty = self.overlay_dirty.get();
9792        self.begin_observation("syncular_commit")
9793            .map_err(|message| ("storage.failed".into(), message))?;
9794        let applied = (|| {
9795            let mut batch = ChangeAccumulator::default();
9796            self.record_commit_changes(&mut batch, tables, changes);
9797            self.apply_commit_changes(tables, changes)?;
9798            self.rebuild_overlay_if_dirty();
9799            self.finish_observation("syncular_commit", batch)
9800                .map_err(|message| ("storage.failed".into(), message))
9801        })();
9802        if applied.is_err() {
9803            self.rollback_observation("syncular_commit");
9804            self.overlay_dirty.set(was_dirty);
9805        }
9806        applied
9807    }
9808
9809    fn apply_commit_changes(
9810        &mut self,
9811        tables: &[String],
9812        changes: &[ssp2::model::Change],
9813    ) -> Result<(), (String, String)> {
9814        // A clean overlay can reconcile changed primary keys independently
9815        // when their tables have no secondary unique constraints. Such a
9816        // constraint can change whether another row's pending write succeeds,
9817        // so those frames still require the complete FIFO replay below.
9818        let mirror_visible = !self.overlay_dirty.get()
9819            && (self.outbox.is_empty()
9820                || changes.iter().all(|change| {
9821                    tables
9822                        .get(change.table_index as usize)
9823                        .and_then(|name| self.schema.table(name))
9824                        .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
9825                }));
9826        for change in changes {
9827            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
9828                (
9829                    "sync.invalid_request".to_owned(),
9830                    "change tableIndex out of range".to_owned(),
9831                )
9832            })?;
9833            let table = self.schema.table(table_name).ok_or_else(|| {
9834                (
9835                    "sync.schema_mismatch".to_owned(),
9836                    format!("change targets unknown table {table_name:?}"),
9837                )
9838            })?;
9839            match change.op {
9840                Op::Upsert => {
9841                    let payload = change.row.as_ref().ok_or_else(|| {
9842                        (
9843                            "sync.invalid_request".to_owned(),
9844                            "upsert change without row payload".to_owned(),
9845                        )
9846                    })?;
9847                    // §5.11: decrypt encrypted columns on apply.
9848                    let row = {
9849                        #[cfg(feature = "bench-internals")]
9850                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
9851                        decode_row_bytes(table, payload, &self.encryption)
9852                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?
9853                    };
9854                    let version = change.row_version.unwrap_or(0);
9855                    let table_name = table.name.clone();
9856                    self.write_base_row(&table_name, &row, version)
9857                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
9858                    if mirror_visible {
9859                        self.write_row(&visible_table(&table_name), &table_name, &row, version)
9860                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
9861                    }
9862                }
9863                Op::Delete => {
9864                    self.delete_base_row(table_name, &change.row_id)
9865                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
9866                    if mirror_visible {
9867                        let sql = format!(
9868                            "DELETE FROM {} WHERE {}",
9869                            visible_table(table_name),
9870                            row_id_predicate(table)
9871                        );
9872                        self.conn
9873                            .prepare_cached(&sql)
9874                            .and_then(|mut statement| {
9875                                statement.execute(rusqlite::params![change.row_id])
9876                            })
9877                            .map_err(|error| {
9878                                ("sync.invalid_request".to_owned(), error.to_string())
9879                            })?;
9880                    }
9881                }
9882            }
9883        }
9884        if mirror_visible {
9885            if !self.outbox.is_empty() {
9886                let changed_rows: HashSet<(&str, &str)> = changes
9887                    .iter()
9888                    .map(|change| {
9889                        (
9890                            tables[change.table_index as usize].as_str(),
9891                            change.row_id.as_str(),
9892                        )
9893                    })
9894                    .collect();
9895                self.apply_outbox_ops(
9896                    self.outbox
9897                        .iter()
9898                        .flat_map(|commit| &commit.ops)
9899                        .filter(|op| {
9900                            changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
9901                        }),
9902                );
9903            }
9904            self.overlay_dirty.set(false);
9905        }
9906        Ok(())
9907    }
9908
9909    /// §5.6 segment application: validate against the generated schema,
9910    /// clear the grant on a fresh bootstrap's first page (fail closed
9911    /// without a mapping), then replace-or-upsert each row with its
9912    /// segment-carried server version (§5.2).
9913    fn apply_segment(
9914        &mut self,
9915        sub_index: usize,
9916        segment: &RowsSegment,
9917        first_fresh_page: bool,
9918    ) -> Result<u32, SectionError> {
9919        let (sub_table, effective) = {
9920            let sub = &self.subs[sub_index];
9921            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
9922        };
9923        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
9924            SectionError::Abort(
9925                "sync.schema_mismatch".to_owned(),
9926                format!("subscription table {sub_table:?} is not in the client schema"),
9927            )
9928        })?;
9929        // §5.2: the column table validates against the generated schema —
9930        // order, names, types, nullability; mismatch is fatal. §5.11: the
9931        // server sends the WIRE types (bytes for an encrypted column), so
9932        // validate against wire_columns.
9933        let matches = segment.table == table.name
9934            && segment.schema_version == self.schema.version
9935            && segment.columns.len() == table.wire_columns.len()
9936            && segment
9937                .columns
9938                .iter()
9939                .zip(table.wire_columns.iter())
9940                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
9941        if !matches {
9942            return Err(SectionError::Abort(
9943                "sync.schema_mismatch".to_owned(),
9944                "segment column table does not match the generated schema (§5.2)".to_owned(),
9945            ));
9946        }
9947        let mut applied = 0u32;
9948        let empty = Vec::new();
9949        let blocks = if segment.blocks.is_empty() {
9950            std::slice::from_ref(&empty)
9951        } else {
9952            &segment.blocks
9953        };
9954        for (index, block) in blocks.iter().enumerate() {
9955            let clear = first_fresh_page && index == 0;
9956            if !clear && block.is_empty() {
9957                continue;
9958            }
9959            let was_dirty = self.overlay_dirty.get();
9960            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
9961                && self
9962                    .outbox
9963                    .iter()
9964                    .flat_map(|commit| &commit.ops)
9965                    .any(|op| op.table == table.name);
9966            let mirror_visible = !reconcile_pending;
9967            self.begin_observation("syncular_segment_block")
9968                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9969            let outcome = (|| {
9970                self.rebuild_overlay_if_dirty();
9971                let mut changed_keys = Vec::new();
9972                let mut batch = ChangeAccumulator::default();
9973                if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
9974                    batch.table(&table.name);
9975                }
9976                if clear {
9977                    self.purge_scope_rows(&table.name, &effective)
9978                        .map_err(|()| SectionError::FailClosed)?;
9979                }
9980                for (row_index, row) in block.iter().enumerate() {
9981                    let decrypted;
9982                    let values = if table.has_encrypted_columns() {
9983                        let mut values = row.values.clone();
9984                        crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
9985                            .map_err(|message| {
9986                            SectionError::Abort("client.decrypt_failed".into(), message)
9987                        })?;
9988                        decrypted = values;
9989                        &decrypted
9990                    } else {
9991                        &row.values
9992                    };
9993                    self.write_base_row(&table.name, values, row.server_version)
9994                        .map_err(|message| {
9995                            SectionError::Abort("sync.invalid_request".into(), message)
9996                        })?;
9997                    if reconcile_pending {
9998                        changed_keys.push(
9999                            owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
10000                                |message| SectionError::Abort("storage.failed".into(), message),
10001                            )?,
10002                        );
10003                    }
10004                    if mirror_visible {
10005                        self.write_row(
10006                            &visible_table(&table.name),
10007                            &table.name,
10008                            values,
10009                            row.server_version,
10010                        )
10011                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10012                    }
10013                    let processed = u64::from(applied) + row_index as u64 + 1;
10014                    if processed.is_multiple_of(1024) {
10015                        self.progress.update(|p| p.rows_processed = processed);
10016                    }
10017                }
10018                if reconcile_pending {
10019                    self.reconcile_imported_rows(&table, &mut changed_keys)
10020                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10021                }
10022                self.apply_outbox_ops(
10023                    self.outbox
10024                        .iter()
10025                        .flat_map(|commit| &commit.ops)
10026                        .filter(|op| op.table == table.name),
10027                );
10028                self.overlay_dirty.set(false);
10029                self.finish_observation("syncular_segment_block", batch)
10030                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
10031            })();
10032            if let Err(error) = outcome {
10033                self.rollback_observation("syncular_segment_block");
10034                self.overlay_dirty.set(was_dirty);
10035                return Err(error);
10036            }
10037            applied += block.len() as u32;
10038            self.progress
10039                .update(|p| p.rows_processed = u64::from(applied));
10040            std::thread::yield_now();
10041        }
10042        self.progress
10043            .update(|p| p.rows_processed = u64::from(applied));
10044        Ok(applied)
10045    }
10046
10047    /// §5.3 sqlite-image application: validate the in-file metadata
10048    /// against the descriptor, validate column names/order against the
10049    /// generated schema, run the §5.6 first-page clear when fresh, then
10050    /// replace-or-upsert every image row with its `_syncular_version`.
10051    /// Mechanics: the image lands in a temp file read through a second
10052    /// rusqlite connection, equivalent to ATTACH + INSERT…SELECT.
10053    /// Each image chunk commits its writes and revision before readers continue.
10054    fn apply_sqlite_segment(
10055        &mut self,
10056        sub_index: usize,
10057        bytes: &[u8],
10058        first_fresh_page: bool,
10059        row_count: i64,
10060        as_of_commit_seq: i64,
10061        scope_digest: &str,
10062    ) -> Result<u32, SectionError> {
10063        let invalid = |detail: &str| {
10064            SectionError::Abort(
10065                "sync.invalid_request".to_owned(),
10066                format!("sqlite segment rejected: {detail} (§5.3)"),
10067            )
10068        };
10069        let (sub_table, effective) = {
10070            let sub = &self.subs[sub_index];
10071            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
10072        };
10073        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
10074            SectionError::Abort(
10075                "sync.schema_mismatch".to_owned(),
10076                format!("subscription table {sub_table:?} is not in the client schema"),
10077            )
10078        })?;
10079
10080        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
10081        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
10082        let img = match rusqlite::Connection::open_with_flags(
10083            &path,
10084            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
10085        ) {
10086            Ok(conn) => conn,
10087            Err(_) => {
10088                let _ = std::fs::remove_file(&path);
10089                return Err(invalid("bytes do not open as a SQLite database"));
10090            }
10091        };
10092        let outcome = self.apply_sqlite_image(
10093            &img,
10094            &table,
10095            first_fresh_page,
10096            &effective,
10097            row_count,
10098            as_of_commit_seq,
10099            scope_digest,
10100        );
10101        drop(img);
10102        let _ = std::fs::remove_file(&path);
10103        outcome
10104    }
10105
10106    #[allow(clippy::too_many_arguments)]
10107    fn apply_sqlite_image(
10108        &mut self,
10109        img: &rusqlite::Connection,
10110        table: &crate::schema::TableSchema,
10111        first_fresh_page: bool,
10112        effective: &[(String, Vec<String>)],
10113        row_count: i64,
10114        as_of_commit_seq: i64,
10115        scope_digest: &str,
10116    ) -> Result<u32, SectionError> {
10117        let invalid = |detail: String| {
10118            SectionError::Abort(
10119                "sync.invalid_request".to_owned(),
10120                format!("sqlite segment rejected: {detail} (§5.3)"),
10121            )
10122        };
10123
10124        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
10125        //    one row).
10126        type MetaRow = (i64, String, i64, i64, String, i64, i64);
10127        let meta: MetaRow = img
10128            .query_row(
10129                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
10130                        \"scopeDigest\", \"rowCount\",
10131                        (SELECT count(*) FROM _syncular_segment)
10132                 FROM _syncular_segment",
10133                [],
10134                |row| {
10135                    Ok((
10136                        row.get(0)?,
10137                        row.get(1)?,
10138                        row.get(2)?,
10139                        row.get(3)?,
10140                        row.get(4)?,
10141                        row.get(5)?,
10142                        row.get(6)?,
10143                    ))
10144                },
10145            )
10146            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
10147        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
10148        if meta_count != 1 {
10149            return Err(invalid(format!(
10150                "_syncular_segment must contain exactly one row, found {meta_count}"
10151            )));
10152        }
10153        if format != 1 {
10154            return Err(invalid(format!("format {format}")));
10155        }
10156        if meta_table != table.name {
10157            return Err(invalid(format!("image table {meta_table:?}")));
10158        }
10159        if schema_version != i64::from(self.schema.version) {
10160            return Err(invalid(format!("schemaVersion {schema_version}")));
10161        }
10162        if pin != as_of_commit_seq {
10163            return Err(invalid(format!("asOfCommitSeq {pin}")));
10164        }
10165        if digest != scope_digest {
10166            return Err(invalid("scopeDigest mismatch".to_owned()));
10167        }
10168        if meta_rows != row_count {
10169            return Err(invalid(format!("rowCount {meta_rows}")));
10170        }
10171
10172        // 2. Column names and order vs the generated schema (§5.3 rule 3).
10173        let mut names: Vec<String> = Vec::new();
10174        let mut primary_keys: Vec<String> = Vec::new();
10175        {
10176            let mut stmt = img
10177                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
10178                .map_err(|_| invalid("image data table missing".to_owned()))?;
10179            let mut rows = stmt
10180                .query([])
10181                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
10182            while let Some(row) = rows
10183                .next()
10184                .map_err(|_| invalid("image data table unreadable".to_owned()))?
10185            {
10186                if row.get::<_, i64>(5).map_err(|e| invalid(e.to_string()))? > 0 {
10187                    primary_keys.push(row.get(1).map_err(|e| invalid(e.to_string()))?);
10188                }
10189                names.push(
10190                    row.get::<_, String>(1)
10191                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
10192                );
10193            }
10194        }
10195        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
10196        expected.push("_syncular_version");
10197        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
10198            return Err(SectionError::Abort(
10199                "sync.schema_mismatch".to_owned(),
10200                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
10201            ));
10202        }
10203
10204        if primary_keys != vec![table.primary_key.clone()] {
10205            return Err(invalid(
10206                "image primary key does not match generated schema".into(),
10207            ));
10208        }
10209        let actual_count: i64 = img
10210            .query_row(
10211                &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
10212                [],
10213                |row| row.get(0),
10214            )
10215            .map_err(|e| invalid(e.to_string()))?;
10216        if actual_count != row_count {
10217            return Err(invalid("image row count does not match descriptor".into()));
10218        }
10219        let insert = self.insert_row_sql(&base_table(&table.name), table);
10220        let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
10221        let column_list = names
10222            .iter()
10223            .map(|n| quote_ident(n))
10224            .collect::<Vec<_>>()
10225            .join(", ");
10226        let mut stmt = img
10227            .prepare(&format!(
10228                "SELECT {column_list} FROM {} ORDER BY {}",
10229                quote_ident(&table.name),
10230                quote_ident(&table.primary_key)
10231            ))
10232            .map_err(|e| invalid(e.to_string()))?;
10233        let mut rows = stmt.query([]).map_err(|e| invalid(e.to_string()))?;
10234        let mut applied = 0u32;
10235        loop {
10236            let clear = applied == 0 && first_fresh_page;
10237            let was_dirty = self.overlay_dirty.get();
10238            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
10239                && self
10240                    .outbox
10241                    .iter()
10242                    .flat_map(|commit| &commit.ops)
10243                    .any(|op| op.table == table.name);
10244            let mirror_visible = !reconcile_pending;
10245            self.begin_observation("syncular_image_chunk")
10246                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10247            let result = (|| {
10248                self.rebuild_overlay_if_dirty();
10249                let mut changed_keys = Vec::new();
10250                let mut batch = ChangeAccumulator::default();
10251                if clear {
10252                    if self.scoped_rows_exist(&table.name, effective) {
10253                        batch.table(&table.name);
10254                    }
10255                    self.purge_scope_rows(&table.name, effective)
10256                        .map_err(|()| SectionError::FailClosed)?;
10257                }
10258                let mut processed = 0u32;
10259                {
10260                    let mut ins = self
10261                        .conn
10262                        .prepare_cached(&insert)
10263                        .map_err(|e| invalid(e.to_string()))?;
10264                    let mut mirror = if mirror_visible {
10265                        Some(
10266                            self.conn
10267                                .prepare_cached(&visible_insert)
10268                                .map_err(|e| invalid(e.to_string()))?,
10269                        )
10270                    } else {
10271                        None
10272                    };
10273                    for _ in 0..1024 {
10274                        let Some(row) = rows.next().map_err(|e| invalid(e.to_string()))? else {
10275                            break;
10276                        };
10277                        for (i, column) in table.columns.iter().enumerate() {
10278                            let cell = row.get_ref(i).map_err(|e| invalid(e.to_string()))?;
10279                            let param = image_cell_param(column, cell).map_err(&invalid)?;
10280                            ins.raw_bind_parameter(i + 1, &param)
10281                                .map_err(|e| invalid(e.to_string()))?;
10282                            if let Some(mirror) = &mut mirror {
10283                                mirror
10284                                    .raw_bind_parameter(i + 1, &param)
10285                                    .map_err(|e| invalid(e.to_string()))?;
10286                            }
10287                        }
10288                        let version_index = table.columns.len();
10289                        let version: i64 =
10290                            row.get(version_index).map_err(|e| invalid(e.to_string()))?;
10291                        if version < 1 {
10292                            return Err(invalid("image row version must be positive".into()));
10293                        }
10294                        ins.raw_bind_parameter(version_index + 1, version)
10295                            .map_err(|e| invalid(e.to_string()))?;
10296                        ins.raw_execute().map_err(|e| invalid(e.to_string()))?;
10297                        if let Some(mirror) = &mut mirror {
10298                            mirror
10299                                .raw_bind_parameter(version_index + 1, version)
10300                                .map_err(|e| invalid(e.to_string()))?;
10301                            mirror.raw_execute().map_err(|e| invalid(e.to_string()))?;
10302                        }
10303                        if reconcile_pending {
10304                            changed_keys.push(
10305                                row.get::<_, SqlValue>(table.pk_index)
10306                                    .map_err(|error| invalid(error.to_string()))?,
10307                            );
10308                        }
10309                        processed += 1;
10310                    }
10311                }
10312                if processed > 0 {
10313                    batch.table(&table.name);
10314                    self.overlay_dirty.set(true);
10315                }
10316                if reconcile_pending {
10317                    self.reconcile_imported_rows(table, &mut changed_keys)
10318                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10319                }
10320                self.apply_outbox_ops(
10321                    self.outbox
10322                        .iter()
10323                        .flat_map(|commit| &commit.ops)
10324                        .filter(|op| op.table == table.name),
10325                );
10326                self.overlay_dirty.set(false);
10327                self.finish_observation("syncular_image_chunk", batch)
10328                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
10329                Ok(processed)
10330            })();
10331            let processed = match result {
10332                Ok(processed) => processed,
10333                Err(error) => {
10334                    self.rollback_observation("syncular_image_chunk");
10335                    self.overlay_dirty.set(was_dirty);
10336                    return Err(error);
10337                }
10338            };
10339            applied += processed;
10340            self.progress
10341                .update(|p| p.rows_processed = u64::from(applied));
10342            std::thread::yield_now();
10343            if i64::from(applied) == actual_count {
10344                break;
10345            }
10346        }
10347        Ok(applied)
10348    }
10349
10350    /// Restore only imported and pending row identities before FIFO replay. All pending
10351    /// identities participate: a server row can free a unique value for a different edit.
10352    /// The caller owns the chunk transaction and publishes its revision after replay.
10353    fn reconcile_imported_rows(
10354        &self,
10355        table: &TableSchema,
10356        keys: &mut Vec<SqlValue>,
10357    ) -> Result<(), String> {
10358        let visible = visible_table(&table.name);
10359        let base = base_table(&table.name);
10360        let pk = quote_ident(&table.primary_key);
10361        let predicate = row_id_predicate(table);
10362        let mut lookup = self.conn.prepare_cached(&format!(
10363            "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
10364        )).map_err(|error| error.to_string())?;
10365        let mut deleted = HashSet::new();
10366        for op in self
10367            .outbox
10368            .iter()
10369            .flat_map(|commit| &commit.ops)
10370            .filter(|op| op.table == table.name)
10371        {
10372            if op.upsert {
10373                if let Some(values) = &op.values {
10374                    let key = json_to_column_value(
10375                        &table.columns[table.pk_index],
10376                        values.get(&table.primary_key),
10377                    )?;
10378                    keys.push(owned_sql_value(RowParam::Cell(&key))?);
10379                }
10380            } else if deleted.insert(&op.row_id) {
10381                keys.extend(
10382                    lookup
10383                        .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
10384                        .map_err(|error| error.to_string())?
10385                        .collect::<Result<Vec<_>, _>>()
10386                        .map_err(|error| error.to_string())?,
10387                );
10388            }
10389        }
10390        // Delete the complete affected set before restoring any base value, so
10391        // old optimistic unique values cannot collide with the new server rows.
10392        for chunk in keys.chunks(400) {
10393            let holes = vec!["?"; chunk.len()].join(",");
10394            self.conn
10395                .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
10396                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10397                .map_err(|error| error.to_string())?;
10398        }
10399        let updates = table
10400            .columns
10401            .iter()
10402            .map(|column| column.name.as_str())
10403            .chain(std::iter::once("_syncular_version"))
10404            .filter(|name| *name != table.primary_key)
10405            .map(|name| {
10406                let name = quote_ident(name);
10407                format!("{name}=excluded.{name}")
10408            })
10409            .collect::<Vec<_>>()
10410            .join(",");
10411        for chunk in keys.chunks(400) {
10412            let holes = vec!["?"; chunk.len()].join(",");
10413            self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
10414                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10415                .map_err(|error| error.to_string())?;
10416        }
10417        Ok(())
10418    }
10419
10420    // -- application-authorized local purge ---------------------------------------
10421
10422    fn compile_local_data_purge(
10423        &self,
10424        input: &LocalDataPurgeInput,
10425    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
10426        let invalid = |message: String| format!("sync.invalid_request: {message}");
10427        if input.purge_id.is_empty()
10428            || input.purge_id.len() > 128
10429            || !is_local_operation_code_like(&input.purge_id)
10430        {
10431            return Err(invalid(
10432                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
10433            ));
10434        }
10435        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
10436            return Err(invalid(format!(
10437                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
10438            )));
10439        }
10440
10441        let mut deduplicated: BTreeMap<
10442            String,
10443            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
10444        > = BTreeMap::new();
10445        for target in &input.targets {
10446            let table = self.schema.table(&target.table).ok_or_else(|| {
10447                invalid(format!(
10448                    "local purge names unknown table {:?}",
10449                    target.table
10450                ))
10451            })?;
10452            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
10453                return Err(invalid(format!(
10454                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
10455                    target.table
10456                )));
10457            }
10458            let mut selectors = Vec::with_capacity(target.selectors.len());
10459            let mut canonical_selectors = BTreeMap::new();
10460            for (column_name, raw_values) in &target.selectors {
10461                let Some((column_index, column)) = table
10462                    .columns
10463                    .iter()
10464                    .enumerate()
10465                    .find(|(_, column)| column.name == *column_name)
10466                else {
10467                    return Err(invalid(format!(
10468                        "local purge target {:?} names unknown column {:?}",
10469                        target.table, column_name
10470                    )));
10471                };
10472                let encrypted = table
10473                    .encrypted_columns
10474                    .iter()
10475                    .any(|candidate| candidate.index == column_index);
10476                if column.ty != ColumnType::String || encrypted {
10477                    return Err(invalid(format!(
10478                        "local purge selector {:?}.{:?} must be a plaintext string column",
10479                        target.table, column_name
10480                    )));
10481                }
10482                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
10483                    return Err(invalid(format!(
10484                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
10485                        target.table, column_name
10486                    )));
10487                }
10488                let mut values = raw_values.clone();
10489                values.sort();
10490                values.dedup();
10491                if values.iter().any(|value| {
10492                    value.is_empty()
10493                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
10494                        || !is_local_operation_code_like(value)
10495                }) {
10496                    return Err(invalid(format!(
10497                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
10498                    )));
10499                }
10500                selectors.push((column_name.clone(), values.clone()));
10501                canonical_selectors.insert(column_name.clone(), values);
10502            }
10503            let canonical = LocalDataPurgeTarget {
10504                table: target.table.clone(),
10505                selectors: canonical_selectors,
10506            };
10507            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
10508            deduplicated.insert(
10509                key,
10510                (
10511                    CompiledLocalDataPurgeTarget {
10512                        table: target.table.clone(),
10513                        selectors,
10514                    },
10515                    canonical,
10516                ),
10517            );
10518        }
10519        let targets = deduplicated
10520            .values()
10521            .map(|(compiled, _)| compiled.clone())
10522            .collect::<Vec<_>>();
10523        let canonical = deduplicated
10524            .values()
10525            .map(|(_, target)| target.clone())
10526            .collect::<Vec<_>>();
10527        let canonical_plan =
10528            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
10529        Ok((targets, canonical_plan))
10530    }
10531
10532    fn local_purge_base_row_ids(
10533        &self,
10534        targets: &[CompiledLocalDataPurgeTarget],
10535    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
10536        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10537        for target in targets {
10538            let table = self
10539                .schema
10540                .table(&target.table)
10541                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
10542            let mut clauses = Vec::with_capacity(target.selectors.len());
10543            let mut params = Vec::new();
10544            for (column, values) in &target.selectors {
10545                clauses.push(format!(
10546                    "{} IN ({})",
10547                    quote_ident(column),
10548                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
10549                ));
10550                params.extend(values.iter().cloned().map(SqlValue::Text));
10551            }
10552            let sql = format!(
10553                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
10554                quote_ident(&table.primary_key),
10555                base_table(&target.table),
10556                clauses.join(" AND ")
10557            );
10558            let mut statement = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
10559            let rows = statement
10560                .query_map(rusqlite::params_from_iter(params), |row| {
10561                    row.get::<_, String>(0)
10562                })
10563                .map_err(|error| error.to_string())?;
10564            let ids = by_table.entry(target.table.clone()).or_default();
10565            for row in rows {
10566                ids.insert(row.map_err(|error| error.to_string())?);
10567            }
10568        }
10569        Ok(by_table)
10570    }
10571
10572    fn local_purge_values_match(
10573        target: &CompiledLocalDataPurgeTarget,
10574        values: &Map<String, Value>,
10575    ) -> bool {
10576        target.selectors.iter().all(|(column, allowed)| {
10577            values
10578                .get(column)
10579                .and_then(Value::as_str)
10580                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
10581        })
10582    }
10583
10584    /// Apply one host-authorized local security purge. The host MUST gate the
10585    /// corresponding subscriptions first; this primitive owns local SQLite
10586    /// cleanup and never grants/revokes server authority by itself.
10587    pub fn purge_local_data(
10588        &mut self,
10589        input: &LocalDataPurgeInput,
10590    ) -> Result<LocalDataPurgeResult, String> {
10591        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
10592        let meta_key = format!("localPurge:{}", input.purge_id);
10593        let applied_plan = self
10594            .conn
10595            .query_row(
10596                "SELECT value FROM _syncular_meta WHERE key = ?1",
10597                rusqlite::params![meta_key],
10598                |row| row.get::<_, String>(0),
10599            )
10600            .optional()
10601            .map_err(|error| error.to_string())?;
10602        if let Some(applied_plan) = applied_plan {
10603            if applied_plan != canonical_plan {
10604                return Err(format!(
10605                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
10606                    input.purge_id
10607                ));
10608            }
10609            return Ok(LocalDataPurgeResult {
10610                already_applied: true,
10611                purged_rows: 0,
10612                dropped_commits: 0,
10613            });
10614        }
10615
10616        // RFC 0005 D7: the container file cannot be a purge target (its name is
10617        // not in the running schema), so purge drops it as a fixed step, before
10618        // any mirror row is touched and unconditionally.
10619        let _ = self.drop_previous_version();
10620
10621        let prior_outbox = self.outbox.clone();
10622        let prior_rejection_count = self.rejections.len();
10623        let prior_overlay_dirty = self.overlay_dirty.get();
10624        self.begin_observation("syncular_local_purge")?;
10625        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
10626            let row_ids = self.local_purge_base_row_ids(&targets)?;
10627            let doomed = self
10628                .outbox
10629                .iter()
10630                .filter(|commit| {
10631                    commit.ops.iter().any(|operation| {
10632                        let matching_targets = targets
10633                            .iter()
10634                            .filter(|target| target.table == operation.table)
10635                            .collect::<Vec<_>>();
10636                        if matching_targets.is_empty() {
10637                            return false;
10638                        }
10639                        if row_ids
10640                            .get(&operation.table)
10641                            .is_some_and(|ids| ids.contains(&operation.row_id))
10642                        {
10643                            return true;
10644                        }
10645                        operation.values.as_ref().is_some_and(|values| {
10646                            matching_targets
10647                                .iter()
10648                                .any(|target| Self::local_purge_values_match(target, values))
10649                        })
10650                    })
10651                })
10652                .cloned()
10653                .collect::<Vec<_>>();
10654            let doomed_ids = doomed
10655                .iter()
10656                .map(|commit| commit.client_commit_id.clone())
10657                .collect::<BTreeSet<_>>();
10658            let mut batch = ChangeAccumulator::default();
10659            let mut rejections = Vec::new();
10660            for commit in &doomed {
10661                for operation in &commit.ops {
10662                    batch.table(&operation.table);
10663                }
10664                let results = commit
10665                    .ops
10666                    .iter()
10667                    .enumerate()
10668                    .map(|(op_index, operation)| {
10669                        let rejection = RejectionRecord {
10670                            client_commit_id: commit.client_commit_id.clone(),
10671                            op_index: op_index as i32,
10672                            code: "client.local_data_purged".to_owned(),
10673                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
10674                            retryable: false,
10675                            details: None,
10676                            operation: Some(CommitOperation::from(operation)),
10677                        };
10678                        rejections.push(rejection.clone());
10679                        CommitOperationOutcome::Error { rejection }
10680                    })
10681                    .collect::<Vec<_>>();
10682                self.persist_commit_outcome(
10683                    &commit.client_commit_id,
10684                    CommitOutcomeStatus::Rejected,
10685                    &results,
10686                    Some(&commit.ops),
10687                )?;
10688                self.delete_outbox_persisted(&commit.client_commit_id)?;
10689            }
10690            if !doomed.is_empty() {
10691                self.outbox
10692                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
10693                self.rejections.extend(rejections);
10694                self.prune_commit_outcomes()?;
10695                self.overlay_dirty.set(true);
10696                batch.status = true;
10697                batch.rejections = true;
10698                batch.outcomes = true;
10699            }
10700
10701            let mut purged_rows = 0usize;
10702            for (table, ids) in &row_ids {
10703                if ids.is_empty() {
10704                    continue;
10705                }
10706                batch.table(table);
10707                for row_id in ids {
10708                    self.delete_base_row(table, row_id)?;
10709                    purged_rows += 1;
10710                }
10711            }
10712            self.rebuild_overlay_if_dirty();
10713            self.delete_unreferenced_cached_blobs();
10714            self.conn
10715                .execute(
10716                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
10717                    rusqlite::params![meta_key, canonical_plan],
10718                )
10719                .map_err(|error| error.to_string())?;
10720            Ok((
10721                batch,
10722                LocalDataPurgeResult {
10723                    already_applied: false,
10724                    purged_rows,
10725                    dropped_commits: doomed.len(),
10726                },
10727            ))
10728        })();
10729        let (batch, result) = match applied {
10730            Ok(value) => value,
10731            Err(error) => {
10732                self.rollback_observation("syncular_local_purge");
10733                self.outbox = prior_outbox;
10734                self.rejections.truncate(prior_rejection_count);
10735                self.overlay_dirty.set(prior_overlay_dirty);
10736                return Err(error);
10737            }
10738        };
10739        if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
10740            self.rollback_observation("syncular_local_purge");
10741            self.outbox = prior_outbox;
10742            self.rejections.truncate(prior_rejection_count);
10743            self.overlay_dirty.set(prior_overlay_dirty);
10744            return Err(error);
10745        }
10746        Ok(result)
10747    }
10748
10749    /// Application-authorized recovery of the replicated projection. Unlike a
10750    /// local security purge, this retains the entire outbox, device identity,
10751    /// lease, outcomes, subscription registrations, and protected bookkeeping.
10752    /// The projection reset, subscription rewind, optimistic replay, and
10753    /// idempotency marker share one savepoint for interruption safety.
10754    pub fn rebootstrap_local_data(
10755        &mut self,
10756        input: &LocalDataRebootstrapInput,
10757    ) -> Result<LocalDataRebootstrapResult, String> {
10758        if input.rebootstrap_id.is_empty()
10759            || input.rebootstrap_id.len() > 128
10760            || !is_local_operation_code_like(&input.rebootstrap_id)
10761        {
10762            return Err(
10763                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
10764                    .to_owned(),
10765            );
10766        }
10767        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
10768        if let Some(persisted) = self.get_meta_strict(&meta_key)? {
10769            let (retained_commits, reset_subscriptions) =
10770                decode_local_rebootstrap_receipt(&persisted)?;
10771            return Ok(LocalDataRebootstrapResult {
10772                already_applied: true,
10773                retained_commits,
10774                reset_subscriptions,
10775            });
10776        }
10777        if self.stopped || self.schema_floor.is_some() {
10778            return Err(
10779                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
10780                    .to_owned(),
10781            );
10782        }
10783
10784        let retained_commits = self.outbox.len();
10785        let reset_subscriptions = self.subs.len();
10786        let prior_subs = self.subs.clone();
10787        let prior_upgrading = self.upgrading;
10788        let prior_stopped = self.stopped;
10789        let prior_schema_floor = self.schema_floor.clone();
10790        let prior_overlay_dirty = self.overlay_dirty.get();
10791        let prior_sync_needed = self.sync_needed;
10792        let prior_sync_intents = self.sync_intent_queue.clone();
10793        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
10794
10795        self.begin_observation("syncular_local_rebootstrap")?;
10796        let mut batch = ChangeAccumulator::default();
10797        let applied = (|| -> Result<(), String> {
10798            self.run_schema_reset_observed(&mut batch, false, false)?;
10799            self.conn
10800                .execute(
10801                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
10802                    rusqlite::params![meta_key, receipt],
10803                )
10804                .map_err(|error| error.to_string())?;
10805            self.sync_needed = true;
10806            self.sync_intent_queue.push_back(SyncIntent::Interactive);
10807            batch.status = true;
10808            Ok(())
10809        })();
10810        if let Err(error) = applied {
10811            self.rollback_observation("syncular_local_rebootstrap");
10812            self.subs = prior_subs;
10813            self.upgrading = prior_upgrading;
10814            self.stopped = prior_stopped;
10815            self.schema_floor = prior_schema_floor;
10816            self.overlay_dirty.set(prior_overlay_dirty);
10817            self.sync_needed = prior_sync_needed;
10818            self.sync_intent_queue = prior_sync_intents;
10819            return Err(error);
10820        }
10821        if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
10822            self.rollback_observation("syncular_local_rebootstrap");
10823            self.subs = prior_subs;
10824            self.upgrading = prior_upgrading;
10825            self.stopped = prior_stopped;
10826            self.schema_floor = prior_schema_floor;
10827            self.overlay_dirty.set(prior_overlay_dirty);
10828            self.sync_needed = prior_sync_needed;
10829            self.sync_intent_queue = prior_sync_intents;
10830            return Err(error);
10831        }
10832
10833        Ok(LocalDataRebootstrapResult {
10834            already_applied: false,
10835            retained_commits,
10836            reset_subscriptions,
10837        })
10838    }
10839
10840    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
10841
10842    /// Delete base rows matching the effective scopes; `Err(())` = no local
10843    /// scope-column mapping for a key (the fail-closed case).
10844    fn purge_scope_rows(
10845        &mut self,
10846        table_name: &str,
10847        effective: &[(String, Vec<String>)],
10848    ) -> Result<(), ()> {
10849        if effective.is_empty() {
10850            return Ok(());
10851        }
10852        let table = self.schema.table(table_name).ok_or(())?.clone();
10853        let mut clauses = Vec::new();
10854        let mut params: Vec<SqlValue> = Vec::new();
10855        for (variable, values) in effective {
10856            let column = table.scope_column(variable).ok_or(())?;
10857            let placeholders: Vec<String> = values
10858                .iter()
10859                .map(|v| {
10860                    params.push(SqlValue::Text(v.clone()));
10861                    "?".to_owned()
10862                })
10863                .collect();
10864            clauses.push(format!(
10865                "{} IN ({})",
10866                quote_ident(column),
10867                placeholders.join(", ")
10868            ));
10869        }
10870        let sql = format!(
10871            "DELETE FROM {} WHERE {}",
10872            base_table(table_name),
10873            clauses.join(" AND ")
10874        );
10875        self.overlay_dirty.set(true);
10876        self.conn
10877            .execute(&sql, rusqlite::params_from_iter(&params))
10878            .map_err(|_| ())?;
10879        self.conn
10880            .execute(
10881                &format!(
10882                    "DELETE FROM {} WHERE {}",
10883                    visible_table(table_name),
10884                    clauses.join(" AND ")
10885                ),
10886                rusqlite::params_from_iter(&params),
10887            )
10888            .map_err(|_| ())?;
10889        Ok(())
10890    }
10891
10892    /// §3.3: drop pending commits whose upserts provably land in the
10893    /// revoked effective scopes — whole-commit, never per-operation.
10894    fn drop_doomed_outbox(
10895        &mut self,
10896        table_name: &str,
10897        effective: &[(String, Vec<String>)],
10898    ) -> Result<bool, String> {
10899        if effective.is_empty() {
10900            return Ok(false);
10901        }
10902        let Some(table) = self.schema.table(table_name).cloned() else {
10903            return Ok(false);
10904        };
10905        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
10906        for (variable, values) in effective {
10907            match table.scope_column(variable) {
10908                Some(column) => mappings.push((column, values)),
10909                None => return Ok(false), // not provable without a mapping
10910            }
10911        }
10912        let doomed: Vec<OutboxCommit> = self
10913            .outbox
10914            .iter()
10915            .filter(|commit| {
10916                commit.ops.iter().any(|op| {
10917                    op.upsert
10918                        && op.table == table_name
10919                        && op.values.as_ref().is_some_and(|values| {
10920                            mappings
10921                                .iter()
10922                                .all(|(column, allowed)| match values.get(*column) {
10923                                    Some(Value::String(s)) => allowed.contains(s),
10924                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
10925                                    _ => false,
10926                                })
10927                        })
10928                })
10929            })
10930            .cloned()
10931            .collect();
10932        if doomed.is_empty() {
10933            return Ok(false);
10934        }
10935        let mut rejections = Vec::new();
10936        for commit in &doomed {
10937            let results = commit
10938                .ops
10939                .iter()
10940                .enumerate()
10941                .map(|(op_index, operation)| {
10942                    let rejection = RejectionRecord {
10943                        client_commit_id: commit.client_commit_id.clone(),
10944                        op_index: op_index as i32,
10945                        code: "sync.scope_revoked".to_owned(),
10946                        message: "the commit was dropped because its effective scope was revoked"
10947                            .to_owned(),
10948                        retryable: false,
10949                        details: None,
10950                        operation: Some(CommitOperation::from(operation)),
10951                    };
10952                    rejections.push(rejection.clone());
10953                    CommitOperationOutcome::Error { rejection }
10954                })
10955                .collect::<Vec<_>>();
10956            self.persist_commit_outcome(
10957                &commit.client_commit_id,
10958                CommitOutcomeStatus::Rejected,
10959                &results,
10960                Some(&commit.ops),
10961            )?;
10962            self.delete_outbox_persisted(&commit.client_commit_id)?;
10963        }
10964        self.prune_commit_outcomes()?;
10965        let doomed_ids = doomed
10966            .iter()
10967            .map(|commit| commit.client_commit_id.as_str())
10968            .collect::<BTreeSet<_>>();
10969        self.outbox
10970            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
10971        self.rejections.extend(rejections);
10972        self.overlay_dirty.set(true);
10973        Ok(true)
10974    }
10975
10976    // -- blobs (§5.9) ----------------------------------------------------------------
10977
10978    /// §5.9.7: hash bytes into the content address, cache them, queue the
10979    /// upload (flushed before the next push, B4). Returns the canonical
10980    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
10981    /// column value.
10982    pub fn upload_blob(
10983        &mut self,
10984        bytes: &[u8],
10985        media_type: Option<String>,
10986        name: Option<String>,
10987    ) -> Result<Value, String> {
10988        let blob_id = blob_id_for(bytes);
10989        let now = self.clock_now_ms();
10990        let tx = self.conn.transaction().map_err(|e| e.to_string())?;
10991        tx.execute(
10992            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
10993             ON CONFLICT(blob_id) DO NOTHING",
10994            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
10995        )
10996        .map_err(|e| e.to_string())?;
10997        tx.execute(
10998            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
10999            rusqlite::params![blob_id, media_type, now],
11000        )
11001        .map_err(|e| e.to_string())?;
11002        tx.commit().map_err(|e| e.to_string())?;
11003        self.enforce_blob_cache_cap();
11004        let mut obj = Map::new();
11005        obj.insert("blobId".to_owned(), Value::from(blob_id));
11006        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
11007        if let Some(mt) = media_type {
11008            obj.insert("mediaType".to_owned(), Value::from(mt));
11009        }
11010        if let Some(n) = name {
11011            obj.insert("name".to_owned(), Value::from(n));
11012        }
11013        Ok(Value::Object(obj))
11014    }
11015
11016    /// §5.9.7: resolve an owned blob body without driver encoding. A
11017    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
11018    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
11019    pub fn fetch_blob_bytes(
11020        &mut self,
11021        transport: &mut dyn Transport,
11022        blob_id_or_ref: &str,
11023    ) -> Result<FetchedBlob, (String, String)> {
11024        let simple = |m: String| ("client.failed".to_owned(), m);
11025        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
11026            blob_id_or_ref.to_owned()
11027        } else {
11028            let value: Value = serde_json::from_str(blob_id_or_ref)
11029                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
11030            value
11031                .get("blobId")
11032                .and_then(Value::as_str)
11033                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
11034                .to_owned()
11035        };
11036        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
11037            return Ok(cached);
11038        }
11039        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
11040        // blob.not_found) verbatim so the harness can assert on it. The
11041        // authorized endpoint serves bytes inline OR (always-issue, presign
11042        // configured) a signed url the client fetches directly — no host auth,
11043        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
11044        #[cfg(feature = "bench-internals")]
11045        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
11046        let bytes = match transport
11047            .blob_download(&blob_id)
11048            .map_err(|e| (e.code, e.message))?
11049        {
11050            BlobDownload::Bytes(bytes) => bytes,
11051            BlobDownload::Url {
11052                url,
11053                url_expires_at_ms,
11054            } => {
11055                // §5.9.5: MUST NOT start a fetch at/past expiry.
11056                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
11057                    return Err((
11058                        "sync.segment_expired".to_owned(),
11059                        format!(
11060                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
11061                        ),
11062                    ));
11063                }
11064                transport
11065                    .fetch_blob_url(&url)
11066                    .map_err(|e| (e.code, e.message))?
11067            }
11068        };
11069        #[cfg(feature = "bench-internals")]
11070        drop(download_phase);
11071        #[cfg(feature = "bench-internals")]
11072        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
11073        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
11074        if blob_id_for(&bytes) != blob_id {
11075            return Err(simple(format!(
11076                "blob content address mismatch for {blob_id}"
11077            )));
11078        }
11079        #[cfg(feature = "bench-internals")]
11080        drop(validate_phase);
11081        #[cfg(feature = "bench-internals")]
11082        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
11083        let now = self.clock_now_ms();
11084        self.conn
11085            .execute(
11086                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
11087                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
11088            )
11089            .map_err(|e| simple(e.to_string()))?;
11090        self.conn
11091            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
11092            .map_err(|e| simple(e.to_string()))?;
11093        self.enforce_blob_cache_cap();
11094        #[cfg(feature = "bench-internals")]
11095        drop(insert_phase);
11096        self.get_cached_blob_bytes(&blob_id)
11097            .map_err(simple)?
11098            .ok_or_else(|| simple("blob cache write failed".to_owned()))
11099    }
11100
11101    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
11102        #[cfg(feature = "bench-internals")]
11103        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
11104        let mut stmt = self
11105            .conn
11106            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
11107            .map_err(|e| e.to_string())?;
11108        let mut rows = stmt
11109            .query(rusqlite::params![blob_id])
11110            .map_err(|e| e.to_string())?;
11111        if let Some(row) = rows.next().map_err(|e| e.to_string())? {
11112            let bytes: Vec<u8> = row.get(0).map_err(|e| e.to_string())?;
11113            let byte_length: i64 = row.get(1).map_err(|e| e.to_string())?;
11114            let media_type: Option<String> = row.get(2).map_err(|e| e.to_string())?;
11115            return Ok(Some(FetchedBlob {
11116                blob_id: blob_id.to_owned(),
11117                byte_length,
11118                bytes,
11119                media_type,
11120            }));
11121        }
11122        Ok(None)
11123    }
11124
11125    /// §5.9.7 B4: upload every queued blob before push.
11126    fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
11127        let pending: Vec<(String, Option<String>)> = {
11128            let mut stmt = self
11129                .conn
11130                .prepare(
11131                    "SELECT blob_id, media_type FROM (
11132                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
11133                      UNION ALL
11134                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
11135                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
11136                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
11137                    ) ORDER BY created_at_ms, blob_id",
11138                )
11139                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11140            let rows = stmt
11141                .query_map([], |row| {
11142                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
11143                })
11144                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11145            rows.collect::<Result<_, _>>()
11146                .map_err(|error| match error {
11147                    rusqlite::Error::InvalidColumnType(..)
11148                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
11149                        "sync.local_corrupt",
11150                        "Pending blob upload metadata is invalid",
11151                    ),
11152                    _ => TransportError::new("client.failed", error.to_string()),
11153                })?
11154        };
11155        for (blob_id, media_type) in pending {
11156            let stored: Option<(SqlValue, SqlValue)> = self
11157                .conn
11158                .query_row(
11159                    "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
11160                    rusqlite::params![blob_id],
11161                    |row| Ok((row.get(0)?, row.get(1)?)),
11162                )
11163                .optional()
11164                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11165            let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
11166                return Err(TransportError::new(
11167                    "sync.local_corrupt",
11168                    "Pending blob upload body is missing or corrupt",
11169                ));
11170            };
11171            if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
11172                return Err(TransportError::new(
11173                    "sync.local_corrupt",
11174                    "Pending blob upload body is missing or corrupt",
11175                ));
11176            }
11177            self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
11178            self.conn
11179                .execute(
11180                    "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
11181                    rusqlite::params![blob_id],
11182                )
11183                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
11184        }
11185        Ok(())
11186    }
11187
11188    /// §5.9.3: upload one blob, preferring the presigned direct-to-storage
11189    /// grant when the transport supports it, else streaming through the direct
11190    /// host-authenticated endpoint (capability, not fallback). A `Url` grant
11191    /// PUTs direct with no host auth; on a grant PUT failure the client streams
11192    /// through the direct endpoint — a *different, host-authenticated
11193    /// capability*, not a fall-through of the grant's authority.
11194    fn upload_one(
11195        &self,
11196        transport: &mut dyn Transport,
11197        blob_id: &str,
11198        bytes: &[u8],
11199        media_type: Option<&str>,
11200    ) -> Result<(), TransportError> {
11201        match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
11202            BlobUploadGrant::Present => return Ok(()), // idempotent §5.9.3
11203            BlobUploadGrant::Url {
11204                url,
11205                url_expires_at_ms,
11206            } => {
11207                let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
11208                if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
11209                    return Ok(());
11210                }
11211                // Failed/expired grant PUT — stream through the direct endpoint.
11212            }
11213            BlobUploadGrant::None => {
11214                // No presign store — stream through the direct endpoint.
11215            }
11216        }
11217        transport.blob_upload(blob_id, bytes, media_type)
11218    }
11219
11220    fn visible_blob_ids_sql(&self) -> String {
11221        let mut selects = Vec::new();
11222        for table in &self.schema.tables {
11223            for column in table
11224                .columns
11225                .iter()
11226                .filter(|column| column.ty == ColumnType::BlobRef)
11227            {
11228                let identifier = quote_ident(&column.name);
11229                selects.push(format!(
11230                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
11231                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
11232                     AND json_type({identifier}, '$.blobId') = 'text'",
11233                    quote_ident(&table.name),
11234                ));
11235            }
11236        }
11237        if selects.is_empty() {
11238            "SELECT NULL AS blob_id WHERE 0".to_owned()
11239        } else {
11240            selects.join(" UNION ")
11241        }
11242    }
11243
11244    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
11245    /// staged uploads pin bodies directly; no derived refcount is stored.
11246    fn enforce_blob_cache_cap(&self) {
11247        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
11248            return;
11249        };
11250        let mut total: i64 = self
11251            .conn
11252            .query_row(
11253                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
11254                [],
11255                |row| row.get(0),
11256            )
11257            .unwrap_or(0);
11258        if total <= max_bytes {
11259            return;
11260        }
11261        let query = format!(
11262            "SELECT blob_id, byte_length FROM _syncular_blobs
11263             WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
11264               AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
11265               AND blob_id NOT IN ({})
11266             ORDER BY created_at_ms ASC, blob_id ASC",
11267            self.visible_blob_ids_sql(),
11268        );
11269        let candidates: Vec<(String, i64)> = {
11270            let Ok(mut stmt) = self.conn.prepare(&query) else {
11271                return;
11272            };
11273            let Ok(rows) = stmt.query_map([], |row| {
11274                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
11275            }) else {
11276                return;
11277            };
11278            rows.filter_map(Result::ok).collect()
11279        };
11280        for (blob_id, byte_length) in candidates {
11281            if total <= max_bytes {
11282                break;
11283            }
11284            let _ = self.conn.execute(
11285                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
11286                rusqlite::params![blob_id],
11287            );
11288            total -= byte_length;
11289        }
11290    }
11291
11292    fn delete_unreferenced_cached_blobs(&mut self) {
11293        #[cfg(feature = "bench-internals")]
11294        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
11295        if !self.schema_has_blobs() {
11296            return;
11297        }
11298        self.rebuild_overlay_if_dirty();
11299        let _ = self.conn.execute(
11300            &format!(
11301                "DELETE FROM _syncular_blobs
11302                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
11303                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
11304                   AND blob_id NOT IN ({})",
11305                self.visible_blob_ids_sql(),
11306            ),
11307            [],
11308        );
11309    }
11310
11311    // -- local row storage ----------------------------------------------------------
11312
11313    /// The cached per-table primary-key upsert SQL for `full_table` (see
11314    /// the `insert_sql` field: built once, reused per row).
11315    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
11316        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
11317            return sql.clone();
11318        }
11319        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
11320        columns.push(quote_ident("_syncular_version"));
11321        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
11322        let primary_key = quote_ident(&table.primary_key);
11323        let updates = columns
11324            .iter()
11325            .filter(|column| **column != primary_key)
11326            .map(|column| format!("{column}=excluded.{column}"))
11327            .collect::<Vec<_>>()
11328            .join(", ");
11329        let sql = format!(
11330            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
11331            columns.join(", "),
11332            placeholders.join(", ")
11333        );
11334        self.insert_sql
11335            .borrow_mut()
11336            .insert(full_table.to_owned(), sql.clone());
11337        sql
11338    }
11339
11340    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
11341        self.overlay_dirty.set(true);
11342        self.write_row(&base_table(table_name), table_name, row, version)
11343    }
11344
11345    fn write_row(
11346        &self,
11347        full_table: &str,
11348        table_name: &str,
11349        row: &Row,
11350        version: i64,
11351    ) -> Result<(), String> {
11352        #[cfg(feature = "bench-internals")]
11353        let _phase = self.benchmark_phases.start(Phase::RowWrite);
11354        let table = self
11355            .schema
11356            .table(table_name)
11357            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
11358        let sql = self.insert_row_sql(full_table, table);
11359        let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
11360        let params = row
11361            .iter()
11362            .map(RowParam::Cell)
11363            .chain(std::iter::once(RowParam::Version(version)));
11364        stmt.execute(rusqlite::params_from_iter(params))
11365            .map_err(|e| e.to_string())?;
11366        Ok(())
11367    }
11368
11369    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
11370        let table = self
11371            .schema
11372            .table(table_name)
11373            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
11374        self.overlay_dirty.set(true);
11375        let sql = format!(
11376            "DELETE FROM {} WHERE {}",
11377            base_table(table_name),
11378            row_id_predicate(table)
11379        );
11380        let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
11381        stmt.execute(rusqlite::params![row_id])
11382            .map_err(|e| e.to_string())?;
11383        Ok(())
11384    }
11385
11386    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
11387    /// the outbox changed since the last rebuild (the no-op sync round).
11388    fn rebuild_overlay_if_dirty(&mut self) {
11389        if self.overlay_dirty.get() {
11390            self.rebuild_overlay();
11391        }
11392    }
11393
11394    /// §7.1: local reads see outbox state applied optimistically — rebuild
11395    /// every visible table as (base server state) + (pending outbox replay
11396    /// on top). Optimistic rows carry version `-1`.
11397    fn rebuild_overlay(&mut self) {
11398        #[cfg(feature = "bench-internals")]
11399        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
11400        #[cfg(test)]
11401        self.overlay_rebuild_count
11402            .set(self.overlay_rebuild_count.get() + 1);
11403        self.exec("SAVEPOINT syncular_overlay");
11404        for table in self.schema.tables.clone() {
11405            for index in &table.fts_indexes {
11406                let _ = self.drop_fts_triggers(index);
11407            }
11408            let visible = visible_table(&table.name);
11409            let base = base_table(&table.name);
11410            self.exec(&format!("DELETE FROM {visible}"));
11411            self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
11412        }
11413        for commit in &self.outbox {
11414            self.apply_outbox_ops(&commit.ops);
11415        }
11416        for table in self.schema.tables.clone() {
11417            for index in &table.fts_indexes {
11418                let _ = self.rebuild_fts_projection(&table, index);
11419                let _ = self.create_fts_triggers(&table, index);
11420            }
11421        }
11422        self.exec("RELEASE syncular_overlay");
11423        self.overlay_dirty.set(false);
11424    }
11425
11426    fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
11427        #[cfg(feature = "bench-internals")]
11428        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
11429        for op in ops {
11430            #[cfg(feature = "bench-internals")]
11431            if let Some(phase) = &mut phase {
11432                phase.unit();
11433            }
11434            let Some(table) = self.schema.table(&op.table) else {
11435                continue;
11436            };
11437            if op.upsert {
11438                let Some(values) = op.values.as_ref() else {
11439                    continue;
11440                };
11441                // §7.1: the overlay applies the operation's PRESENT columns
11442                // over the current local row. A partial operation over an
11443                // absent local row leaves it absent — the server answers it
11444                // with sync.row_deleted or sync.row_missing (§6.2).
11445                let full = table
11446                    .columns
11447                    .iter()
11448                    .all(|column| values.contains_key(&column.name));
11449                let local = self.visible_row(table, &op.row_id);
11450                if local.is_none() && !full {
11451                    continue;
11452                }
11453                let mut row: Row = Vec::with_capacity(table.columns.len());
11454                let mut ok = true;
11455                for (index, column) in table.columns.iter().enumerate() {
11456                    let incoming = match values.get(&column.name) {
11457                        Some(raw) => json_to_column_value(column, Some(raw)),
11458                        None => Ok(local
11459                            .as_ref()
11460                            .and_then(|(values, _)| values.get(index).cloned())
11461                            .flatten()),
11462                    };
11463                    match incoming {
11464                        Ok(value) => row.push(value),
11465                        Err(_) => {
11466                            ok = false;
11467                            break;
11468                        }
11469                    }
11470                }
11471                if ok {
11472                    let version = local.as_ref().map_or(-1, |(_, version)| *version);
11473                    let _ = self.write_row(&visible_table(&table.name), &table.name, &row, version);
11474                }
11475            } else {
11476                let sql = format!(
11477                    "DELETE FROM {} WHERE {}",
11478                    visible_table(&table.name),
11479                    row_id_predicate(table)
11480                );
11481                let _ = self
11482                    .conn
11483                    .prepare_cached(&sql)
11484                    .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]));
11485            }
11486        }
11487    }
11488
11489    /// The visible (optimistic) row's values plus version, or `None` when the
11490    /// row is absent from the overlay.
11491    fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
11492        let sql = format!(
11493            "SELECT * FROM {} WHERE {}",
11494            visible_table(&table.name),
11495            row_id_predicate(table)
11496        );
11497        let mut stmt = self.conn.prepare_cached(&sql).ok()?;
11498        let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
11499        let row = rows.next().ok()??;
11500        let mut values = Vec::with_capacity(table.columns.len());
11501        for (index, column) in table.columns.iter().enumerate() {
11502            let raw = row.get_ref(index).ok()?;
11503            let json = sql_ref_to_json(column, raw);
11504            values.push(json_to_column_value(column, Some(&json)).ok()?);
11505        }
11506        let version: i64 = row.get(table.columns.len()).ok()?;
11507        Some((values, version))
11508    }
11509
11510    fn exec(&self, sql: &str) {
11511        let _ = self.conn.execute_batch(sql);
11512    }
11513
11514    // -- realtime (§8) ---------------------------------------------------------------
11515
11516    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
11517        if self.realtime_connected {
11518            return Ok(());
11519        }
11520        transport
11521            .realtime_connect_for_client(&self.client_id)
11522            .map_err(|e| format!("{}: {}", e.code, e.message))?;
11523        self.realtime_connected = true;
11524        Ok(())
11525    }
11526
11527    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
11528        if !self.realtime_connected {
11529            return;
11530        }
11531        let _ = transport.realtime_close();
11532        self.realtime_connected = false;
11533        self.presence.clear(); // §8.6.1: presence is per-connection
11534    }
11535
11536    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
11537    /// document for `scope_key`. Requires a live socket; the document is
11538    /// ephemeral (lost on disconnect). Authorization is the connection's
11539    /// registration (§8.6.3) — an unheld key is rejected loudly by the
11540    /// server with `presence.forbidden`.
11541    pub fn set_presence(
11542        &mut self,
11543        transport: &mut dyn Transport,
11544        scope_key: &str,
11545        doc: Option<&Value>,
11546    ) -> Result<(), String> {
11547        if !self.realtime_connected {
11548            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
11549        }
11550        let text = encode_presence_publish(scope_key, doc);
11551        transport
11552            .realtime_send(&text)
11553            .map_err(|e| format!("{}: {}", e.code, e.message))
11554    }
11555
11556    /// §8.6: the peers currently present on a scope key (ephemeral).
11557    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
11558        self.presence
11559            .get(scope_key)
11560            .map(|peers| peers.values().cloned().collect())
11561            .unwrap_or_default()
11562    }
11563
11564    /// §8.6 apply an inbound presence fanout to the local map.
11565    fn apply_presence(
11566        &mut self,
11567        scope_key: String,
11568        kind: Option<PresenceKind>,
11569        actor_id: Option<String>,
11570        client_id: Option<String>,
11571        doc: Option<Value>,
11572        error: Option<String>,
11573    ) {
11574        // The publisher-directed error variant is out-of-band; nothing to
11575        // record in the peer map.
11576        if error.is_some() {
11577            return;
11578        }
11579        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
11580            return;
11581        };
11582        let peer_key = format!("{actor_id} {client_id}");
11583        match kind {
11584            PresenceKind::Leave => {
11585                if let Some(peers) = self.presence.get_mut(&scope_key) {
11586                    peers.remove(&peer_key);
11587                    if peers.is_empty() {
11588                        self.presence.remove(&scope_key);
11589                    }
11590                }
11591            }
11592            _ => {
11593                let doc = match doc {
11594                    Some(Value::Object(_)) => doc.unwrap(),
11595                    _ => return,
11596                };
11597                self.presence.entry(scope_key).or_default().insert(
11598                    peer_key,
11599                    PresencePeer {
11600                        actor_id,
11601                        client_id,
11602                        doc,
11603                    },
11604                );
11605            }
11606        }
11607    }
11608
11609    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
11610    pub fn on_realtime_text(&mut self, text: &str) {
11611        match parse_control(text) {
11612            Ok(ControlMessage::Hello { requires_sync, .. }) => {
11613                if requires_sync {
11614                    // §8.1: pull before trusting the socket for continuity.
11615                    self.set_sync_needed(true, true);
11616                }
11617            }
11618            Ok(ControlMessage::Presence {
11619                scope_key,
11620                kind,
11621                actor_id,
11622                client_id,
11623                doc,
11624                error,
11625                ..
11626            }) => {
11627                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
11628            }
11629            Ok(ControlMessage::Wake { .. }) => {
11630                // §8.3: any wake-up means "run a pull soon", never data.
11631                self.set_sync_needed(true, true);
11632            }
11633            _ => {}
11634        }
11635    }
11636
11637    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
11638    /// a pull response per section; an unapplied delta is a wake-up.
11639    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
11640        if self.stopped {
11641            return;
11642        }
11643        #[cfg(feature = "bench-internals")]
11644        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
11645        let message = match decode_message(bytes) {
11646            Ok(m) if m.msg_kind == MsgKind::Response => m,
11647            _ => {
11648                self.set_sync_needed(true, true);
11649                return;
11650            }
11651        };
11652        #[cfg(feature = "bench-internals")]
11653        drop(decode_phase);
11654        let mut frames = message.frames.into_iter();
11655        let mut applied_cursor: Option<i64> = None;
11656        let mut any_covered = false;
11657        let mut dropped = false;
11658        while let Some(frame) = frames.next() {
11659            let Frame::SubStart {
11660                id,
11661                status,
11662                effective_scopes,
11663                ..
11664            } = frame
11665            else {
11666                continue;
11667            };
11668            let mut body = Vec::new();
11669            let mut next_cursor: Option<i64> = None;
11670            for inner in frames.by_ref() {
11671                match inner {
11672                    Frame::SubEnd {
11673                        next_cursor: nc, ..
11674                    } => {
11675                        next_cursor = Some(nc);
11676                        break;
11677                    }
11678                    Frame::Unknown { .. } => {}
11679                    other => body.push(other),
11680                }
11681            }
11682            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
11683                dropped = true;
11684                continue;
11685            };
11686            let sub = &self.subs[sub_index];
11687            // §8.2: only locally active, not mid-bootstrap subscriptions
11688            // apply; skipped sections are repaired by the next pull.
11689            if status != SubStatus::Active
11690                || sub.state != SubState::Active
11691                || sub.bootstrap_state.is_some()
11692                || !sub.synced_once
11693            {
11694                dropped = true;
11695                continue;
11696            }
11697            if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
11698                // Idempotent redelivery of an already-covered window.
11699                any_covered = true;
11700                continue;
11701            }
11702            let previous_effective = self.subs[sub_index].effective.clone();
11703            let previous_cursor = self.subs[sub_index].cursor;
11704            self.subs[sub_index].effective = Some(effective_scopes);
11705            let mut failed = false;
11706            for inner in body {
11707                match inner {
11708                    Frame::Commit {
11709                        tables, changes, ..
11710                    } => {
11711                        if self.apply_commit_frame(&tables, &changes).is_err() {
11712                            failed = true;
11713                            break;
11714                        }
11715                    }
11716                    _ => {
11717                        failed = true;
11718                        break;
11719                    }
11720                }
11721            }
11722            let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
11723                self.apply_sub_end(sub_index, cursor, None)
11724                    .ok()
11725                    .map(|()| cursor)
11726            });
11727            let Some(next_cursor) = committed else {
11728                self.subs[sub_index].effective = previous_effective;
11729                self.subs[sub_index].cursor = previous_cursor;
11730                dropped = true;
11731                continue;
11732            };
11733            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
11734        }
11735        if let Some(cursor) = applied_cursor {
11736            // §8.2 ack point: the highest applied SUB_END.nextCursor.
11737            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
11738            let _ = transport.realtime_send(&ack);
11739        } else if !any_covered || dropped {
11740            // §8.2: a delta not applied at all is treated as a wake-up.
11741            self.set_sync_needed(true, true);
11742        }
11743    }
11744
11745    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
11746    /// cursor across active, non-bootstrapping subscriptions that have
11747    /// synced at least once. No such subscription, no ack.
11748    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
11749        if !self.realtime_connected {
11750            return;
11751        }
11752        let floor = self
11753            .subs
11754            .iter()
11755            .filter(|s| {
11756                s.state == SubState::Active
11757                    && s.bootstrap_state.is_none()
11758                    && s.synced_once
11759                    && s.cursor >= 0
11760            })
11761            .map(|s| s.cursor)
11762            .min();
11763        if let Some(cursor) = floor {
11764            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
11765            let _ = transport.realtime_send(&ack);
11766        }
11767    }
11768}
11769
11770#[cfg(test)]
11771mod previous_version_wiring_tests {
11772    use super::*;
11773    use serde_json::{json, Value};
11774
11775    fn previous_version_schema(version: i32) -> Value {
11776        json!({
11777            "version": version,
11778            "tables": [{
11779                "name": "tasks",
11780                "primaryKey": "id",
11781                "columns": [
11782                    { "name": "id", "type": "string", "nullable": false },
11783                    { "name": "note", "type": "string", "nullable": true }
11784                ],
11785                "scopes": []
11786            }]
11787        })
11788    }
11789
11790    fn previous_version_limits() -> crate::api::ClientLimits {
11791        crate::api::ClientLimits {
11792            previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
11793                enabled: true,
11794                ..Default::default()
11795            }),
11796            ..Default::default()
11797        }
11798    }
11799
11800    /// The configuration the live client carries, for a direct capture call.
11801    fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
11802        previous_version_limits()
11803            .previous_version_context
11804            .expect("configured")
11805    }
11806
11807    /// Capture the container the way a bump would, so a read test starts from a
11808    /// container this client's own state will then decide about.
11809    fn recapture_previous_version(client: &SyncClient) {
11810        let replica_path = client
11811            .previous_version_replica_path()
11812            .expect("file-backed replica");
11813        let outcome = crate::previous_version::capture_previous_version_from_replica(
11814            &client.conn,
11815            &replica_path,
11816            2,
11817            2,
11818            &previous_version_config(),
11819            1,
11820        )
11821        .expect("capture");
11822        assert!(matches!(
11823            outcome,
11824            crate::previous_version::CaptureOutcome::Captured(_)
11825        ));
11826    }
11827
11828    fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
11829        Subscription {
11830            id: "wiring-sub".to_owned(),
11831            table: "tasks".to_owned(),
11832            requested: Vec::new(),
11833            params: None,
11834            cursor,
11835            bootstrap_state: bootstrap_state.map(str::to_owned),
11836            state,
11837            reason_code: None,
11838            effective: None,
11839            synced_once: false,
11840        }
11841    }
11842
11843    #[test]
11844    fn previous_version_read_reasons_come_from_live_client_state() {
11845        use crate::previous_version::{
11846            PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
11847        };
11848        let path = std::env::temp_dir()
11849            .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
11850            .to_string_lossy()
11851            .into_owned();
11852        let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
11853        {
11854            let mut setup = SyncClient::open_path(
11855                "wiring".to_owned(),
11856                &previous_version_schema(1),
11857                previous_version_limits(),
11858                &path,
11859            )
11860            .expect("v1 install");
11861            setup
11862                .mutate(vec![Mutation::Upsert {
11863                    table: "tasks".to_owned(),
11864                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
11865                    base_version: None,
11866                }])
11867                .expect("seed a row");
11868        }
11869        let mut client = SyncClient::open_path(
11870            "wiring".to_owned(),
11871            &previous_version_schema(2),
11872            previous_version_limits(),
11873            &path,
11874        )
11875        .expect("v2 bump captures");
11876        assert!(std::path::Path::new(&container).exists());
11877        let spec = PreviousVersionReadSpec {
11878            table: "tasks".to_owned(),
11879            ..Default::default()
11880        };
11881
11882        // §7.3.5 lease error ⇒ lease-inactive, and the read discards.
11883        recapture_previous_version(&client);
11884        client.lease_state = Some(LeaseState {
11885            lease_id: None,
11886            expires_at_ms: None,
11887            error_code: Some("lease.expired".to_owned()),
11888        });
11889        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
11890        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
11891        assert!(!snapshot.available);
11892        assert!(!std::path::Path::new(&container).exists());
11893
11894        // An expired lease uses the client clock (injected, no sleeps).
11895        recapture_previous_version(&client);
11896        client.set_now_ms(1_000);
11897        client.lease_state = Some(LeaseState {
11898            lease_id: Some("lease".to_owned()),
11899            expires_at_ms: Some(1_000),
11900            error_code: None,
11901        });
11902        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
11903        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
11904        assert!(!std::path::Path::new(&container).exists());
11905        client.lease_state = None;
11906
11907        // A revoked subscription ⇒ scope-revoked.
11908        recapture_previous_version(&client);
11909        client.subs.push(subscription(SubState::Revoked, 0, None));
11910        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
11911        assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
11912        assert!(!std::path::Path::new(&container).exists());
11913        client.subs.clear();
11914
11915        // Coverage completion: every ACTIVE subscription has a cursor and no
11916        // resume token — a still-resuming subscription is not completion.
11917        recapture_previous_version(&client);
11918        client
11919            .subs
11920            .push(subscription(SubState::Active, 0, Some("resume")));
11921        assert!(!client.previous_version_coverage_complete());
11922        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
11923        assert!(snapshot.available);
11924        assert_eq!(snapshot.previous_version, Some(2));
11925        assert_eq!(snapshot.rows.len(), 1);
11926
11927        recapture_previous_version(&client);
11928        client.subs[0].bootstrap_state = None;
11929        assert!(client.previous_version_coverage_complete());
11930        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
11931        assert_eq!(
11932            snapshot.reason,
11933            Some(PreviousVersionReason::CoverageComplete)
11934        );
11935        assert!(!std::path::Path::new(&container).exists());
11936        client.subs.clear();
11937
11938        // With no active subscription at all, coverage is NOT complete.
11939        assert!(!client.previous_version_coverage_complete());
11940
11941        drop(client);
11942        let _ = std::fs::remove_file(&path);
11943    }
11944
11945    /// RFC 0005 D7 lifetime drops. The read surface already proves the reasons
11946    /// come from live state; this proves the OTHER caller —
11947    /// `previous_version_lifetime_check`, the once-per-sync-round trigger —
11948    /// actually removes the container for lease-inactivity, coverage
11949    /// completion and scope revocation, and leaves it alone while the
11950    /// replacement bootstrap is still resuming.
11951    #[test]
11952    fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
11953        let path = std::env::temp_dir()
11954            .join(format!(
11955                "syncular-prev-lifetime-{}.db",
11956                uuid::Uuid::new_v4()
11957            ))
11958            .to_string_lossy()
11959            .into_owned();
11960        let container = format!(
11961            "{path}{}",
11962            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
11963        );
11964        let exists = || std::path::Path::new(&container).exists();
11965        {
11966            let mut setup = SyncClient::open_path(
11967                "lifetime".to_owned(),
11968                &previous_version_schema(1),
11969                previous_version_limits(),
11970                &path,
11971            )
11972            .expect("v1 install");
11973            setup
11974                .mutate(vec![Mutation::Upsert {
11975                    table: "tasks".to_owned(),
11976                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
11977                    base_version: None,
11978                }])
11979                .expect("seed a row");
11980        }
11981        let mut client = SyncClient::open_path(
11982            "lifetime".to_owned(),
11983            &previous_version_schema(2),
11984            previous_version_limits(),
11985            &path,
11986        )
11987        .expect("v2 bump captures");
11988        // Pin the clock so the 24h TTL never fires in this test (the TTL has
11989        // its own coverage); the capture record's createdAtMs is 1.
11990        client.set_now_ms(1_000);
11991        assert!(exists());
11992
11993        // §7.3.5 lease error ⇒ the lifetime check drops the container.
11994        client.lease_state = Some(LeaseState {
11995            lease_id: None,
11996            expires_at_ms: None,
11997            error_code: Some("lease.expired".to_owned()),
11998        });
11999        client.previous_version_lifetime_check();
12000        assert!(!exists());
12001        client.lease_state = None;
12002
12003        // §7.4.5 coverage completion (every active sub has a cursor and no
12004        // resume token) ⇒ drop.
12005        recapture_previous_version(&client);
12006        client.subs.push(subscription(SubState::Active, 0, None));
12007        client.previous_version_lifetime_check();
12008        assert!(!exists());
12009        client.subs.clear();
12010
12011        // §3.3 scope revocation ⇒ drop.
12012        recapture_previous_version(&client);
12013        client.subs.push(subscription(SubState::Revoked, 0, None));
12014        client.previous_version_lifetime_check();
12015        assert!(!exists());
12016        client.subs.clear();
12017
12018        // Negative control: a still-resuming active subscription is NOT
12019        // coverage completion, so the container survives the same call.
12020        recapture_previous_version(&client);
12021        client
12022            .subs
12023            .push(subscription(SubState::Active, 0, Some("resume")));
12024        client.previous_version_lifetime_check();
12025        assert!(exists());
12026        client.subs.clear();
12027
12028        drop(client);
12029        let _ = std::fs::remove_file(&path);
12030        let _ = std::fs::remove_file(&container);
12031        let _ = std::fs::remove_file(format!("{container}-wal"));
12032        let _ = std::fs::remove_file(format!("{container}-shm"));
12033    }
12034}