Skip to main content

syncular_client/
client.rs

1//! The Syncular v2 Rust client core (SPEC.md client-behavior contract):
2//! rusqlite local storage, §3.2/§3.3 effective-scope persistence + purge,
3//! §4 pull/cursor/bootstrap (§4.7 resume, §5.6 segment application), §6
4//! push with outbox order, §7 optimistic apply / rollback / replay-on-top,
5//! §2.3 clientCommitId idempotency, §8 realtime client rules, §10 errors.
6//!
7//! Built from `SPEC.md` and the committed `ssp2` codec alone — no
8//! reference to the v1 Rust tree or the v2 TypeScript client.
9
10use crate::{ProgressObserver, ProgressPhase, ProgressState};
11use std::cell::{Cell, RefCell};
12use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
13
14use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
15use rusqlite::{Connection, OpenFlags, OptionalExtension};
16use serde::{Deserialize, Serialize};
17use serde_json::{Map, Value};
18use sha2::{Digest, Sha256};
19use ssp2::decode::WIRE_VERSION;
20use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
21use ssp2::primitives::RawJson;
22use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
23use ssp2::{
24    decode_message, encode_message, encode_presence_publish, parse_control, ControlMessage,
25    PresenceKind,
26};
27
28use crate::api::{
29    ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
30    ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
31    ClientDiagnosticsStorage, ClientLimits, CommandEffects, CommitOperation,
32    CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery, CommitOutcomeResolution,
33    CommitOutcomeStatus, ConflictRecord, CoverageSnapshot, DiagnosticLastChange,
34    DiagnosticLastRound, DiagnosticRoundCounters, DiagnosticSubscription, FetchedBlob, LeaseState,
35    LocalDataPurgeInput, LocalDataPurgeResult, LocalDataPurgeTarget, LocalDataRebootstrapInput,
36    LocalDataRebootstrapResult, Mutation, PresencePeer, QueryRow, QuerySnapshot, QueryValue,
37    RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RowState, SchemaFloor,
38    SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport, SyncStatusSnapshot, TableChange,
39    WindowBase, WindowChange, WindowCoverage, WindowState, WindowUnitRef,
40    CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
41};
42#[cfg(feature = "bench-internals")]
43use crate::bench::{Phase, Recorder};
44use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
45use crate::transport::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
46use crate::values::{
47    bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
48    encode_sparse_row_json, full_row_values, json_to_column_value, json_to_scope_map,
49    normalize_values_casing, render_row_id_json, scope_map_to_json, sort_scope_map,
50};
51
52/// §4.2 default: rows and SQLite images, both applied in committed chunks.
53/// Bit 3 (signed URLs, §5.4) is
54/// added per transport capability at request-build time.
55const DEFAULT_ACCEPT: u8 = 0b0111;
56const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
57const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
58const ACCEPT_SQLITE: u8 = 1 << 2;
59const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
60const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
61
62/// §7.4.1 persisted local schema-version marker (`_syncular_meta` key).
63const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
64const LOCAL_REVISION_KEY: &str = "localRevision";
65const CLIENT_ID_KEY: &str = "clientId";
66const LEASE_STATE_KEY: &str = "leaseState";
67const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
68const LOG_EPOCH_KEY: &str = "logEpoch";
69/// Persisted fail-closed quarantine marker: present while a security preflight
70/// is pending and `activateSecurity` has yet to run. Storing it in the replica
71/// keeps the gate with the data it protects, so a rebuilt host handle reopening
72/// the same file returns in preflight even though its in-memory state is fresh.
73const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
74const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
75const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
76/// §7.4.4 client-local code: a pending outbox commit cannot re-encode under
77/// the new schema after a bump. Never a wire code (§10.3).
78const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
79
80#[derive(Debug, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase", deny_unknown_fields)]
82struct PersistedLocalDataRebootstrapReceipt {
83    version: u8,
84    retained_commits: u64,
85    reset_subscriptions: u64,
86}
87
88fn invalid_local_rebootstrap_receipt() -> String {
89    "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
90}
91
92fn encode_local_rebootstrap_receipt(
93    retained_commits: usize,
94    reset_subscriptions: usize,
95) -> Result<String, String> {
96    let retained_commits =
97        u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
98    let reset_subscriptions =
99        u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
100    if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
101        return Err(invalid_local_rebootstrap_receipt());
102    }
103    serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
104        version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
105        retained_commits,
106        reset_subscriptions,
107    })
108    .map_err(|_| invalid_local_rebootstrap_receipt())
109}
110
111fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
112    // Pre-0.15.36 markers proved application but did not retain the receipt.
113    if value == "v1" {
114        return Ok((0, 0));
115    }
116    let receipt: PersistedLocalDataRebootstrapReceipt =
117        serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
118    if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
119        || receipt.retained_commits > MAX_JS_SAFE_INTEGER
120        || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
121    {
122        return Err(invalid_local_rebootstrap_receipt());
123    }
124    Ok((
125        usize::try_from(receipt.retained_commits)
126            .map_err(|_| invalid_local_rebootstrap_receipt())?,
127        usize::try_from(receipt.reset_subscriptions)
128            .map_err(|_| invalid_local_rebootstrap_receipt())?,
129    ))
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133enum SubState {
134    Active,
135    Revoked,
136    Failed,
137}
138
139#[cfg(test)]
140mod observation_tests {
141    use super::*;
142    use crate::native_transport::HostTransport;
143    use crate::values::encode_row_json;
144    use serde_json::json;
145
146    fn client() -> SyncClient {
147        SyncClient::new(
148            "retry-test".to_owned(),
149            &json!({
150                "version": 1,
151                "tables": [{
152                    "name": "tasks",
153                    "primaryKey": "id",
154                    "columns": [
155                        { "name": "id", "type": "string", "nullable": false },
156                        { "name": "project_id", "type": "string", "nullable": false }
157                    ],
158                    "scopes": [{ "pattern": "project:{project_id}" }]
159                }]
160            }),
161            ClientLimits::default(),
162        )
163        .expect("test client")
164    }
165
166    #[test]
167    fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
168        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
169            {"name":"id","type":"string","nullable":false},
170            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
171        for state in ["absent", "cached", "pinned"] {
172            let path = std::env::temp_dir().join(format!(
173                "syncular-blob-stage-{}.sqlite",
174                uuid::Uuid::new_v4()
175            ));
176            let mut client = SyncClient::open_path(
177                "stage-test".into(),
178                &schema,
179                Default::default(),
180                path.to_str().unwrap(),
181            )
182            .unwrap();
183            let bytes = b"atomic stage";
184            if state != "absent" {
185                client
186                    .upload_blob(bytes, Some("text/plain".into()), None)
187                    .unwrap();
188                if state == "cached" {
189                    client
190                        .conn
191                        .execute("DELETE FROM _syncular_blob_uploads", [])
192                        .unwrap();
193                }
194            }
195            let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
196            client
197                .conn
198                .execute_batch(
199                    "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
200                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
201                 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
202                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
203                )
204                .unwrap();
205            let error = client
206                .upload_blob(bytes, Some("application/octet-stream".into()), None)
207                .unwrap_err();
208            assert!(error.contains("injected stage failure"), "{state}: {error}");
209            assert!(client.conn.is_autocommit());
210            let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
211            drop(client);
212            let mut reopened = SyncClient::open_path(
213                "stage-test".into(),
214                &schema,
215                Default::default(),
216                path.to_str().unwrap(),
217            )
218            .unwrap();
219            let durable = reopened
220                .query("SELECT * FROM _syncular_blobs", &[])
221                .unwrap();
222            reopened
223                .conn
224                .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
225                .unwrap();
226            let retried = reopened.upload_blob(bytes, None, None).unwrap();
227            assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
228            assert_eq!(
229                reopened
230                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
231                    .unwrap(),
232                vec![Map::from_iter([(
233                    "blob_id".into(),
234                    Value::from(blob_id_for(bytes)),
235                )])]
236            );
237            drop(reopened);
238            std::fs::remove_file(&path).unwrap();
239            assert_eq!(durable, bodies, "{state}: reopened body state");
240            assert_eq!(immediate, bodies, "{state}: immediate body state");
241        }
242    }
243
244    #[test]
245    fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
246        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
247            {"name":"id","type":"string","nullable":false},
248            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
249        for fault in [
250            "missing",
251            "body-type",
252            "length",
253            "hash",
254            "pin-metadata",
255            "read",
256            "pin-delete",
257        ] {
258            let path = std::env::temp_dir().join(format!(
259                "syncular-blob-upload-{}.sqlite",
260                uuid::Uuid::new_v4()
261            ));
262            let mut client = SyncClient::open_path(
263                "upload-test".into(),
264                &schema,
265                Default::default(),
266                path.to_str().unwrap(),
267            )
268            .unwrap();
269            let bytes = b"durable upload";
270            let ref_value = client
271                .upload_blob(bytes, Some("text/plain".into()), None)
272                .unwrap();
273            let commit = client
274                .mutate(vec![Mutation::Upsert {
275                    table: "attachments".into(),
276                    values: Map::from_iter([
277                        ("id".into(), Value::from("pending")),
278                        (
279                            "file".into(),
280                            Value::from(format!(
281                                "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
282                                blob_id_for(bytes),
283                                bytes.len()
284                            )),
285                        ),
286                    ]),
287                    base_version: None,
288                }])
289                .unwrap();
290            client
291                .conn
292                .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
293                .unwrap();
294            client.conn.execute_batch(match fault {
295                "missing" => "DELETE FROM _syncular_blobs",
296                "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
297                "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
298                "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
299                "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
300                "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
301                _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
302            }).unwrap();
303            let state = if fault == "read" {
304                Vec::new()
305            } else {
306                client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
307            };
308            let mut transport = CountingRealtimeTransport::default();
309            let error = client.flush_blob_uploads(&mut transport).unwrap_err();
310            assert_eq!(
311                error.code,
312                if matches!(fault, "read" | "pin-delete") {
313                    "client.failed"
314                } else {
315                    "sync.local_corrupt"
316                },
317                "{fault}"
318            );
319            assert_eq!(
320                transport.blob_uploads.len(),
321                usize::from(fault == "pin-delete"),
322                "{fault}: corrupt bytes must never be uploaded"
323            );
324            assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
325            if fault != "read" {
326                assert_eq!(
327                    client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
328                    state
329                );
330            } else {
331                client
332                    .conn
333                    .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
334                    .unwrap();
335            }
336            drop(client);
337            let mut reopened = SyncClient::open_path(
338                "upload-test".into(),
339                &schema,
340                Default::default(),
341                path.to_str().unwrap(),
342            )
343            .unwrap();
344            assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
345            if fault != "read" {
346                assert_eq!(
347                    reopened
348                        .query("SELECT * FROM _syncular_blobs", &[])
349                        .unwrap(),
350                    state
351                );
352            }
353            if fault == "pin-delete" {
354                reopened
355                    .conn
356                    .execute_batch("DROP TRIGGER fail_pin_delete")
357                    .unwrap();
358            }
359            if fault == "pin-metadata" {
360                reopened
361                    .conn
362                    .execute(
363                        "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
364                        [],
365                    )
366                    .unwrap();
367            }
368            reopened
369                .conn
370                .execute_batch(
371                    "DELETE FROM _syncular_blobs;
372                     INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
373                )
374                .unwrap();
375            transport.blob_uploads.clear();
376            reopened.flush_blob_uploads(&mut transport).unwrap();
377            assert_eq!(
378                transport.blob_uploads,
379                [(blob_id_for(bytes), bytes.to_vec())]
380            );
381            assert_eq!(
382                reopened
383                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
384                    .unwrap(),
385                Vec::<Map<String, Value>>::new()
386            );
387            assert_eq!(reopened.pending_commit_ids(), [commit]);
388            assert_eq!(
389                reopened
390                    .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
391                    .unwrap()
392                    .unwrap()
393                    .bytes,
394                bytes
395            );
396            drop(reopened);
397            std::fs::remove_file(path).unwrap();
398        }
399    }
400
401    #[test]
402    fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
403        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
404            {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
405            "scopes":[{"pattern":"project:{project_id}"}]}]});
406        for realtime in [false, true] {
407            for fault in [
408                "none",
409                "row",
410                "revision",
411                "commit",
412                "cursor",
413                "error",
414                "missing-end",
415            ] {
416                if realtime && fault == "missing-end" {
417                    continue;
418                }
419                let path = std::env::temp_dir()
420                    .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
421                let mut client = SyncClient::open_path(
422                    "frame-test".into(),
423                    &schema,
424                    Default::default(),
425                    path.to_str().unwrap(),
426                )
427                .unwrap();
428                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
429                client
430                    .subscribe(
431                        "tasks".into(),
432                        "tasks".into(),
433                        vec![("project_id".into(), vec!["p1".into()])],
434                        None,
435                    )
436                    .unwrap();
437                client.subs[0].cursor = 0;
438                client.subs[0].synced_once = true;
439                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
440                client.persist_sub(&client.subs[0]).unwrap();
441                client.realtime_connected = true;
442                client.drain_change_batches();
443                let mut transport = CountingRealtimeTransport::default();
444                let (_, meta) = client.build_request(false);
445                let mut response = Message {
446                    wire_version: WIRE_VERSION,
447                    msg_kind: MsgKind::Response,
448                    frames: vec![
449                        Frame::RespHeader {
450                            required_schema_version: None,
451                            latest_schema_version: None,
452                            log_epoch: Some("epoch-1".into()),
453                            reset_required: Some(false),
454                        },
455                        Frame::SubStart {
456                            id: "tasks".into(),
457                            status: SubStatus::Active,
458                            reason_code: "".into(),
459                            effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
460                            bootstrap: false,
461                        },
462                    ],
463                };
464                for (index, id) in ["first", "second"].iter().enumerate() {
465                    let values = Map::from_iter([
466                        ("id".into(), Value::from(*id)),
467                        ("project_id".into(), Value::from("p1")),
468                    ]);
469                    response.frames.push(Frame::Commit {
470                        commit_seq: index as i64 + 1,
471                        actor_id: "writer".into(),
472                        created_at_ms: 1,
473                        tables: vec!["tasks".into()],
474                        changes: vec![ssp2::model::Change {
475                            table_index: 0,
476                            row_id: (*id).into(),
477                            op: Op::Upsert,
478                            row_version: Some(index as i64 + 1),
479                            scopes: vec![("project_id".into(), "p1".into())],
480                            row: Some(
481                                encode_row_json(
482                                    client.schema.table("tasks").unwrap(),
483                                    id,
484                                    &values,
485                                    &client.encryption,
486                                )
487                                .unwrap(),
488                            ),
489                        }],
490                    });
491                }
492                response.frames.push(Frame::SubEnd {
493                    next_cursor: 2,
494                    bootstrap_state: None,
495                });
496                let valid = response.clone();
497                match fault {
498                    "row" => {
499                        let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
500                        let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
501                    },
502                    "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(),
503                    "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(),
504                    "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
505                    "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});},
506                    "missing-end" => {response.frames.truncate(3);},
507                    _ => {}
508                }
509                if realtime {
510                    client.on_realtime_binary(&mut transport, &encode_message(&response));
511                } else {
512                    assert_eq!(
513                        matches!(
514                            client.process_response(&mut transport, response, &meta),
515                            SyncOutcome::Ok(_)
516                        ),
517                        fault == "none",
518                        "{fault}"
519                    );
520                }
521                assert!(
522                    client.conn.is_autocommit(),
523                    "no open transaction after {realtime}/{fault}"
524                );
525                let count = if matches!(fault, "none" | "cursor") {
526                    2
527                } else {
528                    1
529                };
530                let expected_revision = count + u64::from(realtime && fault != "none");
531                assert_eq!(
532                    client.local_revision(),
533                    expected_revision,
534                    "{realtime}/{fault}"
535                );
536                let batches = client.drain_change_batches();
537                assert_eq!(
538                    batches.len(),
539                    expected_revision as usize,
540                    "{realtime}/{fault}"
541                );
542                let row_batches: Vec<_> = batches
543                    .iter()
544                    .filter(|batch| !batch.tables.is_empty())
545                    .collect();
546                assert_eq!(row_batches.len(), count as usize);
547                assert_eq!(
548                    row_batches
549                        .iter()
550                        .map(|b| b.revision.clone())
551                        .collect::<Vec<_>>(),
552                    (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
553                );
554                for table in ["tasks", "_syncular_base_tasks"] {
555                    assert_eq!(
556                        client
557                            .conn
558                            .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
559                                .get::<_, u64>(0))
560                            .unwrap(),
561                        count,
562                        "{realtime}/{fault}/{table}"
563                    );
564                }
565                assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
566                assert_eq!(
567                    transport
568                        .messages
569                        .iter()
570                        .filter(|m| m.contains("\"ack\""))
571                        .count(),
572                    usize::from(fault == "none")
573                );
574                if matches!(fault, "revision" | "commit" | "cursor") {
575                    client
576                        .conn
577                        .execute_batch("DROP TRIGGER fail_frame")
578                        .unwrap();
579                }
580                drop(client);
581                let mut reopened = SyncClient::open_path(
582                    "frame-test".into(),
583                    &schema,
584                    Default::default(),
585                    path.to_str().unwrap(),
586                )
587                .unwrap();
588                assert_eq!(reopened.local_revision(), expected_revision);
589                assert_eq!(
590                    reopened
591                        .query("SELECT id FROM tasks ORDER BY id", &[])
592                        .unwrap()
593                        .len(),
594                    count as usize
595                );
596                assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
597                let (_, meta) = reopened.build_request(false);
598                assert!(matches!(
599                    reopened.process_response(&mut transport, valid, &meta),
600                    SyncOutcome::Ok(_)
601                ));
602                assert_eq!(
603                    reopened
604                        .query("SELECT id FROM tasks ORDER BY id", &[])
605                        .unwrap()
606                        .len(),
607                    2
608                );
609                assert_eq!(reopened.subs[0].cursor, 2);
610                drop(reopened);
611                std::fs::remove_file(path).unwrap();
612            }
613        }
614    }
615
616    #[test]
617    fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
618        for status in [SubStatus::Reset, SubStatus::Revoked] {
619            for fault in ["subscription", "revision"] {
620                let mut client = client();
621                client
622                    .subscribe(
623                        "tasks".into(),
624                        "tasks".into(),
625                        vec![("project_id".into(), vec!["p1".into()])],
626                        None,
627                    )
628                    .unwrap();
629                client.subs[0].cursor = 41;
630                client.subs[0].synced_once = true;
631                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
632                client.persist_sub(&client.subs[0]).unwrap();
633                client
634                    .mutate(vec![Mutation::Upsert {
635                        table: "tasks".into(),
636                        values: Map::from_iter([
637                            ("id".into(), Value::from("pending")),
638                            ("project_id".into(), Value::from("p1")),
639                        ]),
640                        base_version: None,
641                    }])
642                    .unwrap();
643                let ids = client.pending_commit_ids();
644                let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
645                let revision = client.local_revision();
646                client.drain_change_batches();
647                let (_, meta) = client.build_request(false);
648                client.conn.execute_batch(if fault == "subscription" {
649                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
650                } else {
651                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
652                }).unwrap();
653                let mut transport = CountingRealtimeTransport::default();
654                let mut report = SyncReport::default();
655                // Reset changes window completeness and therefore needs a revision.
656                if status == SubStatus::Reset {
657                    client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
658                }
659                let result = client.process_section(
660                    &mut transport,
661                    "tasks",
662                    status,
663                    "sync.scope_revoked",
664                    vec![],
665                    vec![],
666                    Some((42, None)),
667                    &meta,
668                    &mut report,
669                );
670                assert!(result.is_err(), "{status:?}/{fault}");
671                assert!(client.conn.is_autocommit());
672                assert_eq!(client.subs[0].cursor, 41);
673                assert_eq!(client.subs[0].state, SubState::Active);
674                assert_eq!(client.pending_commit_ids(), ids);
675                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
676                assert!(client.rejections.is_empty());
677                assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
678                assert_eq!(client.local_revision(), revision);
679                assert!(client.drain_change_batches().is_empty());
680                assert!(report.revoked.is_empty() && report.resets.is_empty());
681            }
682        }
683    }
684
685    #[test]
686    fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
687        let mut client = client();
688        client
689            .subscribe(
690                "tasks".into(),
691                "tasks".into(),
692                vec![("project_id".into(), vec!["p1".into()])],
693                None,
694            )
695            .unwrap();
696        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
697        client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
698            CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
699        let row = |id: &str| ssp2::segment::SegmentRow {
700            server_version: 1,
701            values: vec![
702                Some(ColumnValue::String(id.into())),
703                Some(ColumnValue::String("p1".into())),
704            ],
705        };
706        let segment = RowsSegment {
707            table: "tasks".into(),
708            schema_version: 1,
709            columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
710            blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
711        };
712        assert!(client.apply_segment(0, &segment, true).is_err());
713        assert!(client.conn.is_autocommit());
714        assert_eq!(
715            client
716                .query("SELECT id FROM tasks ORDER BY id", &[])
717                .unwrap(),
718            vec![Map::from_iter([("id".into(), Value::from("first"))])]
719        );
720        assert_eq!(client.local_revision(), 1);
721        assert_eq!(client.drain_change_batches().len(), 1);
722        assert_eq!(client.subs[0].cursor, -1);
723        client
724            .conn
725            .execute_batch("DROP TRIGGER fail_block")
726            .unwrap();
727        assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
728        assert_eq!(client.local_revision(), 3);
729        assert_eq!(client.drain_change_batches().len(), 2);
730    }
731
732    #[test]
733    fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
734        assert_eq!(
735            client().diagnostics_storage().blob_cache_bytes_approx,
736            Some(0)
737        );
738        let client = SyncClient::new(
739            "diagnostic-storage".to_owned(),
740            &json!({
741                "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
742                    "columns": [{"name": "id", "type": "string", "nullable": false},
743                        {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
744            }),
745            ClientLimits {
746                blob_cache_max_bytes: Some(5),
747                ..Default::default()
748            },
749        )
750        .unwrap();
751        let empty = client.diagnostics_storage();
752        assert_eq!(empty.status, "healthy");
753        assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
754        assert_eq!(empty.retained_outcome_entries, Some(0));
755        assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
756        assert_eq!(empty.blob_cache_bytes_approx, Some(0));
757        client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
758            INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
759                VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
760            INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
761                VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
762        let written = client.diagnostics_storage();
763        assert_eq!(written.status, "pressure");
764        assert_eq!(written.pending_outbox_bytes_approx, Some(2));
765        assert_eq!(written.retained_outcome_entries, Some(2));
766        assert_eq!(written.retained_outcome_bytes_approx, Some(6));
767        assert_eq!(written.blob_cache_bytes_approx, Some(8));
768        assert_eq!(
769            written.pressure_reason_code.as_deref(),
770            Some("client.blob_cache_over_limit")
771        );
772        client
773            .conn
774            .execute_batch(
775                "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
776            DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
777            )
778            .unwrap();
779        let staged = client.diagnostics_storage();
780        assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
781        assert_eq!(staged.retained_outcome_entries, Some(0));
782        assert_eq!(staged.blob_cache_bytes_approx, Some(0));
783        assert_eq!(staged.status, "healthy");
784        client.conn.execute_batch("ROLLBACK").unwrap();
785        assert_eq!(client.diagnostics_storage(), written);
786        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
787        let unreadable = client.diagnostics_storage();
788        assert_eq!(unreadable.status, "unreadable");
789        assert!(unreadable.database_bytes_approx.is_none());
790        assert!(unreadable.pending_outbox_bytes_approx.is_none());
791        assert!(unreadable.retained_outcome_entries.is_none());
792        assert!(unreadable.retained_outcome_bytes_approx.is_none());
793        assert!(unreadable.blob_cache_bytes_approx.is_none());
794        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
795        let restored = client.diagnostics_storage();
796        assert_eq!(restored.status, written.status);
797        assert_eq!(
798            restored.retained_outcome_bytes_approx,
799            written.retained_outcome_bytes_approx
800        );
801        client.conn.set_prepared_statement_cache_capacity(0);
802        assert_eq!(client.diagnostics_storage(), restored);
803    }
804
805    #[test]
806    fn background_retry_deadlines_back_off_and_reset() {
807        let mut client = client();
808        client.schedule_background_retry();
809        assert!(matches!(
810            client.drain_sync_intents().as_slice(),
811            [SyncIntent::Background { delay_ms: 250 }]
812        ));
813        client.schedule_background_retry();
814        assert!(matches!(
815            client.drain_sync_intents().as_slice(),
816            [SyncIntent::Background { delay_ms: 500 }]
817        ));
818        client.reset_background_retry();
819        client.schedule_background_retry();
820        assert!(matches!(
821            client.drain_sync_intents().as_slice(),
822            [SyncIntent::Background { delay_ms: 250 }]
823        ));
824    }
825
826    #[test]
827    fn log_epoch_reset_requests_an_immediate_follow_up_round() {
828        let mut client = client();
829        client
830            .subscribe(
831                "epoch-tasks".to_owned(),
832                "tasks".to_owned(),
833                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
834                None,
835            )
836            .expect("subscribe");
837        client.drain_sync_intents();
838
839        client
840            .run_log_epoch_reset("epoch-2")
841            .expect("reset partition log epoch");
842
843        assert!(matches!(
844            client.drain_sync_intents().as_slice(),
845            [SyncIntent::Interactive]
846        ));
847    }
848
849    #[derive(Default)]
850    struct CountingRealtimeTransport {
851        connects: usize,
852        closes: usize,
853        messages: Vec<String>,
854        blob_uploads: Vec<(String, Vec<u8>)>,
855        blob_download: Option<Vec<u8>>,
856    }
857
858    impl Transport for CountingRealtimeTransport {
859        fn blob_upload(
860            &mut self,
861            blob_id: &str,
862            bytes: &[u8],
863            _media_type: Option<&str>,
864        ) -> Result<(), TransportError> {
865            self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
866            Ok(())
867        }
868
869        fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
870            self.blob_download
871                .clone()
872                .map(BlobDownload::Bytes)
873                .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
874        }
875
876        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
877            Err(TransportError::new("sync.transport_failed", "offline"))
878        }
879
880        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
881            Err(TransportError::new("sync.transport_failed", "offline"))
882        }
883
884        fn download_segment(
885            &mut self,
886            _request: &SegmentRequest,
887            _on_progress: &mut dyn FnMut(u64),
888        ) -> Result<Vec<u8>, TransportError> {
889            Err(TransportError::new("sync.transport_failed", "offline"))
890        }
891
892        fn realtime_connect(&mut self) -> Result<(), TransportError> {
893            self.connects += 1;
894            Ok(())
895        }
896
897        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
898            self.messages.push(text.to_owned());
899            Ok(())
900        }
901
902        fn realtime_close(&mut self) -> Result<(), TransportError> {
903            self.closes += 1;
904            Ok(())
905        }
906    }
907
908    #[test]
909    fn blob_results_own_downloaded_bytes() {
910        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
911            {"name":"id","type":"string","nullable":false},
912            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
913        let bytes = b"owned typed result".to_vec();
914        let blob_id = blob_id_for(&bytes);
915        let mut transport = CountingRealtimeTransport {
916            blob_download: Some(bytes.clone()),
917            ..Default::default()
918        };
919        let result = {
920            let mut client =
921                SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
922                    .unwrap();
923            let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
924            assert_eq!(result.blob_id, blob_id);
925            assert_eq!(result.byte_length, bytes.len() as i64);
926            assert_eq!(result.bytes, bytes);
927            assert_eq!(result.media_type, None);
928            assert_eq!(
929                client
930                    .conn
931                    .query_row(
932                        "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
933                        rusqlite::params![blob_id],
934                        |row| row.get::<_, i64>(0),
935                    )
936                    .unwrap(),
937                0
938            );
939            result
940        };
941        assert_eq!(result.bytes, bytes);
942
943        let mut client =
944            SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
945        assert_eq!(
946            client
947                .fetch_blob_bytes(&mut transport, "not json")
948                .unwrap_err(),
949            (
950                "client.failed".to_owned(),
951                "blob ref is not JSON".to_owned()
952            )
953        );
954    }
955
956    #[test]
957    fn visible_blob_references_survive_cache_trimming() {
958        let schema = json!({"version":1,"tables":[
959            {"name":"attachments","primaryKey":"id","columns":[
960                {"name":"id","type":"string","nullable":false},
961                {"name":"body","type":"blob_ref","nullable":true},
962                {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
963            {"name":"avatars","primaryKey":"id","columns":[
964                {"name":"id","type":"string","nullable":false},
965                {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
966        ]});
967        let bytes = b"referenced above cap".to_vec();
968        let blob_id = blob_id_for(&bytes);
969        let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
970        let mut client = SyncClient::new(
971            "visible-blob-refs".to_owned(),
972            &schema,
973            ClientLimits {
974                blob_cache_max_bytes: Some(1),
975                ..Default::default()
976            },
977        )
978        .unwrap();
979        client.conn.execute(
980            "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
981            rusqlite::params![reference, reference, reference, reference],
982        ).unwrap();
983        client
984            .conn
985            .execute(
986                "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
987                rusqlite::params![reference],
988            )
989            .unwrap();
990        client.conn.execute(
991            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
992            [],
993        ).unwrap();
994        let mut transport = CountingRealtimeTransport {
995            blob_download: Some(bytes.clone()),
996            ..Default::default()
997        };
998
999        assert_eq!(
1000            client
1001                .fetch_blob_bytes(&mut transport, &blob_id)
1002                .unwrap()
1003                .bytes,
1004            bytes
1005        );
1006        let rows = client
1007            .conn
1008            .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1009            .unwrap()
1010            .query_map([], |row| row.get::<_, String>(0))
1011            .unwrap()
1012            .collect::<Result<Vec<_>, _>>()
1013            .unwrap();
1014        assert_eq!(rows, vec![blob_id]);
1015    }
1016
1017    #[test]
1018    fn realtime_connection_ownership_is_idempotent() {
1019        let mut client = client();
1020        let mut transport = CountingRealtimeTransport {
1021            connects: 0,
1022            closes: 0,
1023            ..Default::default()
1024        };
1025        client
1026            .connect_realtime(&mut transport)
1027            .expect("first connect");
1028        client
1029            .connect_realtime(&mut transport)
1030            .expect("idempotent connect");
1031        assert_eq!(transport.connects, 1);
1032        client.disconnect_realtime(&mut transport);
1033        client.disconnect_realtime(&mut transport);
1034        assert_eq!(transport.closes, 1);
1035        client
1036            .connect_realtime(&mut transport)
1037            .expect("deliberate reconnect");
1038        assert_eq!(transport.connects, 2);
1039    }
1040
1041    #[test]
1042    fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1043        let mut client = client();
1044        client
1045            .subscribe(
1046                "repair-tasks".to_owned(),
1047                "tasks".to_owned(),
1048                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1049                None,
1050            )
1051            .expect("subscribe");
1052        {
1053            let sub = client
1054                .subs
1055                .iter_mut()
1056                .find(|sub| sub.id == "repair-tasks")
1057                .expect("subscription");
1058            sub.cursor = 42;
1059            sub.synced_once = true;
1060            let persisted = sub.clone();
1061            client.persist_sub(&persisted).unwrap();
1062        }
1063        for table in ["_syncular_base_tasks", "tasks"] {
1064            client
1065                .conn
1066                .execute(
1067                    &format!(
1068                        "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1069                    ),
1070                    rusqlite::params!["server-row", "p1"],
1071                )
1072                .expect("seed server row");
1073        }
1074        let pending = client
1075            .mutate(vec![Mutation::Upsert {
1076                table: "tasks".to_owned(),
1077                values: Map::from_iter([
1078                    ("id".to_owned(), Value::from("offline-row")),
1079                    ("project_id".to_owned(), Value::from("p1")),
1080                ]),
1081                base_version: None,
1082            }])
1083            .expect("queue offline work");
1084        client.drain_change_batches();
1085        client.drain_sync_intents();
1086
1087        assert_eq!(
1088            client
1089                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1090                    rebootstrap_id: "support-case-001".to_owned(),
1091                })
1092                .expect("rebootstrap"),
1093            LocalDataRebootstrapResult {
1094                already_applied: false,
1095                retained_commits: 1,
1096                reset_subscriptions: 1,
1097            }
1098        );
1099        let visible_ids = client
1100            .conn
1101            .prepare("SELECT id FROM tasks ORDER BY id")
1102            .expect("prepare visible ids")
1103            .query_map([], |row| row.get::<_, String>(0))
1104            .expect("query visible ids")
1105            .collect::<Result<Vec<_>, _>>()
1106            .expect("collect visible ids");
1107        assert_eq!(visible_ids, vec!["offline-row"]);
1108        assert_eq!(client.pending_commit_ids(), vec![pending]);
1109        assert_eq!(
1110            client
1111                .subscription_state("repair-tasks")
1112                .expect("subscription")
1113                .cursor,
1114            -1
1115        );
1116        assert!(client.upgrading());
1117        assert!(client.sync_needed());
1118        assert!(matches!(
1119            client.drain_sync_intents().as_slice(),
1120            [SyncIntent::Interactive]
1121        ));
1122        assert_eq!(client.drain_change_batches().len(), 1);
1123
1124        assert_eq!(
1125            client
1126                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1127                    rebootstrap_id: "support-case-001".to_owned(),
1128                })
1129                .expect("idempotent retry"),
1130            LocalDataRebootstrapResult {
1131                already_applied: true,
1132                retained_commits: 1,
1133                reset_subscriptions: 1,
1134            }
1135        );
1136        assert!(client.drain_change_batches().is_empty());
1137    }
1138
1139    #[test]
1140    fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1141        let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1142        assert_eq!(
1143            decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1144            (3, 4)
1145        );
1146        assert_eq!(
1147            decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1148            (0, 0)
1149        );
1150        for malformed in [
1151            "",
1152            "{}",
1153            r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1154            r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1155            r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1156        ] {
1157            assert_eq!(
1158                decode_local_rebootstrap_receipt(malformed)
1159                    .expect_err("malformed receipt must fail"),
1160                "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1161            );
1162        }
1163    }
1164
1165    #[test]
1166    fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1167        let path = std::env::temp_dir().join(format!(
1168            "syncular-rebootstrap-receipt-{}.db",
1169            uuid::Uuid::new_v4()
1170        ));
1171        let schema = json!({
1172            "version": 1,
1173            "tables": [{
1174                "name": "tasks",
1175                "primaryKey": "id",
1176                "columns": [
1177                    { "name": "id", "type": "string", "nullable": false },
1178                    { "name": "project_id", "type": "string", "nullable": false }
1179                ],
1180                "scopes": [{ "pattern": "project:{project_id}" }]
1181            }]
1182        });
1183        let path_string = path.to_str().expect("UTF-8 temp path");
1184
1185        {
1186            let mut first = SyncClient::open_path(
1187                "repair-restart-client".to_owned(),
1188                &schema,
1189                ClientLimits::default(),
1190                path_string,
1191            )
1192            .expect("first open");
1193            first
1194                .subscribe(
1195                    "repair-tasks".to_owned(),
1196                    "tasks".to_owned(),
1197                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1198                    None,
1199                )
1200                .expect("subscribe");
1201            first
1202                .mutate(vec![Mutation::Upsert {
1203                    table: "tasks".to_owned(),
1204                    values: Map::from_iter([
1205                        ("id".to_owned(), Value::from("offline-row")),
1206                        ("project_id".to_owned(), Value::from("p1")),
1207                    ]),
1208                    base_version: None,
1209                }])
1210                .expect("queue offline work");
1211            assert_eq!(
1212                first
1213                    .rebootstrap_local_data(&LocalDataRebootstrapInput {
1214                        rebootstrap_id: "restart-receipt".to_owned(),
1215                    })
1216                    .expect("first rebootstrap"),
1217                LocalDataRebootstrapResult {
1218                    already_applied: false,
1219                    retained_commits: 1,
1220                    reset_subscriptions: 1,
1221                }
1222            );
1223        }
1224
1225        let mut reopened = SyncClient::open_path(
1226            "repair-restart-client".to_owned(),
1227            &schema,
1228            ClientLimits::default(),
1229            path_string,
1230        )
1231        .expect("reopen");
1232        reopened.drain_change_batches();
1233        reopened.drain_sync_intents();
1234        assert_eq!(
1235            reopened
1236                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1237                    rebootstrap_id: "restart-receipt".to_owned(),
1238                })
1239                .expect("receipt replay"),
1240            LocalDataRebootstrapResult {
1241                already_applied: true,
1242                retained_commits: 1,
1243                reset_subscriptions: 1,
1244            }
1245        );
1246        assert!(reopened.drain_change_batches().is_empty());
1247        assert!(reopened.drain_sync_intents().is_empty());
1248        drop(reopened);
1249        std::fs::remove_file(path).expect("remove temp database");
1250    }
1251
1252    #[test]
1253    fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1254        let mut malformed = client();
1255        malformed
1256            .subscribe(
1257                "repair-tasks".to_owned(),
1258                "tasks".to_owned(),
1259                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1260                None,
1261            )
1262            .expect("subscribe");
1263        malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1264        malformed.drain_change_batches();
1265        malformed.drain_sync_intents();
1266        let malformed_error = malformed
1267            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1268                rebootstrap_id: "malformed".to_owned(),
1269            })
1270            .expect_err("malformed receipt must fail closed");
1271        assert_eq!(
1272            malformed_error,
1273            "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1274        );
1275        assert!(!malformed.upgrading());
1276        assert_eq!(
1277            malformed
1278                .subscription_state("repair-tasks")
1279                .expect("unchanged subscription")
1280                .cursor,
1281            -1
1282        );
1283        assert!(malformed.drain_change_batches().is_empty());
1284        assert!(malformed.drain_sync_intents().is_empty());
1285
1286        let mut unreadable = client();
1287        unreadable
1288            .conn
1289            .execute(
1290                "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1291                rusqlite::params!["server-row", "p1"],
1292            )
1293            .expect("seed visible row");
1294        unreadable
1295            .conn
1296            .execute("DROP TABLE _syncular_meta", [])
1297            .expect("break marker storage");
1298        let unreadable_error = unreadable
1299            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1300                rebootstrap_id: "unreadable".to_owned(),
1301            })
1302            .expect_err("unreadable marker storage must fail closed");
1303        assert_eq!(
1304            unreadable_error,
1305            "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1306        );
1307        let visible_rows = unreadable
1308            .conn
1309            .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1310            .expect("visible projection remains");
1311        assert_eq!(visible_rows, 1);
1312        assert!(!unreadable.upgrading());
1313        assert!(unreadable.drain_change_batches().is_empty());
1314        assert!(unreadable.drain_sync_intents().is_empty());
1315    }
1316
1317    #[test]
1318    fn local_rebootstrap_cannot_bypass_schema_floor() {
1319        let mut client = client();
1320        client.set_schema_floor(Some(SchemaFloor {
1321            required_schema_version: Some(2),
1322            latest_schema_version: Some(2),
1323        }));
1324        let error = client
1325            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1326                rebootstrap_id: "blocked-floor".to_owned(),
1327            })
1328            .expect_err("schema floor must block repair");
1329        assert!(error.contains("cannot bypass an active schema-floor stop"));
1330    }
1331
1332    #[test]
1333    fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
1334        let mut client = client();
1335        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1336        const COMMIT_COUNT: usize = 32;
1337
1338        for index in 0..COMMIT_COUNT {
1339            client
1340                .mutate(vec![Mutation::Upsert {
1341                    table: "tasks".to_owned(),
1342                    values: Map::from_iter([
1343                        ("id".to_owned(), Value::from(format!("task-{index}"))),
1344                        ("project_id".to_owned(), Value::from("project-1")),
1345                    ]),
1346                    base_version: None,
1347                }])
1348                .expect("queue commit");
1349        }
1350
1351        let (_, request_meta) = client.build_request(false);
1352        assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
1353        let mut frames = vec![Frame::RespHeader {
1354            required_schema_version: None,
1355            latest_schema_version: None,
1356            log_epoch: Some("epoch-1".to_owned()),
1357            reset_required: Some(false),
1358        }];
1359        frames.extend(request_meta.pushed_ids.iter().enumerate().map(
1360            |(index, client_commit_id)| Frame::PushResult {
1361                client_commit_id: client_commit_id.clone(),
1362                status: PushStatus::Applied,
1363                commit_seq: Some(index as i64 + 1),
1364                results: vec![OpResult::Applied { op_index: 0 }],
1365            },
1366        ));
1367        let response = Message {
1368            wire_version: WIRE_VERSION,
1369            msg_kind: MsgKind::Response,
1370            frames,
1371        };
1372        let mut transport =
1373            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
1374
1375        client.overlay_rebuild_count.set(0);
1376        client.outcome_prune_count.set(0);
1377        let outcome = client.process_response(&mut transport, response, &request_meta);
1378        assert!(matches!(outcome, SyncOutcome::Ok(_)));
1379        assert!(client.pending_commit_ids().is_empty());
1380        assert_eq!(
1381            client.overlay_rebuild_count.get(),
1382            1,
1383            "one response must reconcile its acknowledged commits with one overlay rebuild"
1384        );
1385        assert_eq!(
1386            client.outcome_prune_count.get(),
1387            1,
1388            "one response must enforce outcome retention once"
1389        );
1390    }
1391
1392    #[test]
1393    fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
1394        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
1395            "columns": [
1396                {"name":"id", "type":"string", "nullable":false},
1397                {"name":"project_id", "type":"string", "nullable":false},
1398                {"name":"title", "type":"string", "nullable":false}
1399            ], "scopes": [{"pattern":"project:{project_id}"}],
1400            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1401            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
1402        }] });
1403        let mut incremental =
1404            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1405        let mut reference =
1406            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1407        let tables = vec!["tasks".to_owned()];
1408        for index in 0..32 {
1409            let id = format!("task-{}", index % 8);
1410            let values = Map::from_iter([
1411                ("id".to_owned(), Value::from(id.clone())),
1412                (
1413                    "project_id".to_owned(),
1414                    Value::from(format!("p{}", index % 2)),
1415                ),
1416                ("title".to_owned(), Value::from(format!("needle{index}"))),
1417            ]);
1418            let payload = encode_row_json(
1419                incremental.schema.table("tasks").unwrap(),
1420                &id,
1421                &values,
1422                &incremental.encryption,
1423            )
1424            .unwrap();
1425            let change = ssp2::model::Change {
1426                table_index: 0,
1427                row_id: id,
1428                op: if index % 5 == 0 {
1429                    Op::Delete
1430                } else {
1431                    Op::Upsert
1432                },
1433                row_version: Some(index + 1),
1434                scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
1435                row: Some(payload),
1436            };
1437            reference.overlay_dirty.set(true);
1438            for instance in [&mut incremental, &mut reference] {
1439                instance.begin_observation("mirror_test").unwrap();
1440                let mut batch = ChangeAccumulator::default();
1441                instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
1442                instance
1443                    .apply_commit_changes(&tables, std::slice::from_ref(&change))
1444                    .unwrap();
1445                instance.rebuild_overlay_if_dirty();
1446                instance.finish_observation("mirror_test", batch).unwrap();
1447            }
1448            for sql in [
1449                "SELECT * FROM tasks ORDER BY id",
1450                "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
1451            ] {
1452                assert_eq!(
1453                    incremental.query(sql, &[]).unwrap(),
1454                    reference.query(sql, &[]).unwrap()
1455                );
1456            }
1457            assert_eq!(incremental.local_revision(), reference.local_revision());
1458            assert_eq!(
1459                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1460                serde_json::to_value(reference.drain_change_batches()).unwrap()
1461            );
1462        }
1463        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
1464        assert_eq!(reference.overlay_rebuild_count.get(), 32);
1465    }
1466
1467    #[test]
1468    fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
1469        let cases = [
1470            (
1471                "string",
1472                vec![
1473                    json!("1"),
1474                    json!("01"),
1475                    json!(""),
1476                    json!("nul\u{0}id"),
1477                    json!("é"),
1478                ],
1479            ),
1480            (
1481                "integer",
1482                vec![
1483                    json!(i64::MIN),
1484                    json!(-1),
1485                    json!(0),
1486                    json!(1),
1487                    json!(i64::MAX),
1488                ],
1489            ),
1490            ("boolean", vec![json!(false), json!(true)]),
1491            ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
1492            (
1493                "float",
1494                vec![
1495                    json!(1.0),
1496                    json!(1.0000000000000002),
1497                    json!(-1.0),
1498                    json!(1e20),
1499                ],
1500            ),
1501        ];
1502        for (kind, ids) in cases {
1503            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1504                "columns":[{"name":"id","type":kind,"nullable":false},
1505                    {"name":"project_id","type":"string","nullable":false}],
1506                "scopes":[{"pattern":"project:{project_id}"}]}]});
1507            let mut client =
1508                SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
1509            for (index, id) in ids.iter().enumerate() {
1510                let table = client.schema.table("tasks").unwrap();
1511                client
1512                    .write_base_row(
1513                        "tasks",
1514                        &vec![
1515                            json_to_column_value(&table.columns[0], Some(id)).unwrap(),
1516                            Some(ColumnValue::String(format!("p{index}"))),
1517                        ],
1518                        1,
1519                    )
1520                    .unwrap();
1521            }
1522            client.rebuild_overlay_if_dirty();
1523            for base in [true, false] {
1524                let full = if base {
1525                    base_table("tasks")
1526                } else {
1527                    visible_table("tasks")
1528                };
1529                let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
1530                for row_id in [
1531                    "1",
1532                    "01",
1533                    "1.0",
1534                    "1.0000000000000002",
1535                    "+1",
1536                    "1e0",
1537                    " 1",
1538                    "-1",
1539                    "0",
1540                    "9223372036854775807",
1541                    "-9223372036854775808",
1542                    "true",
1543                    "false",
1544                    "",
1545                    "nul\u{0}id",
1546                    "é",
1547                    "null",
1548                    "{\"a\":1}",
1549                ] {
1550                    let old_sql =
1551                        format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
1552                    let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
1553                    let old = client
1554                        .conn
1555                        .prepare(&old_sql)
1556                        .unwrap()
1557                        .query_map([row_id], |row| row.get::<_, String>(0))
1558                        .unwrap()
1559                        .collect::<Result<Vec<_>, _>>()
1560                        .unwrap();
1561                    let new = client
1562                        .conn
1563                        .prepare_cached(&new_sql)
1564                        .unwrap()
1565                        .query_map([row_id], |row| row.get::<_, String>(0))
1566                        .unwrap()
1567                        .collect::<Result<Vec<_>, _>>()
1568                        .unwrap();
1569                    assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
1570                    // The floating-point text collision is deliberate: an
1571                    // indexed numeric equality must not silently lose a match.
1572                    if kind == "float" && row_id == "1.0" {
1573                        assert_eq!(old.len(), 2);
1574                    }
1575                    let mut batch = ChangeAccumulator::default();
1576                    assert_eq!(
1577                        client.record_row_scopes(&mut batch, "tasks", row_id, base),
1578                        !old.is_empty()
1579                    );
1580                    let expected = old.first().map(|scope| {
1581                        BTreeMap::from([(
1582                            "tasks".to_owned(),
1583                            Some(BTreeSet::from([format!("project:{scope}")])),
1584                        )])
1585                    });
1586                    assert_eq!(batch.tables, expected.unwrap_or_default());
1587                    client
1588                        .conn
1589                        .execute_batch("SAVEPOINT lookup_delete")
1590                        .unwrap();
1591                    if base {
1592                        client.delete_base_row("tasks", row_id).unwrap();
1593                    } else {
1594                        client.apply_outbox_ops(&[OutboxOp {
1595                            upsert: false,
1596                            table: "tasks".into(),
1597                            row_id: row_id.into(),
1598                            base_version: None,
1599                            values: None,
1600                        }]);
1601                    }
1602                    let remaining: i64 = client
1603                        .conn
1604                        .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
1605                            row.get(0)
1606                        })
1607                        .unwrap();
1608                    assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
1609                    client
1610                        .conn
1611                        .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
1612                        .unwrap();
1613                    client.overlay_dirty.set(false);
1614                }
1615                for (predicate, expected) in [
1616                    ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
1617                    (predicate, if kind == "float" { "SCAN" } else { "SEARCH" }),
1618                ] {
1619                    let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
1620                    let details = client
1621                        .conn
1622                        .prepare(&sql)
1623                        .unwrap()
1624                        .query_map(["1"], |row| row.get::<_, String>(3))
1625                        .unwrap()
1626                        .collect::<Result<Vec<_>, _>>()
1627                        .unwrap();
1628                    assert!(
1629                        details.iter().any(|detail| detail.contains(expected)),
1630                        "{kind}: {details:?}"
1631                    );
1632                }
1633            }
1634        }
1635    }
1636
1637    #[test]
1638    fn pending_row_reconciliation_matches_full_fifo_replay() {
1639        for unique in [false, true] {
1640            let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
1641                "name": name, "primaryKey": "id", "columns": [
1642                    {"name":"id", "type":"string", "nullable":false},
1643                    {"name":"project_id", "type":"string", "nullable":false},
1644                    {"name":"title", "type":"string", "nullable":false}],
1645                "scopes": [{"pattern":"project:{project_id}"}],
1646                "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
1647                "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
1648            })))});
1649            let mut incremental =
1650                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
1651            let mut reference =
1652                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
1653            let tables = vec!["tasks".to_owned(), "notes".to_owned()];
1654            for instance in [&mut incremental, &mut reference] {
1655                for table in &tables {
1656                    for index in 0..12 {
1657                        instance
1658                            .write_base_row(
1659                                table,
1660                                &vec![
1661                                    Some(ColumnValue::String(format!("row{index}"))),
1662                                    Some(ColumnValue::String("p0".into())),
1663                                    Some(ColumnValue::String(format!("base{index}"))),
1664                                ],
1665                                1,
1666                            )
1667                            .unwrap();
1668                    }
1669                }
1670                instance.rebuild_overlay_if_dirty();
1671            }
1672            for index in 0..80 {
1673                let mutations = (0..3)
1674                    .map(|offset| {
1675                        let table = tables[(index + offset) % 2].clone();
1676                        let id = format!("row{}", (index + offset) % 16);
1677                        if (index + offset) % 7 == 0 {
1678                            Mutation::Delete {
1679                                table,
1680                                row_id: id,
1681                                base_version: None,
1682                            }
1683                        } else {
1684                            Mutation::Upsert {
1685                                table,
1686                                values: Map::from_iter([
1687                                    ("id".into(), Value::from(id)),
1688                                    ("project_id".into(), Value::from(format!("p{}", index % 3))),
1689                                    (
1690                                        "title".into(),
1691                                        Value::from(format!("needle{}", (index + offset) % 5)),
1692                                    ),
1693                                ]),
1694                                base_version: None,
1695                            }
1696                        }
1697                    })
1698                    .collect::<Vec<_>>();
1699                for instance in [&mut incremental, &mut reference] {
1700                    instance.mutate(mutations.clone()).unwrap();
1701                }
1702                // Most frames touch only the table without unique constraints.
1703                // Mixed frames must retain full replay for both tables.
1704                let changes = (0..3)
1705                    .map(|offset| {
1706                        let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
1707                        let id = format!("row{}", (index + offset) % 16);
1708                        let values = Map::from_iter([
1709                            ("id".into(), Value::from(id.clone())),
1710                            (
1711                                "project_id".into(),
1712                                Value::from(format!("p{}", (index + 1) % 3)),
1713                            ),
1714                            (
1715                                "title".into(),
1716                                Value::from(format!("remote{index}_{offset}")),
1717                            ),
1718                        ]);
1719                        ssp2::model::Change {
1720                            table_index: table_index as u16,
1721                            row_id: id.clone(),
1722                            op: if (index + offset) % 5 == 0 {
1723                                Op::Delete
1724                            } else {
1725                                Op::Upsert
1726                            },
1727                            row_version: Some(index as i64 + 2),
1728                            scopes: vec![],
1729                            row: Some(
1730                                encode_row_json(
1731                                    incremental.schema.table(&tables[table_index]).unwrap(),
1732                                    &id,
1733                                    &values,
1734                                    &incremental.encryption,
1735                                )
1736                                .unwrap(),
1737                            ),
1738                        }
1739                    })
1740                    .collect::<Vec<_>>();
1741                reference.overlay_dirty.set(true);
1742                incremental.apply_commit_frame(&tables, &changes).unwrap();
1743                reference.apply_commit_frame(&tables, &changes).unwrap();
1744                for table in &tables {
1745                    for sql in [format!("SELECT * FROM {table} ORDER BY id"),
1746                        format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
1747                        format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
1748                        format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
1749                        assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
1750                    }
1751                }
1752                assert_eq!(incremental.local_revision(), reference.local_revision());
1753                assert_eq!(
1754                    serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1755                    serde_json::to_value(reference.drain_change_batches()).unwrap()
1756                );
1757            }
1758            assert_eq!(
1759                incremental.overlay_rebuild_count.get(),
1760                if unique { 81 } else { 21 }
1761            );
1762            assert_eq!(reference.overlay_rebuild_count.get(), 81);
1763        }
1764    }
1765
1766    #[test]
1767    fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
1768        let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
1769            "columns":[{"name":"id", "type":"string", "nullable":false},
1770                {"name":"title", "type":"string", "nullable":false}],
1771            "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
1772        let mut client =
1773            SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
1774        for (id, title) in [("a", "original"), ("b", "occupied")] {
1775            client
1776                .write_base_row(
1777                    "tasks",
1778                    &vec![
1779                        Some(ColumnValue::String(id.into())),
1780                        Some(ColumnValue::String(title.into())),
1781                    ],
1782                    1,
1783                )
1784                .unwrap();
1785        }
1786        client.rebuild_overlay_if_dirty();
1787        let pending = client
1788            .mutate(vec![Mutation::Upsert {
1789                table: "tasks".into(),
1790                values: Map::from_iter([
1791                    ("id".into(), Value::from("a")),
1792                    ("title".into(), Value::from("occupied")),
1793                ]),
1794                base_version: None,
1795            }])
1796            .unwrap();
1797        assert_eq!(
1798            client
1799                .conn
1800                .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
1801                    .get::<_, String>(
1802                    0
1803                ))
1804                .unwrap(),
1805            "original"
1806        );
1807        for (title, expected) in [("available", "occupied"), ("occupied", "original")] {
1808            let change = ssp2::model::Change {
1809                table_index: 0,
1810                row_id: "b".into(),
1811                op: Op::Upsert,
1812                row_version: Some(2),
1813                scopes: vec![],
1814                row: Some(
1815                    encode_row_json(
1816                        client.schema.table("tasks").unwrap(),
1817                        "b",
1818                        &Map::from_iter([
1819                            ("id".into(), Value::from("b")),
1820                            ("title".into(), Value::from(title)),
1821                        ]),
1822                        &client.encryption,
1823                    )
1824                    .unwrap(),
1825                ),
1826            };
1827            client
1828                .apply_commit_frame(&["tasks".into()], &[change])
1829                .unwrap();
1830            assert_eq!(
1831                client
1832                    .conn
1833                    .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
1834                        .get::<_, String>(
1835                        0
1836                    ))
1837                    .unwrap(),
1838                expected
1839            );
1840            assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
1841        }
1842        assert_eq!(client.overlay_rebuild_count.get(), 3);
1843    }
1844
1845    #[test]
1846    fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
1847        for failure in ["visible", "revision", "commit"] {
1848            let mut client = client();
1849            let id = client
1850                .mutate(vec![Mutation::Upsert {
1851                    table: "tasks".into(),
1852                    values: Map::from_iter([
1853                        ("id".into(), Value::from("t1")),
1854                        ("project_id".into(), Value::from("pending")),
1855                    ]),
1856                    base_version: None,
1857                }])
1858                .unwrap();
1859            client.drain_change_batches();
1860            let revision = client.local_revision();
1861            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
1862            let trigger = match failure {
1863                "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
1864                "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"),
1865                _ => "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(),
1866            };
1867            client.conn.execute_batch(&trigger).unwrap();
1868            let change = ssp2::model::Change {
1869                table_index: 0,
1870                row_id: "t1".into(),
1871                op: Op::Upsert,
1872                row_version: Some(2),
1873                scopes: vec![],
1874                row: Some(
1875                    encode_row_json(
1876                        client.schema.table("tasks").unwrap(),
1877                        "t1",
1878                        &Map::from_iter([
1879                            ("id".into(), Value::from("t1")),
1880                            ("project_id".into(), Value::from("remote")),
1881                        ]),
1882                        &client.encryption,
1883                    )
1884                    .unwrap(),
1885                ),
1886            };
1887            assert!(client
1888                .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change))
1889                .is_err());
1890            assert!(client.conn.is_autocommit());
1891            assert!(!client.overlay_dirty.get());
1892            assert_eq!(client.local_revision(), revision);
1893            assert!(client.drain_change_batches().is_empty());
1894            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
1895            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
1896            assert!(client
1897                .query("SELECT * FROM _syncular_base_tasks", &[])
1898                .unwrap()
1899                .is_empty());
1900            client
1901                .conn
1902                .execute_batch("DROP TRIGGER fail_pending")
1903                .unwrap();
1904            client
1905                .apply_commit_frame(&["tasks".into()], &[change])
1906                .unwrap();
1907            assert_eq!(client.local_revision(), revision + 1);
1908            assert_eq!(client.pending_commit_ids(), vec![id]);
1909            // The overlay keeps the pending write visible; the row now carries
1910            // the server row's version it is layered on (§7.1).
1911            let mut expected = before[0].clone();
1912            expected.insert("_syncular_version".to_owned(), Value::from(2));
1913            assert_eq!(
1914                client.query("SELECT * FROM tasks", &[]).unwrap(),
1915                vec![expected]
1916            );
1917            assert_eq!(
1918                client
1919                    .conn
1920                    .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
1921                        .get::<_, String>(0))
1922                    .unwrap(),
1923                "remote"
1924            );
1925        }
1926    }
1927
1928    #[test]
1929    fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
1930        let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
1931            "columns": [
1932                {"name":"id", "type":"string", "nullable":false},
1933                {"name":"project_id", "type":"string", "nullable":false},
1934                {"name":"title", "type":"string", "nullable":false}],
1935            "scopes": [{"pattern":"project:{project_id}"}],
1936            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1937            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
1938        let mut incremental =
1939            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
1940        let mut reference =
1941            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
1942        for instance in [&mut incremental, &mut reference] {
1943            for index in 0..16 {
1944                instance
1945                    .write_base_row(
1946                        "tasks",
1947                        &vec![
1948                            Some(ColumnValue::String(format!("task{index}"))),
1949                            Some(ColumnValue::String("p0".to_owned())),
1950                            Some(ColumnValue::String(format!("base{index}"))),
1951                        ],
1952                        1,
1953                    )
1954                    .unwrap();
1955            }
1956            instance.rebuild_overlay_if_dirty();
1957            instance.overlay_rebuild_count.set(0);
1958        }
1959        for index in 0..100 {
1960            let mutations = (0..1 + index % 4)
1961                .map(|offset| {
1962                    let id = format!("task{}", (index + offset) % 24);
1963                    if (index + offset) % 7 == 0 {
1964                        Mutation::Delete {
1965                            table: "tasks".to_owned(),
1966                            row_id: id,
1967                            base_version: None,
1968                        }
1969                    } else {
1970                        Mutation::Upsert {
1971                            table: "tasks".to_owned(),
1972                            values: Map::from_iter([
1973                                ("id".to_owned(), Value::from(id)),
1974                                (
1975                                    "project_id".to_owned(),
1976                                    Value::from(format!("p{}", index % 2)),
1977                                ),
1978                                (
1979                                    "title".to_owned(),
1980                                    Value::from(format!("needle{}", (index + offset) % 5)),
1981                                ),
1982                            ]),
1983                            base_version: None,
1984                        }
1985                    }
1986                })
1987                .collect::<Vec<_>>();
1988            reference.overlay_dirty.set(true);
1989            incremental.mutate(mutations.clone()).unwrap();
1990            reference.mutate(mutations).unwrap();
1991            for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
1992                "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
1993                assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
1994            }
1995            assert_eq!(incremental.local_revision(), reference.local_revision());
1996            assert_eq!(
1997                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1998                serde_json::to_value(reference.drain_change_batches()).unwrap()
1999            );
2000        }
2001        assert_eq!(incremental.outbox.len(), 100);
2002        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2003        assert_eq!(reference.overlay_rebuild_count.get(), 100);
2004    }
2005
2006    #[test]
2007    fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
2008        for fail_at in ["outbox", "revision"] {
2009            let mut client = client();
2010            let values = Map::from_iter([
2011                ("id".to_owned(), Value::from("t1")),
2012                ("project_id".to_owned(), Value::from("p1")),
2013            ]);
2014            let mutation = Mutation::Upsert {
2015                table: "tasks".to_owned(),
2016                values,
2017                base_version: None,
2018            };
2019            let trigger = if fail_at == "outbox" {
2020                "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
2021            } else {
2022                format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
2023            };
2024            client.conn.execute_batch(&trigger).unwrap();
2025            let revision = client.local_revision();
2026            assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
2027            assert_eq!(client.local_revision(), revision);
2028            assert!(client.outbox.is_empty());
2029            assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2030            assert_eq!(
2031                client
2032                    .conn
2033                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
2034                        .get::<_, i64>(0))
2035                    .unwrap(),
2036                0
2037            );
2038            assert!(client.drain_change_batches().is_empty());
2039            assert!(!client.overlay_dirty.get());
2040            client
2041                .conn
2042                .execute_batch("DROP TRIGGER fail_append")
2043                .unwrap();
2044            client.mutate(vec![mutation]).unwrap();
2045            assert_eq!(client.outbox.len(), 1);
2046            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
2047        }
2048    }
2049
2050    #[test]
2051    fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
2052        let mut client = client();
2053        let tables = vec!["tasks".to_owned(), "missing".to_owned()];
2054        let values = Map::from_iter([
2055            ("id".to_owned(), Value::from("t1")),
2056            ("project_id".to_owned(), Value::from("p1")),
2057        ]);
2058        let payload = encode_row_json(
2059            client.schema.table("tasks").unwrap(),
2060            "t1",
2061            &values,
2062            &client.encryption,
2063        )
2064        .unwrap();
2065        let first = ssp2::model::Change {
2066            table_index: 0,
2067            row_id: "t1".to_owned(),
2068            op: Op::Upsert,
2069            row_version: Some(1),
2070            scopes: vec![("project_id".to_owned(), "p1".to_owned())],
2071            row: Some(payload),
2072        };
2073        let mut invalid = first.clone();
2074        invalid.table_index = 1;
2075        let revision = client.local_revision();
2076        client.begin_observation("failed_mirror").unwrap();
2077        assert!(client
2078            .apply_commit_changes(&tables, &[first, invalid])
2079            .is_err());
2080        client.rollback_observation("failed_mirror");
2081        client.rebuild_overlay_if_dirty();
2082        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2083        assert_eq!(
2084            client
2085                .conn
2086                .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
2087                    .get::<_, i64>(
2088                    0
2089                ))
2090                .unwrap(),
2091            0
2092        );
2093        assert_eq!(client.local_revision(), revision);
2094        assert!(client.drain_change_batches().is_empty());
2095    }
2096
2097    #[test]
2098    fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
2099        for failure in ["journal", "revision", "commit", "later-frame"] {
2100            let mut client = client();
2101            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2102            for id in ["first", "second"] {
2103                client
2104                    .mutate(vec![Mutation::Upsert {
2105                        table: "tasks".to_owned(),
2106                        values: Map::from_iter([
2107                            ("id".to_owned(), Value::from(id)),
2108                            ("project_id".to_owned(), Value::from("p1")),
2109                        ]),
2110                        base_version: None,
2111                    }])
2112                    .unwrap();
2113            }
2114            let (_, meta) = client.build_request(false);
2115            // A malicious or stale response must not drain an unsent local commit.
2116            client
2117                .mutate(vec![Mutation::Upsert {
2118                    table: "tasks".to_owned(),
2119                    values: Map::from_iter([
2120                        ("id".to_owned(), Value::from("later")),
2121                        ("project_id".to_owned(), Value::from("p1")),
2122                    ]),
2123                    base_version: None,
2124                }])
2125                .unwrap();
2126            let ids = client.pending_commit_ids();
2127            let rows = client
2128                .query("SELECT * FROM tasks ORDER BY id", &[])
2129                .unwrap();
2130            let revision = client.local_revision();
2131            client.drain_change_batches();
2132            if failure != "later-frame" {
2133                client.conn.execute_batch(match failure {
2134                    "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",
2135                    "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",
2136                    _ => "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",
2137                }).unwrap();
2138            }
2139            let mut response = Message {
2140                wire_version: WIRE_VERSION,
2141                msg_kind: MsgKind::Response,
2142                frames: vec![Frame::RespHeader {
2143                    required_schema_version: None,
2144                    latest_schema_version: None,
2145                    log_epoch: Some("epoch-1".to_owned()),
2146                    reset_required: Some(false),
2147                }],
2148            };
2149            for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
2150                response.frames.push(Frame::PushResult {
2151                    client_commit_id: id.to_owned(),
2152                    status: PushStatus::Applied,
2153                    commit_seq: Some(1),
2154                    results: vec![OpResult::Applied { op_index: 0 }],
2155                });
2156            }
2157            if failure == "later-frame" {
2158                response.frames.push(Frame::Error {
2159                    code: "sync.invalid_request".to_owned(),
2160                    message: "later error".to_owned(),
2161                    category: "protocol".to_owned(),
2162                    retryable: false,
2163                    recommended_action: "retry".to_owned(),
2164                    details: None,
2165                });
2166            }
2167            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2168            let outcome = client.process_response(&mut transport, response.clone(), &meta);
2169            assert!(
2170                matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
2171            );
2172            if failure == "later-frame" {
2173                assert_eq!(client.pending_commit_ids(), ids[2..]);
2174                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2175                assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
2176                let batches = client.drain_change_batches();
2177                assert_eq!(batches.len(), 1);
2178                assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
2179                assert_eq!(client.local_revision(), revision + 1);
2180                response.frames.pop();
2181            } else {
2182                assert_eq!(client.pending_commit_ids(), ids);
2183                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2184                assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
2185                assert_eq!(client.local_revision(), revision);
2186                assert!(client.drain_change_batches().is_empty());
2187                assert_eq!(
2188                    client
2189                        .query("SELECT * FROM tasks ORDER BY id", &[])
2190                        .unwrap(),
2191                    rows
2192                );
2193                client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
2194            }
2195            for frame in &mut response.frames {
2196                if let Frame::PushResult { status, .. } = frame {
2197                    *status = PushStatus::Cached;
2198                }
2199            }
2200            let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
2201            else {
2202                panic!("retry failed");
2203            };
2204            assert_eq!(
2205                report.applied,
2206                if failure == "later-frame" {
2207                    vec![]
2208                } else {
2209                    ids[..2].to_vec()
2210                }
2211            );
2212            assert_eq!(client.pending_commit_ids(), ids[2..]);
2213            let batches = client.drain_change_batches();
2214            assert_eq!(batches.len(), usize::from(failure != "later-frame"));
2215            if let Some(batch) = batches.first() {
2216                assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
2217            }
2218            assert_eq!(client.local_revision(), revision + 1);
2219        }
2220    }
2221
2222    #[test]
2223    fn failed_push_result_preserves_memory_disk_and_retry_identity() {
2224        for failure in ["revision", "journal", "commit"] {
2225            for status in ["applied", "cached", "rejected", "conflict"] {
2226                let mut client = client();
2227                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2228                for id in ["first", "later"] {
2229                    client
2230                        .mutate(vec![Mutation::Upsert {
2231                            table: "tasks".to_owned(),
2232                            values: Map::from_iter([
2233                                ("id".to_owned(), Value::from(id)),
2234                                ("project_id".to_owned(), Value::from("p1")),
2235                            ]),
2236                            base_version: None,
2237                        }])
2238                        .unwrap();
2239                }
2240                let (_, meta) = client.build_request(false);
2241                let ids = client.pending_commit_ids();
2242                let rows = client
2243                    .query("SELECT * FROM tasks ORDER BY id", &[])
2244                    .unwrap();
2245                let revision = client.local_revision();
2246                client.drain_change_batches();
2247                let (push_status, results) = match status {
2248                    "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
2249                    "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
2250                    "conflict" => (
2251                        PushStatus::Rejected,
2252                        vec![OpResult::Conflict {
2253                            op_index: 0,
2254                            code: "sync.version_conflict".to_owned(),
2255                            message: "conflict".to_owned(),
2256                            server_version: 1,
2257                            server_row: encode_row_json(
2258                                client.schema.table("tasks").unwrap(),
2259                                "first",
2260                                &Map::from_iter([
2261                                    ("id".to_owned(), Value::from("first")),
2262                                    ("project_id".to_owned(), Value::from("p1")),
2263                                ]),
2264                                &client.encryption,
2265                            )
2266                            .unwrap(),
2267                            conflict_columns: Vec::new(),
2268                        }],
2269                    ),
2270                    _ => (
2271                        PushStatus::Rejected,
2272                        vec![OpResult::Error {
2273                            op_index: 0,
2274                            code: "sync.validation_failed".to_owned(),
2275                            message: "rejected".to_owned(),
2276                            retryable: false,
2277                        }],
2278                    ),
2279                };
2280                let response = Message {
2281                    wire_version: WIRE_VERSION,
2282                    msg_kind: MsgKind::Response,
2283                    frames: vec![
2284                        Frame::RespHeader {
2285                            required_schema_version: None,
2286                            latest_schema_version: None,
2287                            log_epoch: Some("epoch-1".to_owned()),
2288                            reset_required: Some(false),
2289                        },
2290                        Frame::PushResult {
2291                            client_commit_id: ids[0].clone(),
2292                            status: push_status,
2293                            commit_seq: Some(1),
2294                            results,
2295                        },
2296                    ],
2297                };
2298                client.conn.execute_batch(match failure {
2299                    "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
2300                    "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
2301                    _ => "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",
2302                }).unwrap();
2303                let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2304                let outcome = client.process_response(&mut transport, response.clone(), &meta);
2305                assert_eq!(
2306                    client.pending_commit_ids(),
2307                    ids,
2308                    "{failure}/{status}: pending identity"
2309                );
2310                assert_eq!(
2311                    client.local_revision(),
2312                    revision,
2313                    "{failure}/{status}: revision"
2314                );
2315                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2316                assert!(client.conflicts().is_empty());
2317                assert!(client.rejections().is_empty());
2318                assert!(client.drain_change_batches().is_empty());
2319                assert_eq!(
2320                    client
2321                        .query("SELECT * FROM tasks ORDER BY id", &[])
2322                        .unwrap(),
2323                    rows
2324                );
2325                assert!(
2326                    matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
2327                    "{failure}/{status}: failed round"
2328                );
2329                client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
2330                assert!(matches!(
2331                    client.process_response(&mut transport, response, &meta),
2332                    SyncOutcome::Ok(_)
2333                ));
2334                assert_eq!(client.pending_commit_ids(), ids[1..]);
2335                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2336                assert_eq!(client.drain_change_batches().len(), 1);
2337            }
2338        }
2339    }
2340
2341    #[test]
2342    fn mixed_push_results_reconcile_and_prune_once_per_response() {
2343        let mut client = client();
2344        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2345        for index in 0..4 {
2346            client
2347                .mutate(vec![Mutation::Upsert {
2348                    table: "tasks".to_owned(),
2349                    values: Map::from_iter([
2350                        ("id".to_owned(), Value::from(format!("task-{index}"))),
2351                        ("project_id".to_owned(), Value::from("project-1")),
2352                    ]),
2353                    base_version: None,
2354                }])
2355                .expect("queue commit");
2356        }
2357
2358        let (_, request_meta) = client.build_request(false);
2359        let ids = &request_meta.pushed_ids;
2360        assert_eq!(ids.len(), 4);
2361        let response = Message {
2362            wire_version: WIRE_VERSION,
2363            msg_kind: MsgKind::Response,
2364            frames: vec![
2365                Frame::RespHeader {
2366                    required_schema_version: None,
2367                    latest_schema_version: None,
2368                    log_epoch: Some("epoch-1".to_owned()),
2369                    reset_required: Some(false),
2370                },
2371                Frame::PushResult {
2372                    client_commit_id: ids[0].clone(),
2373                    status: PushStatus::Applied,
2374                    commit_seq: Some(1),
2375                    results: vec![OpResult::Applied { op_index: 0 }],
2376                },
2377                Frame::PushResult {
2378                    client_commit_id: ids[1].clone(),
2379                    status: PushStatus::Cached,
2380                    commit_seq: Some(2),
2381                    results: vec![OpResult::Applied { op_index: 0 }],
2382                },
2383                Frame::PushResult {
2384                    client_commit_id: ids[2].clone(),
2385                    status: PushStatus::Rejected,
2386                    commit_seq: None,
2387                    results: vec![OpResult::Error {
2388                        op_index: 0,
2389                        code: "sync.validation_failed".to_owned(),
2390                        message: "rejected".to_owned(),
2391                        retryable: false,
2392                    }],
2393                },
2394                Frame::PushResult {
2395                    client_commit_id: ids[3].clone(),
2396                    status: PushStatus::Rejected,
2397                    commit_seq: None,
2398                    results: vec![OpResult::Error {
2399                        op_index: 0,
2400                        code: "sync.idempotency_cache_miss".to_owned(),
2401                        message: "retry".to_owned(),
2402                        retryable: true,
2403                    }],
2404                },
2405            ],
2406        };
2407        let mut transport =
2408            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
2409
2410        client.overlay_rebuild_count.set(0);
2411        client.outcome_prune_count.set(0);
2412        let outcome = client.process_response(&mut transport, response, &request_meta);
2413        let SyncOutcome::Ok(report) = outcome else {
2414            panic!("mixed push-result response failed");
2415        };
2416
2417        assert_eq!(report.applied, ids[..2]);
2418        assert_eq!(report.rejected, ids[2..3]);
2419        assert_eq!(report.retryable, ids[3..4]);
2420        assert_eq!(client.pending_commit_ids(), ids[3..4]);
2421        assert_eq!(
2422            client
2423                .query("SELECT id FROM tasks ORDER BY id", &[])
2424                .expect("query visible overlay"),
2425            vec![Map::from_iter([("id".to_owned(), Value::from("task-3"))])]
2426        );
2427        assert_eq!(client.overlay_rebuild_count.get(), 1);
2428        assert_eq!(client.outcome_prune_count.get(), 1);
2429    }
2430
2431    #[test]
2432    fn secondary_unique_collision_preserves_existing_synced_row() {
2433        let client = SyncClient::new(
2434            "unique-upsert-test".to_owned(),
2435            &json!({
2436                "version": 1,
2437                "tables": [{
2438                    "name": "tasks",
2439                    "primaryKey": "id",
2440                    "columns": [
2441                        { "name": "id", "type": "string", "nullable": false },
2442                        { "name": "project_id", "type": "string", "nullable": false },
2443                        { "name": "title", "type": "string", "nullable": false }
2444                    ],
2445                    "scopes": [{ "pattern": "project:{project_id}" }],
2446                    "indexes": [{
2447                        "name": "tasks_by_project_title",
2448                        "columns": ["project_id", "title"],
2449                        "unique": true
2450                    }]
2451                }]
2452            }),
2453            ClientLimits::default(),
2454        )
2455        .expect("test client");
2456        let table = client.schema.table("tasks").expect("tasks table");
2457        let sql = client.insert_row_sql(&base_table("tasks"), table);
2458
2459        client
2460            .conn
2461            .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
2462            .expect("insert first row");
2463        client
2464            .conn
2465            .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
2466            .expect("update same primary key");
2467        client
2468            .conn
2469            .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
2470            .expect("insert second row");
2471        assert!(client
2472            .conn
2473            .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
2474            .is_err());
2475
2476        let rows = client
2477            .conn
2478            .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
2479            .expect("prepare rows")
2480            .query_map([], |row| {
2481                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2482            })
2483            .expect("query rows")
2484            .collect::<Result<Vec<_>, _>>()
2485            .expect("collect rows");
2486        assert_eq!(
2487            rows,
2488            vec![
2489                ("t1".to_owned(), "updated".to_owned()),
2490                ("t2".to_owned(), "original".to_owned())
2491            ]
2492        );
2493    }
2494
2495    #[test]
2496    fn reopening_active_subscriptions_emits_a_catch_up_intent() {
2497        let path = std::env::temp_dir().join(format!(
2498            "syncular-startup-intent-{}.db",
2499            uuid::Uuid::new_v4()
2500        ));
2501        let schema = json!({
2502            "version": 1,
2503            "tables": [{
2504                "name": "tasks",
2505                "primaryKey": "id",
2506                "columns": [
2507                    { "name": "id", "type": "string", "nullable": false },
2508                    { "name": "project_id", "type": "string", "nullable": false }
2509                ],
2510                "scopes": [{ "pattern": "project:{project_id}" }]
2511            }]
2512        });
2513
2514        {
2515            let mut first = SyncClient::open_path_with_identity(
2516                None,
2517                &schema,
2518                ClientLimits::default(),
2519                path.to_str().expect("UTF-8 temp path"),
2520            )
2521            .expect("first open");
2522            first
2523                .set_window(
2524                    &WindowBase {
2525                        table: "tasks".to_owned(),
2526                        variable: "project_id".to_owned(),
2527                        fixed_scopes: Vec::new(),
2528                        params: None,
2529                    },
2530                    &["persisted".to_owned()],
2531                )
2532                .expect("persist window");
2533        }
2534
2535        let mut reopened = SyncClient::open_path_with_identity(
2536            None,
2537            &schema,
2538            ClientLimits::default(),
2539            path.to_str().expect("UTF-8 temp path"),
2540        )
2541        .expect("reopen");
2542        assert!(reopened.sync_needed());
2543        assert!(matches!(
2544            reopened.drain_sync_intents().as_slice(),
2545            [SyncIntent::Interactive]
2546        ));
2547        drop(reopened);
2548        std::fs::remove_file(path).expect("remove temp database");
2549    }
2550
2551    #[test]
2552    fn reopening_preserves_immutable_subscription_identity_and_progress() {
2553        let path = std::env::temp_dir().join(format!(
2554            "syncular-subscription-identity-{}.db",
2555            uuid::Uuid::new_v4()
2556        ));
2557        let schema = json!({
2558            "version": 1,
2559            "tables": [
2560                {
2561                    "name": "tasks",
2562                    "primaryKey": "id",
2563                    "columns": [
2564                        { "name": "id", "type": "string", "nullable": false },
2565                        { "name": "project_id", "type": "string", "nullable": false }
2566                    ],
2567                    "scopes": [{ "pattern": "project:{project_id}" }]
2568                },
2569                {
2570                    "name": "docs",
2571                    "primaryKey": "id",
2572                    "columns": [
2573                        { "name": "id", "type": "string", "nullable": false },
2574                        { "name": "org_id", "type": "string", "nullable": false },
2575                        { "name": "project_id", "type": "string", "nullable": false }
2576                    ],
2577                    "scopes": [
2578                        { "pattern": "org:{org_id}" },
2579                        { "pattern": "project:{projectId}", "column": "project_id" }
2580                    ]
2581                }
2582            ]
2583        });
2584
2585        {
2586            let mut first = SyncClient::open_path_with_identity(
2587                None,
2588                &schema,
2589                ClientLimits::default(),
2590                path.to_str().expect("UTF-8 temp path"),
2591            )
2592            .expect("first open");
2593            first
2594                .subscribe(
2595                    "stable-subscription".to_owned(),
2596                    "tasks".to_owned(),
2597                    vec![(
2598                        "project_id".to_owned(),
2599                        vec!["p2".to_owned(), "p1".to_owned()],
2600                    )],
2601                    Some(r#"{"view":"v1"}"#.to_owned()),
2602                )
2603                .expect("persist subscription");
2604            let persisted = {
2605                let subscription = first
2606                    .subs
2607                    .iter_mut()
2608                    .find(|subscription| subscription.id == "stable-subscription")
2609                    .expect("subscription");
2610                subscription.cursor = 41;
2611                subscription.bootstrap_state = Some("resume-token".to_owned());
2612                subscription.effective = Some(vec![(
2613                    "project_id".to_owned(),
2614                    vec!["p1".to_owned(), "p2".to_owned()],
2615                )]);
2616                subscription.synced_once = true;
2617                subscription.clone()
2618            };
2619            first.persist_sub(&persisted).unwrap();
2620        }
2621
2622        let mut reopened = SyncClient::open_path_with_identity(
2623            None,
2624            &schema,
2625            ClientLimits::default(),
2626            path.to_str().expect("UTF-8 temp path"),
2627        )
2628        .expect("reopen");
2629        let progress = reopened
2630            .subscription_state("stable-subscription")
2631            .expect("persisted state");
2632        assert_eq!(progress.cursor, 41);
2633        assert!(progress.has_resume_token);
2634
2635        reopened
2636            .subscribe(
2637                "stable-subscription".to_owned(),
2638                "tasks".to_owned(),
2639                vec![(
2640                    "project_id".to_owned(),
2641                    vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
2642                )],
2643                Some(r#"{"view":"v1"}"#.to_owned()),
2644            )
2645            .expect("canonical intent is idempotent");
2646        assert_eq!(
2647            reopened
2648                .subscription_state("stable-subscription")
2649                .expect("unchanged state")
2650                .cursor,
2651            progress.cursor
2652        );
2653
2654        for (table, scopes, params) in [
2655            (
2656                "tasks",
2657                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2658                Some(r#"{"view":"v1"}"#.to_owned()),
2659            ),
2660            (
2661                "tasks",
2662                vec![(
2663                    "project_id".to_owned(),
2664                    vec!["p2".to_owned(), "p1".to_owned()],
2665                )],
2666                Some(r#"{"view":"v2"}"#.to_owned()),
2667            ),
2668            (
2669                "docs",
2670                vec![
2671                    ("org_id".to_owned(), vec!["o1".to_owned()]),
2672                    ("projectId".to_owned(), vec!["p1".to_owned()]),
2673                ],
2674                Some(r#"{"view":"v1"}"#.to_owned()),
2675            ),
2676        ] {
2677            let error = reopened
2678                .subscribe(
2679                    "stable-subscription".to_owned(),
2680                    table.to_owned(),
2681                    scopes,
2682                    params,
2683                )
2684                .expect_err("identity rebind must fail");
2685            assert!(error.starts_with("client.subscription_intent_mismatch:"));
2686            assert_eq!(
2687                reopened
2688                    .subscription_state("stable-subscription")
2689                    .expect("unchanged state")
2690                    .cursor,
2691                progress.cursor
2692            );
2693        }
2694
2695        drop(reopened);
2696        std::fs::remove_file(path).expect("remove temp database");
2697    }
2698
2699    #[test]
2700    fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
2701        let path = std::env::temp_dir().join(format!(
2702            "syncular-satisfied-schema-floor-{}.db",
2703            uuid::Uuid::new_v4()
2704        ));
2705        let schema = json!({
2706            "version": 23,
2707            "tables": [{
2708                "name": "tasks",
2709                "primaryKey": "id",
2710                "columns": [
2711                    { "name": "id", "type": "string", "nullable": false },
2712                    { "name": "project_id", "type": "string", "nullable": false }
2713                ],
2714                "scopes": [{ "pattern": "project:{project_id}" }]
2715            }]
2716        });
2717
2718        {
2719            let mut first = SyncClient::open_path_with_identity(
2720                None,
2721                &schema,
2722                ClientLimits::default(),
2723                path.to_str().expect("UTF-8 temp path"),
2724            )
2725            .expect("first open");
2726            first
2727                .subscribe(
2728                    "tasks".to_owned(),
2729                    "tasks".to_owned(),
2730                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2731                    None,
2732                )
2733                .expect("persist subscription");
2734            first.set_schema_floor(Some(SchemaFloor {
2735                required_schema_version: Some(22),
2736                latest_schema_version: Some(22),
2737            }));
2738        }
2739
2740        let mut reopened = SyncClient::open_path_with_identity(
2741            None,
2742            &schema,
2743            ClientLimits::default(),
2744            path.to_str().expect("UTF-8 temp path"),
2745        )
2746        .expect("reopen");
2747        assert!(reopened.schema_floor().is_none());
2748        assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
2749        assert!(reopened.sync_needed());
2750        assert!(matches!(
2751            reopened.drain_sync_intents().as_slice(),
2752            [SyncIntent::Interactive]
2753        ));
2754        drop(reopened);
2755        std::fs::remove_file(path).expect("remove temp database");
2756    }
2757
2758    #[test]
2759    fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
2760        let path = std::env::temp_dir().join(format!(
2761            "syncular-unsatisfied-schema-floor-{}.db",
2762            uuid::Uuid::new_v4()
2763        ));
2764        let schema = json!({
2765            "version": 1,
2766            "tables": [{
2767                "name": "tasks",
2768                "primaryKey": "id",
2769                "columns": [
2770                    { "name": "id", "type": "string", "nullable": false },
2771                    { "name": "project_id", "type": "string", "nullable": false }
2772                ],
2773                "scopes": [{ "pattern": "project:{project_id}" }]
2774            }]
2775        });
2776
2777        {
2778            let mut first = SyncClient::open_path_with_identity(
2779                None,
2780                &schema,
2781                ClientLimits::default(),
2782                path.to_str().expect("UTF-8 temp path"),
2783            )
2784            .expect("first open");
2785            first
2786                .subscribe(
2787                    "tasks".to_owned(),
2788                    "tasks".to_owned(),
2789                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2790                    None,
2791                )
2792                .expect("persist subscription");
2793            first.set_schema_floor(Some(SchemaFloor {
2794                required_schema_version: Some(2),
2795                latest_schema_version: Some(2),
2796            }));
2797        }
2798
2799        let reopened = SyncClient::open_path_with_identity(
2800            None,
2801            &schema,
2802            ClientLimits::default(),
2803            path.to_str().expect("UTF-8 temp path"),
2804        )
2805        .expect("reopen");
2806        assert_eq!(
2807            reopened.schema_floor(),
2808            Some(&SchemaFloor {
2809                required_schema_version: Some(2),
2810                latest_schema_version: Some(2),
2811            })
2812        );
2813        assert!(!reopened.sync_needed());
2814        drop(reopened);
2815        std::fs::remove_file(path).expect("remove temp database");
2816    }
2817
2818    #[test]
2819    fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
2820        let path = std::env::temp_dir().join(format!(
2821            "syncular-indexed-column-bump-{}.db",
2822            uuid::Uuid::new_v4()
2823        ));
2824        let old_schema = json!({
2825            "version": 1,
2826            "tables": [
2827                {
2828                    "name": "tasks",
2829                    "primaryKey": "id",
2830                    "columns": [
2831                        { "name": "id", "type": "string", "nullable": false },
2832                        { "name": "project_id", "type": "string", "nullable": false }
2833                    ],
2834                    "scopes": [{ "pattern": "project:{project_id}" }]
2835                },
2836                {
2837                    "name": "legacy",
2838                    "primaryKey": "id",
2839                    "columns": [
2840                        { "name": "id", "type": "string", "nullable": false },
2841                        { "name": "project_id", "type": "string", "nullable": false }
2842                    ],
2843                    "scopes": [{ "pattern": "project:{project_id}" }]
2844                }
2845            ]
2846        });
2847        {
2848            let mut first = SyncClient::open_path_with_identity(
2849                None,
2850                &old_schema,
2851                ClientLimits::default(),
2852                path.to_str().expect("UTF-8 temp path"),
2853            )
2854            .expect("open old schema");
2855            first
2856                .subscribe(
2857                    "legacy-sub".to_owned(),
2858                    "legacy".to_owned(),
2859                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2860                    None,
2861                )
2862                .expect("persist legacy subscription");
2863        }
2864
2865        let new_schema = json!({
2866            "version": 2,
2867            "tables": [{
2868                "name": "tasks",
2869                "primaryKey": "id",
2870                "columns": [
2871                    { "name": "id", "type": "string", "nullable": false },
2872                    { "name": "project_id", "type": "string", "nullable": false },
2873                    { "name": "facility_membership_id", "type": "string", "nullable": true }
2874                ],
2875                "scopes": [{ "pattern": "project:{project_id}" }],
2876                "indexes": [{
2877                    "name": "tasks_by_membership",
2878                    "columns": ["project_id", "facility_membership_id"],
2879                    "unique": false
2880                }]
2881            }]
2882        });
2883        let upgraded = SyncClient::open_path_with_identity(
2884            None,
2885            &new_schema,
2886            ClientLimits::default(),
2887            path.to_str().expect("UTF-8 temp path"),
2888        )
2889        .expect("open upgraded schema");
2890        let columns = upgraded
2891            .conn
2892            .prepare("PRAGMA table_info(tasks)")
2893            .expect("prepare columns")
2894            .query_map([], |row| row.get::<_, String>(1))
2895            .expect("query columns")
2896            .collect::<Result<Vec<_>, _>>()
2897            .expect("collect columns");
2898        assert!(columns.contains(&"facility_membership_id".to_owned()));
2899        let index_count: i64 = upgraded
2900            .conn
2901            .query_row(
2902                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
2903                [],
2904                |row| row.get(0),
2905            )
2906            .expect("query index");
2907        assert_eq!(index_count, 1);
2908        assert!(upgraded.subscription_state("legacy-sub").is_none());
2909        drop(upgraded);
2910        std::fs::remove_file(path).expect("remove temp database");
2911    }
2912
2913    #[test]
2914    fn migrates_pre_envelope_outcome_journal_additively() {
2915        let path = std::env::temp_dir().join(format!(
2916            "syncular-outcome-migration-{}.db",
2917            uuid::Uuid::new_v4()
2918        ));
2919        let conn = Connection::open(&path).expect("open legacy database");
2920        conn.execute_batch(
2921            "CREATE TABLE _syncular_commit_outcomes (
2922               seq INTEGER PRIMARY KEY AUTOINCREMENT,
2923               client_commit_id TEXT NOT NULL UNIQUE,
2924               status TEXT NOT NULL,
2925               recorded_at_ms INTEGER NOT NULL,
2926               results_json TEXT NOT NULL,
2927               resolution TEXT NOT NULL DEFAULT 'active',
2928               resolved_at_ms INTEGER,
2929               replacement_client_commit_id TEXT);",
2930        )
2931        .expect("create legacy outcome journal");
2932        drop(conn);
2933        let schema = json!({
2934            "version": 1,
2935            "tables": [{
2936                "name": "tasks",
2937                "primaryKey": "id",
2938                "columns": [
2939                    { "name": "id", "type": "string", "nullable": false },
2940                    { "name": "project_id", "type": "string", "nullable": false }
2941                ],
2942                "scopes": [{ "pattern": "project:{project_id}" }]
2943            }]
2944        });
2945        let client = SyncClient::open_path(
2946            "migration-native".to_owned(),
2947            &schema,
2948            ClientLimits::default(),
2949            path.to_str().expect("UTF-8 temp path"),
2950        )
2951        .expect("migrate database");
2952        let has_operations = client
2953            .conn
2954            .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
2955            .expect("prepare table info")
2956            .query_map([], |row| row.get::<_, String>(1))
2957            .expect("query table info")
2958            .filter_map(Result::ok)
2959            .any(|column| column == "operations_json");
2960        assert!(has_operations);
2961        drop(client);
2962        std::fs::remove_file(path).expect("remove temp database");
2963    }
2964
2965    #[test]
2966    fn durable_conflict_outcome_and_resolution_survive_reopen() {
2967        let path = std::env::temp_dir().join(format!(
2968            "syncular-durable-outcome-{}.db",
2969            uuid::Uuid::new_v4()
2970        ));
2971        let schema = json!({
2972            "version": 1,
2973            "tables": [{
2974                "name": "tasks",
2975                "primaryKey": "id",
2976                "columns": [
2977                    { "name": "id", "type": "string", "nullable": false },
2978                    { "name": "project_id", "type": "string", "nullable": false }
2979                ],
2980                "scopes": [{ "pattern": "project:{project_id}" }]
2981            }]
2982        });
2983        let conflict = ConflictRecord {
2984            client_commit_id: "losing-commit".to_owned(),
2985            op_index: 0,
2986            table: "tasks".to_owned(),
2987            row_id: "t1".to_owned(),
2988            code: "sync.version_conflict".to_owned(),
2989            message: "stale base version".to_owned(),
2990            server_version: 2,
2991            server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
2992            conflict_columns: vec!["project_id".to_owned()],
2993            operation: Some(CommitOperation {
2994                table: "tasks".to_owned(),
2995                row_id: "t1".to_owned(),
2996                op: "upsert".to_owned(),
2997                base_version: Some(1),
2998                values: None,
2999            }),
3000        };
3001        let failed_operations = vec![
3002            OutboxOp {
3003                upsert: true,
3004                table: "tasks".to_owned(),
3005                row_id: "t1".to_owned(),
3006                base_version: Some(1),
3007                values: None,
3008            },
3009            OutboxOp {
3010                upsert: true,
3011                table: "tasks".to_owned(),
3012                row_id: "status-event-1".to_owned(),
3013                base_version: Some(0),
3014                values: None,
3015            },
3016        ];
3017
3018        {
3019            let mut first = SyncClient::open_path(
3020                "durable-native".to_owned(),
3021                &schema,
3022                ClientLimits::default(),
3023                path.to_str().expect("UTF-8 temp path"),
3024            )
3025            .expect("first open");
3026            first
3027                .begin_observation("test_outcome")
3028                .expect("begin outcome");
3029            first
3030                .persist_commit_outcome(
3031                    "losing-commit",
3032                    CommitOutcomeStatus::Conflict,
3033                    &[CommitOperationOutcome::Conflict {
3034                        conflict: conflict.clone(),
3035                    }],
3036                    Some(&failed_operations),
3037                )
3038                .expect("persist outcome");
3039            first.conflicts.push(conflict);
3040            first
3041                .finish_observation(
3042                    "test_outcome",
3043                    ChangeAccumulator {
3044                        conflicts: true,
3045                        outcomes: true,
3046                        ..ChangeAccumulator::default()
3047                    },
3048                )
3049                .expect("commit outcome");
3050        }
3051
3052        {
3053            let mut reopened = SyncClient::open_path(
3054                "durable-native".to_owned(),
3055                &schema,
3056                ClientLimits::default(),
3057                path.to_str().expect("UTF-8 temp path"),
3058            )
3059            .expect("reopen");
3060            assert_eq!(reopened.conflicts().len(), 1);
3061            assert_eq!(
3062                reopened.conflicts()[0].conflict_columns,
3063                vec!["project_id".to_owned()],
3064                "conflictColumns survives the journal round trip"
3065            );
3066            let outcome = reopened
3067                .commit_outcome("losing-commit")
3068                .expect("read outcome")
3069                .expect("outcome");
3070            assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
3071            let operations = outcome.operations.expect("aggregate envelope");
3072            assert_eq!(operations.len(), 2);
3073            assert_eq!(operations[1].row_id, "status-event-1");
3074            let resolved = reopened
3075                .resolve_commit_outcome(ResolveCommitOutcomeInput {
3076                    client_commit_id: "losing-commit".to_owned(),
3077                    resolution: CommitOutcomeResolution::ResolvedKeepServer,
3078                    replacement_client_commit_id: None,
3079                })
3080                .expect("resolve");
3081            assert_eq!(
3082                resolved.resolution,
3083                CommitOutcomeResolution::ResolvedKeepServer
3084            );
3085            assert!(reopened.conflicts().is_empty());
3086        }
3087
3088        let reopened = SyncClient::open_path(
3089            "durable-native".to_owned(),
3090            &schema,
3091            ClientLimits::default(),
3092            path.to_str().expect("UTF-8 temp path"),
3093        )
3094        .expect("second reopen");
3095        assert!(reopened.conflicts().is_empty());
3096        assert_eq!(
3097            reopened
3098                .commit_outcome("losing-commit")
3099                .expect("read outcome")
3100                .expect("outcome")
3101                .resolution,
3102            CommitOutcomeResolution::ResolvedKeepServer
3103        );
3104        drop(reopened);
3105        std::fs::remove_file(path).expect("remove temp database");
3106    }
3107
3108    #[test]
3109    fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
3110        let path =
3111            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
3112        let schema = json!({
3113            "version": 1,
3114            "tables": [{
3115                "name": "tasks",
3116                "primaryKey": "id",
3117                "columns": [
3118                    { "name": "id", "type": "string", "nullable": false },
3119                    { "name": "project_id", "type": "string", "nullable": false }
3120                ],
3121                "scopes": [{ "pattern": "project:{project_id}" }]
3122            }]
3123        });
3124        let mut client = SyncClient::open_path_with_identity(
3125            Some("sidecar-client".to_owned()),
3126            &schema,
3127            ClientLimits::default(),
3128            path.to_str().expect("UTF-8 temp path"),
3129        )
3130        .expect("open owner");
3131        let base = WindowBase {
3132            table: "tasks".to_owned(),
3133            variable: "project_id".to_owned(),
3134            fixed_scopes: Vec::new(),
3135            params: None,
3136        };
3137        client
3138            .set_window(&base, &["one".to_owned()])
3139            .expect("set window");
3140        client
3141            .mutate(vec![Mutation::Upsert {
3142                table: "tasks".to_owned(),
3143                values: Map::from_iter([
3144                    ("id".to_owned(), Value::from("t1")),
3145                    ("project_id".to_owned(), Value::from("one")),
3146                ]),
3147                base_version: None,
3148            }])
3149            .expect("local mutate");
3150
3151        let coverage = [WindowCoverage {
3152            base,
3153            units: vec!["one".to_owned(), "missing".to_owned()],
3154        }];
3155        let owner = client
3156            .query_snapshot(
3157                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3158                &[],
3159                &coverage,
3160            )
3161            .expect("owner snapshot");
3162        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
3163        let sidecar = reader
3164            .query_snapshot(
3165                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3166                &[],
3167                &coverage,
3168            )
3169            .expect("sidecar snapshot");
3170
3171        assert_eq!(sidecar.revision, owner.revision);
3172        assert_eq!(sidecar.rows, owner.rows);
3173        assert_eq!(
3174            serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
3175            serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
3176        );
3177        assert_eq!(sidecar.revision, "2");
3178        assert_eq!(sidecar.rows[0]["id"], "t1");
3179        assert_eq!(sidecar.rows[0]["server_version"], -1);
3180        assert!(!sidecar.coverage.complete);
3181        assert_eq!(sidecar.coverage.pending.len(), 1);
3182        assert_eq!(sidecar.coverage.missing.len(), 1);
3183
3184        drop(reader);
3185        drop(client);
3186        std::fs::remove_file(path).expect("remove temp database");
3187    }
3188
3189    #[test]
3190    fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
3191        let mut client = client();
3192        let image = Connection::open_in_memory().unwrap();
3193        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
3194            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3195            INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
3196            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3197            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
3198        let table = client.schema.table("tasks").unwrap().clone();
3199        let scopes = vec![("project_id".into(), vec!["p1".into()])];
3200        assert!(client
3201            .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
3202            .is_err());
3203        assert!(client.conn.is_autocommit());
3204        assert_eq!(
3205            client
3206                .query("SELECT count(*) AS n FROM tasks", &[])
3207                .unwrap()[0]["n"],
3208            1024
3209        );
3210        assert_eq!(client.drain_change_batches().len(), 1);
3211        image
3212            .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
3213            .unwrap();
3214        assert!(matches!(
3215            client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
3216            Ok(2050)
3217        ));
3218        assert_eq!(
3219            client
3220                .query("SELECT count(*) AS n FROM tasks", &[])
3221                .unwrap()[0]["n"],
3222            2050
3223        );
3224        assert_eq!(client.drain_change_batches().len(), 3);
3225    }
3226
3227    #[test]
3228    fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
3229        let mut client = client();
3230        let base = WindowBase {
3231            table: "tasks".into(),
3232            variable: "project_id".into(),
3233            fixed_scopes: Vec::new(),
3234            params: None,
3235        };
3236        client
3237            .set_window(&base, &["p1".into(), "p2".into()])
3238            .unwrap();
3239        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3240            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3241            INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
3242            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3243            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3244        client.drain_change_batches();
3245        assert!(client.set_window(&base, &["p2".into()]).is_err());
3246        assert!(client.conn.is_autocommit());
3247        assert_eq!(
3248            client
3249                .query("SELECT count(*) AS n FROM tasks", &[])
3250                .unwrap()[0]["n"],
3251            1027
3252        );
3253        assert_eq!(client.window_state(&base).units, vec!["p2"]);
3254        assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
3255        assert_eq!(client.drain_change_batches().len(), 1);
3256        client
3257            .conn
3258            .execute_batch("DROP TRIGGER interrupt_evict")
3259            .unwrap();
3260        client.drain_pending_evictions().unwrap();
3261        assert!(client.load_pending_evictions().unwrap().is_empty());
3262        assert_eq!(
3263            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3264            "held"
3265        );
3266        assert_eq!(client.drain_change_batches().len(), 2);
3267    }
3268
3269    #[test]
3270    fn eviction_marker_failures_roll_back_window_changes() {
3271        let mut client = client();
3272        let base = WindowBase {
3273            table: "tasks".into(),
3274            variable: "project_id".into(),
3275            fixed_scopes: Vec::new(),
3276            params: None,
3277        };
3278        client.set_window(&base, &["p1".into()]).unwrap();
3279        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3280            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3281            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3282            CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3283        let revision = client.local_revision();
3284        assert!(client.set_window(&base, &[]).is_err());
3285        assert!(client.conn.is_autocommit());
3286        assert_eq!(client.local_revision(), revision);
3287        assert_eq!(
3288            client
3289                .query("SELECT count(*) AS n FROM tasks", &[])
3290                .unwrap()[0]["n"],
3291            2050
3292        );
3293        assert_eq!(client.window_state(&base).units, vec!["p1"]);
3294        client.conn.execute_batch("DROP TRIGGER fail_marker;
3295            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3296        assert!(client.set_window(&base, &[]).is_err());
3297        client.conn.execute_batch("DROP TRIGGER interrupt_evict;
3298            CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3299        assert!(client.drain_pending_evictions().is_err());
3300        assert!(client.conn.is_autocommit());
3301        assert_eq!(
3302            client
3303                .query("SELECT count(*) AS n FROM tasks", &[])
3304                .unwrap()[0]["n"],
3305            2
3306        );
3307        assert!(client.set_window(&base, &["p1".into()]).is_err());
3308        assert!(client.conn.is_autocommit());
3309        assert!(client.window_state(&base).units.is_empty());
3310        assert!(client.subs.is_empty());
3311        assert_eq!(
3312            client
3313                .query(
3314                    "SELECT count(*) AS n FROM _syncular_window_pending_evict",
3315                    &[]
3316                )
3317                .unwrap()[0]["n"],
3318            1
3319        );
3320        client
3321            .conn
3322            .execute_batch("DROP TRIGGER fail_marker")
3323            .unwrap();
3324        client.set_window(&base, &["p1".into()]).unwrap();
3325        assert_eq!(client.window_state(&base).units, vec!["p1"]);
3326    }
3327
3328    #[test]
3329    fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
3330        let mut client = client();
3331        client
3332            .conn
3333            .execute(
3334                "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
3335                [],
3336            )
3337            .unwrap();
3338        assert_eq!(
3339            client.drain_pending_evictions().unwrap_err(),
3340            "sync.local_corrupt: invalid pending eviction scopes"
3341        );
3342        assert!(client.conn.is_autocommit());
3343        assert!(client.enqueue_startup_sync_if_needed().is_err());
3344    }
3345
3346    #[test]
3347    fn image_chunks_reject_null_primary_keys() {
3348        let mut client = client();
3349        let image = Connection::open_in_memory().unwrap();
3350        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3351            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
3352            INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
3353            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3354            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
3355        let table = client.schema.table("tasks").unwrap().clone();
3356        assert!(client
3357            .apply_sqlite_image(
3358                &image,
3359                &table,
3360                true,
3361                &[("project_id".into(), vec!["p1".into()])],
3362                1025,
3363                7,
3364                "digest"
3365            )
3366            .is_err());
3367        assert!(client.conn.is_autocommit());
3368        assert_eq!(
3369            client
3370                .query("SELECT count(*) AS n FROM tasks", &[])
3371                .unwrap()[0]["n"],
3372            0
3373        );
3374    }
3375
3376    #[test]
3377    fn import_chunks_with_unique_constraints_match_full_pending_replay() {
3378        for images in [false, true] {
3379            let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
3380                "name":name,"primaryKey":"id","columns":[
3381                    {"name":"id","type":"string","nullable":false},
3382                    {"name":"project_id","type":"string","nullable":false},
3383                    {"name":"title","type":"string","nullable":false}],
3384                "scopes":[{"pattern":"project:{project_id}"}],
3385                "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
3386                "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
3387            })))});
3388            let mut actual =
3389                SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
3390            let mut reference =
3391                SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
3392            let scopes = vec![("project_id".into(), vec!["p1".into()])];
3393            for client in [&mut actual, &mut reference] {
3394                client
3395                    .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
3396                    .unwrap();
3397                client.subs[0].effective = Some(scopes.clone());
3398                for (table, id, title) in [
3399                    ("tasks", "a", "original"),
3400                    ("tasks", "b", "occupied"),
3401                    ("tasks", "c", "deleted"),
3402                    ("notes", "n", "untouched"),
3403                ] {
3404                    client
3405                        .write_base_row(
3406                            table,
3407                            &vec![
3408                                Some(ColumnValue::String(id.into())),
3409                                Some(ColumnValue::String("p1".into())),
3410                                Some(ColumnValue::String(title.into())),
3411                            ],
3412                            1,
3413                        )
3414                        .unwrap();
3415                }
3416                client.rebuild_overlay_if_dirty();
3417                client
3418                    .mutate(vec![
3419                        Mutation::Upsert {
3420                            table: "tasks".into(),
3421                            values: Map::from_iter([
3422                                ("id".into(), Value::from("a")),
3423                                ("project_id".into(), Value::from("p1")),
3424                                ("title".into(), Value::from("occupied")),
3425                            ]),
3426                            base_version: None,
3427                        },
3428                        Mutation::Delete {
3429                            table: "tasks".into(),
3430                            row_id: "c".into(),
3431                            base_version: None,
3432                        },
3433                        Mutation::Upsert {
3434                            table: "tasks".into(),
3435                            values: Map::from_iter([
3436                                ("id".into(), Value::from("local")),
3437                                ("project_id".into(), Value::from("p1")),
3438                                ("title".into(), Value::from("local value")),
3439                            ]),
3440                            base_version: None,
3441                        },
3442                        Mutation::Upsert {
3443                            table: "notes".into(),
3444                            values: Map::from_iter([
3445                                ("id".into(), Value::from("n")),
3446                                ("project_id".into(), Value::from("p1")),
3447                                ("title".into(), Value::from("pending note")),
3448                            ]),
3449                            base_version: None,
3450                        },
3451                    ])
3452                    .unwrap();
3453            }
3454            let pending = actual.pending_commit_ids();
3455            actual.overlay_rebuild_count.set(0);
3456            for (id, title) in [
3457                ("b", "available"),
3458                ("b", "occupied"),
3459                ("a", "server original"),
3460                ("d", "local value"),
3461                ("d", "released"),
3462                ("c", "redelivered"),
3463            ] {
3464                let table = actual.schema.table("tasks").unwrap().clone();
3465                let values = vec![
3466                    Some(ColumnValue::String(id.into())),
3467                    Some(ColumnValue::String("p1".into())),
3468                    Some(ColumnValue::String(title.into())),
3469                ];
3470                if images {
3471                    let image = Connection::open_in_memory().unwrap();
3472                    image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3473                        CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3474                        INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
3475                    image
3476                        .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
3477                        .unwrap();
3478                    assert!(matches!(
3479                        actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
3480                        Ok(1)
3481                    ));
3482                } else {
3483                    let segment = RowsSegment {
3484                        table: "tasks".into(),
3485                        schema_version: 1,
3486                        columns: table.wire_columns.clone(),
3487                        blocks: vec![vec![ssp2::segment::SegmentRow {
3488                            server_version: 2,
3489                            values: values.clone(),
3490                        }]],
3491                    };
3492                    assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
3493                }
3494                reference.write_base_row("tasks", &values, 2).unwrap();
3495                reference.rebuild_overlay();
3496                for table in ["tasks", "notes"] {
3497                    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")] {
3498                        assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
3499                    }
3500                }
3501                assert_eq!(actual.pending_commit_ids(), pending);
3502            }
3503            assert_eq!(
3504                actual.overlay_rebuild_count.get(),
3505                0,
3506                "each chunk must leave unrelated tables alone"
3507            );
3508            let before = actual
3509                .query("SELECT * FROM tasks ORDER BY id", &[])
3510                .unwrap();
3511            let fts_before = actual
3512                .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
3513                .unwrap();
3514            let revision = actual.local_revision();
3515            actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
3516            let table = actual.schema.table("tasks").unwrap().clone();
3517            let segment = RowsSegment {
3518                table: "tasks".into(),
3519                schema_version: 1,
3520                columns: table.wire_columns.clone(),
3521                blocks: vec![vec![ssp2::segment::SegmentRow {
3522                    server_version: 3,
3523                    values: vec![
3524                        Some(ColumnValue::String("b".into())),
3525                        Some(ColumnValue::String("p1".into())),
3526                        Some(ColumnValue::String("fresh".into())),
3527                    ],
3528                }]],
3529            };
3530            assert!(actual.apply_segment(0, &segment, false).is_err());
3531            assert!(actual.conn.is_autocommit());
3532            assert_eq!(
3533                actual
3534                    .query("SELECT * FROM tasks ORDER BY id", &[])
3535                    .unwrap(),
3536                before
3537            );
3538            assert_eq!(
3539                actual
3540                    .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
3541                    .unwrap(),
3542                fts_before
3543            );
3544            assert_eq!(actual.local_revision(), revision);
3545            assert_eq!(actual.pending_commit_ids(), pending);
3546        }
3547    }
3548
3549    #[test]
3550    fn import_reconciliation_preserves_typed_pending_primary_keys() {
3551        for (kind, a, b) in [
3552            ("string", json!("a"), json!("b")),
3553            ("integer", json!(1), json!(2)),
3554            ("float", json!(1.5), json!(2.5)),
3555            ("boolean", json!(true), json!(false)),
3556            ("json", json!("1"), json!("2")),
3557        ] {
3558            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}]}]});
3559            for images in [false, true] {
3560                let mut client =
3561                    SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
3562                let table = client.schema.table("tasks").unwrap().clone();
3563                client
3564                    .subscribe("tasks".into(), "tasks".into(), vec![], None)
3565                    .unwrap();
3566                for (id, title) in [(&a, "original"), (&b, "occupied")] {
3567                    client
3568                        .write_base_row(
3569                            "tasks",
3570                            &vec![
3571                                json_to_column_value(&table.columns[0], Some(id)).unwrap(),
3572                                Some(ColumnValue::String(title.into())),
3573                            ],
3574                            1,
3575                        )
3576                        .unwrap();
3577                }
3578                client.rebuild_overlay_if_dirty();
3579                client
3580                    .mutate(vec![Mutation::Upsert {
3581                        table: "tasks".into(),
3582                        values: Map::from_iter([
3583                            ("id".into(), a.clone()),
3584                            ("title".into(), json!("occupied")),
3585                        ]),
3586                        base_version: None,
3587                    }])
3588                    .unwrap();
3589                client.overlay_rebuild_count.set(0);
3590                for (title, expected) in [("available", "occupied"), ("occupied", "original")] {
3591                    let values = vec![
3592                        json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
3593                        Some(ColumnValue::String(title.into())),
3594                    ];
3595                    if images {
3596                        let image = Connection::open_in_memory().unwrap();
3597                        image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3598                            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3599                            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
3600                        image
3601                            .execute(
3602                                "INSERT INTO tasks VALUES (?1,?2,2)",
3603                                rusqlite::params![RowParam::Cell(&values[0]), title],
3604                            )
3605                            .unwrap();
3606                        assert!(
3607                            matches!(
3608                                client.apply_sqlite_image(
3609                                    &image,
3610                                    &table,
3611                                    false,
3612                                    &[],
3613                                    1,
3614                                    7,
3615                                    "digest"
3616                                ),
3617                                Ok(1)
3618                            ),
3619                            "{kind}, images={images}"
3620                        );
3621                    } else {
3622                        let segment = RowsSegment {
3623                            table: "tasks".into(),
3624                            schema_version: 1,
3625                            columns: table.wire_columns.clone(),
3626                            blocks: vec![vec![ssp2::segment::SegmentRow {
3627                                server_version: 2,
3628                                values,
3629                            }]],
3630                        };
3631                        assert!(
3632                            matches!(client.apply_segment(0, &segment, false), Ok(1)),
3633                            "{kind}"
3634                        );
3635                    }
3636                    let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
3637                    let actual: String = client
3638                        .conn
3639                        .query_row(
3640                            "SELECT title FROM tasks WHERE id=?1",
3641                            [RowParam::Cell(&key)],
3642                            |row| row.get(0),
3643                        )
3644                        .unwrap();
3645                    assert_eq!(actual, expected, "{kind}, images={images}");
3646                }
3647                assert_eq!(client.overlay_rebuild_count.get(), 0);
3648            }
3649        }
3650    }
3651
3652    fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
3653        let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
3654            "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
3655            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
3656            "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
3657        })))});
3658        let mut client =
3659            SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
3660        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();
3661        client.rebuild_overlay();
3662        client
3663            .mutate(vec![Mutation::Upsert {
3664                table: "tasks".into(),
3665                values: Map::from_iter([
3666                    ("id".into(), json!("offline")),
3667                    ("project_id".into(), json!("p1")),
3668                    ("title".into(), json!("pending edit")),
3669                ]),
3670                base_version: None,
3671            }])
3672            .unwrap();
3673        let image = Connection::open_in_memory().unwrap();
3674        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3675            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
3676        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();
3677        image
3678            .execute(
3679                "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
3680                [rows],
3681            )
3682            .unwrap();
3683        client.overlay_rebuild_count.set(0);
3684        let table = client.schema.table("tasks").unwrap().clone();
3685        (client, image, table)
3686    }
3687
3688    #[test]
3689    fn large_unique_import_copies_no_unrelated_tables() {
3690        let (mut client, image, table) = unique_import_fixture(10240);
3691        let pending = client.pending_commit_ids();
3692        client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
3693            CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
3694        assert!(matches!(
3695            client.apply_sqlite_image(
3696                &image,
3697                &table,
3698                true,
3699                &[("project_id".into(), vec!["p1".into()])],
3700                10240,
3701                7,
3702                "digest"
3703            ),
3704            Ok(10240)
3705        ));
3706        assert_eq!(client.overlay_rebuild_count.get(), 0);
3707        assert_eq!(client.pending_commit_ids(), pending);
3708        assert_eq!(
3709            client
3710                .query("SELECT count(*) AS n FROM tasks", &[])
3711                .unwrap()[0]["n"],
3712            10241
3713        );
3714        assert_eq!(
3715            client
3716                .query("SELECT count(*) AS n FROM tasks_fts", &[])
3717                .unwrap()[0]["n"],
3718            10241
3719        );
3720        assert_eq!(
3721            client
3722                .query("SELECT count(*) AS n FROM notes_fts", &[])
3723                .unwrap()[0]["n"],
3724            8192
3725        );
3726    }
3727
3728    #[test]
3729    #[ignore = "manual scaling benchmark; see bench/README.md"]
3730    fn benchmark_unique_image_import() {
3731        let mut samples = Vec::new();
3732        for rows in [10240, 51200, 102400] {
3733            for trial in 0..4 {
3734                let (mut client, image, table) = unique_import_fixture(rows);
3735                let start = std::time::Instant::now();
3736                assert!(
3737                    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)
3738                );
3739                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
3740                assert_eq!(
3741                    client
3742                        .query("SELECT count(*) AS n FROM tasks", &[])
3743                        .unwrap()[0]["n"],
3744                    rows + 1
3745                );
3746                assert_eq!(
3747                    client
3748                        .query("SELECT count(*) AS n FROM tasks_fts", &[])
3749                        .unwrap()[0]["n"],
3750                    rows + 1
3751                );
3752                assert_eq!(
3753                    client
3754                        .query("SELECT count(*) AS n FROM notes_fts", &[])
3755                        .unwrap()[0]["n"],
3756                    8192
3757                );
3758                assert_eq!(client.pending_commit_ids().len(), 1);
3759                if trial > 0 {
3760                    samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
3761                }
3762            }
3763        }
3764        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});
3765        let output = serde_json::to_string_pretty(&artifact).unwrap();
3766        if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
3767            use std::io::Write;
3768            let mut file = std::fs::OpenOptions::new()
3769                .write(true)
3770                .create_new(true)
3771                .open(path)
3772                .unwrap();
3773            file.write_all(output.as_bytes()).unwrap();
3774        } else {
3775            println!("{output}");
3776        }
3777    }
3778
3779    #[test]
3780    fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
3781        let schema = json!({
3782            "version": 1,
3783            "tables": [{
3784                "name": "catalogue_codes",
3785                "primaryKey": "id",
3786                "columns": [
3787                    { "name": "id", "type": "string", "nullable": false },
3788                    { "name": "release_id", "type": "string", "nullable": false },
3789                    { "name": "code", "type": "string", "nullable": false },
3790                    { "name": "title", "type": "string", "nullable": false }
3791                ],
3792                "scopes": [{ "pattern": "release:{release_id}" }],
3793                "ftsIndexes": [{
3794                    "name": "catalogue_codes_fts",
3795                    "columns": ["code", "title"],
3796                    "tokenize": "unicode61 remove_diacritics 2"
3797                }]
3798            }]
3799        });
3800        let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
3801            .expect("FTS5 client");
3802        let insert_trigger: String = client
3803            .conn
3804            .query_row(
3805                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
3806                [],
3807                |row| row.get(0),
3808            )
3809            .expect("insert trigger");
3810        let replace_guard: String = client
3811            .conn
3812            .query_row(
3813                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
3814                [],
3815                |row| row.get(0),
3816            )
3817            .expect("replace guard");
3818        assert!(!insert_trigger.contains("DELETE FROM"));
3819        assert!(replace_guard.contains("BEFORE INSERT"));
3820        assert!(replace_guard.contains("WHEN EXISTS"));
3821        let search = |client: &SyncClient, query: &str| {
3822            client
3823                .query(
3824                    "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",
3825                    &[Value::from(query)],
3826                )
3827                .expect("FTS query")
3828        };
3829
3830        client
3831            .mutate(vec![Mutation::Upsert {
3832                table: "catalogue_codes".to_owned(),
3833                values: Map::from_iter([
3834                    ("id".to_owned(), Value::from("c1")),
3835                    ("release_id".to_owned(), Value::from("r1")),
3836                    ("code".to_owned(), Value::from("A01")),
3837                    ("title".to_owned(), Value::from("Cholera")),
3838                ]),
3839                base_version: None,
3840            }])
3841            .expect("insert code");
3842        assert_eq!(search(&client, "cholera").len(), 1);
3843        let prior = client
3844            .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
3845            .unwrap();
3846        client
3847            .conn
3848            .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
3849            .unwrap();
3850        client.create_synced_tables().unwrap();
3851        assert_eq!(
3852            client
3853                .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
3854                .unwrap(),
3855            prior
3856        );
3857        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();
3858        let rendered = serde_json::to_string(&plan).unwrap();
3859        assert!(rendered.contains("COVERING INDEX"));
3860        assert!(rendered.contains("INDEX 0:="));
3861        client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
3862        assert_eq!(search(&client, "cholera").len(), 1);
3863
3864        client
3865            .mutate(vec![Mutation::Upsert {
3866                table: "catalogue_codes".to_owned(),
3867                values: Map::from_iter([
3868                    ("id".to_owned(), Value::from("c1")),
3869                    ("release_id".to_owned(), Value::from("r1")),
3870                    ("code".to_owned(), Value::from("A01")),
3871                    ("title".to_owned(), Value::from("Enteric infection")),
3872                ]),
3873                base_version: None,
3874            }])
3875            .expect("update code");
3876        assert!(search(&client, "cholera").is_empty());
3877        assert_eq!(search(&client, "enteric").len(), 1);
3878
3879        client
3880            .mutate(vec![Mutation::Delete {
3881                table: "catalogue_codes".to_owned(),
3882                row_id: "c1".to_owned(),
3883                base_version: None,
3884            }])
3885            .expect("delete code");
3886        assert!(search(&client, "enteric").is_empty());
3887    }
3888
3889    #[test]
3890    fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
3891        let schema = json!({
3892            "version": 1,
3893            "tables": [{
3894                "name": "patient_notes",
3895                "primaryKey": "id",
3896                "columns": [
3897                    { "name": "id", "type": "string", "nullable": false },
3898                    { "name": "practice_id", "type": "string", "nullable": false },
3899                    { "name": "encryption_key_id", "type": "string", "nullable": false },
3900                    { "name": "title", "type": "string", "nullable": false }
3901                ],
3902                "scopes": [{ "pattern": "practice:{practice_id}" }],
3903                "ftsIndexes": [{
3904                    "name": "patient_notes_fts",
3905                    "columns": ["title"],
3906                    "tokenize": "unicode61 remove_diacritics 2"
3907                }]
3908            }]
3909        });
3910        let mut client = SyncClient::new(
3911            "local-purge-test".to_owned(),
3912            &schema,
3913            ClientLimits::default(),
3914        )
3915        .expect("local purge client");
3916        client
3917            .conn
3918            .execute(
3919                "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
3920                 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
3921                 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
3922                [],
3923            )
3924            .expect("seed base rows");
3925        client.overlay_dirty.set(true);
3926        client.rebuild_overlay_if_dirty();
3927
3928        let note = |id: &str, key_id: &str, title: &str| {
3929            Map::from_iter([
3930                ("id".to_owned(), Value::from(id)),
3931                ("practice_id".to_owned(), Value::from("practice-1")),
3932                ("encryption_key_id".to_owned(), Value::from(key_id)),
3933                ("title".to_owned(), Value::from(title)),
3934            ])
3935        };
3936        let doomed = client
3937            .mutate(vec![
3938                Mutation::Upsert {
3939                    table: "patient_notes".to_owned(),
3940                    values: note("target", "key-revoked", "Target changed"),
3941                    base_version: None,
3942                },
3943                Mutation::Upsert {
3944                    table: "patient_notes".to_owned(),
3945                    values: note("unrelated", "key-held", "Unrelated changed"),
3946                    base_version: None,
3947                },
3948            ])
3949            .expect("doomed commit");
3950        let kept = client
3951            .mutate(vec![Mutation::Upsert {
3952                table: "patient_notes".to_owned(),
3953                values: note("kept", "key-held", "Kept optimistic"),
3954                base_version: None,
3955            }])
3956            .expect("kept commit");
3957        client.drain_change_batches();
3958
3959        let input = LocalDataPurgeInput {
3960            purge_id: "purge-001".to_owned(),
3961            targets: vec![LocalDataPurgeTarget {
3962                table: "patient_notes".to_owned(),
3963                selectors: BTreeMap::from([(
3964                    "encryption_key_id".to_owned(),
3965                    vec!["key-revoked".to_owned()],
3966                )]),
3967            }],
3968        };
3969        assert_eq!(
3970            client.purge_local_data(&input).expect("apply purge"),
3971            LocalDataPurgeResult {
3972                already_applied: false,
3973                purged_rows: 1,
3974                dropped_commits: 1,
3975            }
3976        );
3977        let rows = client
3978            .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
3979            .expect("visible rows");
3980        assert_eq!(rows.len(), 2);
3981        assert_eq!(rows[0]["id"], "kept");
3982        assert_eq!(rows[1]["id"], "unrelated");
3983        assert_eq!(rows[1]["title"], "Unrelated original");
3984        let fts = client
3985            .query(
3986                "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'",
3987                &[],
3988            )
3989            .expect("fts query");
3990        assert!(fts.is_empty());
3991        assert_eq!(client.outbox.len(), 1);
3992        assert_eq!(client.outbox[0].client_commit_id, kept);
3993        let outcome = client
3994            .commit_outcome(&doomed)
3995            .expect("read doomed outcome")
3996            .expect("doomed outcome");
3997        assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
3998        match &outcome.results[0] {
3999            CommitOperationOutcome::Error { rejection } => {
4000                assert_eq!(rejection.code, "client.local_data_purged");
4001                assert_eq!(rejection.client_commit_id, doomed);
4002            }
4003            other => panic!("expected local purge rejection, got {other:?}"),
4004        }
4005        assert_eq!(client.drain_change_batches().len(), 1);
4006        assert_eq!(
4007            client.purge_local_data(&input).expect("retry purge"),
4008            LocalDataPurgeResult {
4009                already_applied: true,
4010                purged_rows: 0,
4011                dropped_commits: 0,
4012            }
4013        );
4014        let conflicting = LocalDataPurgeInput {
4015            purge_id: input.purge_id.clone(),
4016            targets: vec![LocalDataPurgeTarget {
4017                table: "patient_notes".to_owned(),
4018                selectors: BTreeMap::from([(
4019                    "encryption_key_id".to_owned(),
4020                    vec!["key-held".to_owned()],
4021                )]),
4022            }],
4023        };
4024        assert!(client
4025            .purge_local_data(&conflicting)
4026            .expect_err("id collision must fail")
4027            .contains("already used with a different plan"));
4028    }
4029}
4030
4031impl SubState {
4032    fn name(self) -> &'static str {
4033        match self {
4034            SubState::Active => "active",
4035            SubState::Revoked => "revoked",
4036            SubState::Failed => "failed",
4037        }
4038    }
4039
4040    fn parse(value: &str) -> Self {
4041        match value {
4042            "revoked" => Self::Revoked,
4043            "failed" => Self::Failed,
4044            _ => Self::Active,
4045        }
4046    }
4047}
4048
4049#[derive(Debug, Clone)]
4050struct Subscription {
4051    id: String,
4052    table: String,
4053    requested: Vec<(String, Vec<String>)>,
4054    params: Option<String>,
4055    cursor: i64,
4056    /// §4.7 resume token, round-tripped opaquely.
4057    bootstrap_state: Option<String>,
4058    state: SubState,
4059    reason_code: Option<String>,
4060    /// Last effective scopes echoed while active (§3.3: persisted for the
4061    /// purge contract; each active echo replaces it).
4062    effective: Option<Vec<(String, Vec<String>)>>,
4063    synced_once: bool,
4064}
4065
4066#[derive(Debug, Clone)]
4067struct OutboxOp {
4068    upsert: bool,
4069    table: String,
4070    row_id: String,
4071    base_version: Option<i64>,
4072    /// Schema-agnostic local form (§0): driver JSON values, encoded with
4073    /// the current codec at send time. A key's presence IS the §6.1 sparse
4074    /// presence set — both the overlay and the wire payload derive from it.
4075    values: Option<Map<String, Value>>,
4076}
4077
4078impl From<&OutboxOp> for CommitOperation {
4079    fn from(operation: &OutboxOp) -> Self {
4080        Self {
4081            table: operation.table.clone(),
4082            row_id: operation.row_id.clone(),
4083            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
4084            base_version: operation.base_version,
4085            values: operation.values.clone(),
4086        }
4087    }
4088}
4089
4090#[derive(Debug, Clone)]
4091struct OutboxCommit {
4092    client_commit_id: String,
4093    ops: Vec<OutboxOp>,
4094}
4095
4096#[derive(Debug, Clone)]
4097struct CompiledLocalDataPurgeTarget {
4098    table: String,
4099    selectors: Vec<(String, Vec<String>)>,
4100}
4101
4102struct StoredCommitOutcomeRow {
4103    sequence: i64,
4104    client_commit_id: String,
4105    status: String,
4106    recorded_at_ms: i64,
4107    results_json: String,
4108    operations_json: Option<String>,
4109    resolution: String,
4110    resolved_at_ms: Option<i64>,
4111    replacement_client_commit_id: Option<String>,
4112}
4113
4114/// Section outcome distinguishing the §5.6 subscription-local fail-closed
4115/// path from a round-aborting failure (§1.4 rule 5).
4116enum SectionError {
4117    FailClosed,
4118    Abort(String, String),
4119}
4120
4121struct RequestMeta {
4122    pushed_ids: Vec<String>,
4123    /// Subscription id → the request carried `cursor < 0` and no resume
4124    /// token (§5.6 first-page detection: a *fresh* bootstrap).
4125    fresh: Vec<(String, bool)>,
4126    accept: u8,
4127    /// §6.1 splitBatch: outbox commits held back from THIS request because
4128    /// the running operation count reached the push cap — the next round
4129    /// pushes them (`sync_needed` stays set while any remain).
4130    deferred_commits: usize,
4131}
4132
4133#[derive(Default)]
4134struct ChangeAccumulator {
4135    /// None means table-wide; Some is the exact scoped domain.
4136    tables: BTreeMap<String, Option<BTreeSet<String>>>,
4137    windows: BTreeMap<(String, String), BTreeSet<String>>,
4138    status: bool,
4139    conflicts: bool,
4140    rejections: bool,
4141    outcomes: bool,
4142}
4143
4144impl ChangeAccumulator {
4145    fn table(&mut self, table: &str) {
4146        self.tables.insert(table.to_owned(), None);
4147    }
4148
4149    fn scope(&mut self, table: &str, key: String) {
4150        match self.tables.get_mut(table) {
4151            Some(None) => {}
4152            Some(Some(keys)) => {
4153                keys.insert(key);
4154            }
4155            None => {
4156                self.tables
4157                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
4158            }
4159        }
4160    }
4161
4162    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
4163        self.windows
4164            .entry((base_key.to_owned(), table.to_owned()))
4165            .or_default()
4166            .insert(unit.to_owned());
4167    }
4168
4169    fn touched(&self) -> bool {
4170        !self.tables.is_empty()
4171            || !self.windows.is_empty()
4172            || self.status
4173            || self.conflicts
4174            || self.rejections
4175            || self.outcomes
4176    }
4177}
4178
4179/// §6.1: the server caps total operations per request (reference default
4180/// 500) and rejects the whole batch with `sync.too_many_operations`; the
4181/// client "splits and retries". Splitting happens at build time: commits are
4182/// included IN ORDER until the operation budget is spent, the rest wait for
4183/// the next round.
4184const PUSH_OPS_PER_REQUEST: usize = 500;
4185const MAX_LOCAL_PURGE_TARGETS: usize = 64;
4186const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
4187const MAX_LOCAL_PURGE_VALUES: usize = 128;
4188const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
4189pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
4190
4191pub struct SyncClient {
4192    #[cfg(feature = "bench-internals")]
4193    benchmark_phases: Recorder,
4194    conn: Connection,
4195    schema: ClientSchema,
4196    client_id: String,
4197    limits: ClientLimits,
4198    subs: Vec<Subscription>,
4199    outbox: Vec<OutboxCommit>,
4200    conflicts: Vec<ConflictRecord>,
4201    rejections: Vec<RejectionRecord>,
4202    schema_floor: Option<SchemaFloor>,
4203    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
4204    lease_state: Option<LeaseState>,
4205    /// §1.6: the schema-floor response stops syncing until an upgrade.
4206    stopped: bool,
4207    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
4208    upgrading: bool,
4209    /// §8.4 coalesced sync-needed signal.
4210    sync_needed: bool,
4211    realtime_connected: bool,
4212    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
4213    presence: HashMap<String, HashMap<String, PresencePeer>>,
4214    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
4215    /// host may pin it (conformance runs on a virtual clock).
4216    now_ms: Option<i64>,
4217    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
4218    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
4219    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
4220    encryption: crate::values::EncryptionConfig,
4221    /// Fail-closed host bootstrap gate. While set, command hosts permit only
4222    /// status/lifecycle inspection and an exact authorized local purge.
4223    security_preflight: bool,
4224    /// Per-table primary-key upsert SQL, built once per (full table name) —
4225    /// the row write path runs per row during bootstrap (§5.6), so the SQL
4226    /// string (and, via `prepare_cached`, its compiled statement) is reused
4227    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
4228    /// schema reset (the column lists may have changed).
4229    insert_sql: RefCell<HashMap<String, String>>,
4230    /// §7.1 rebuild gate: true whenever the base tables or the outbox have
4231    /// diverged from the visible overlay since the last rebuild. Lets a
4232    /// no-op sync round skip the full base→visible copy.
4233    overlay_dirty: Cell<bool>,
4234    /// Test-only structural performance signal: response processing must not
4235    /// turn a batch of acknowledgements into one full overlay rebuild each.
4236    #[cfg(test)]
4237    overlay_rebuild_count: Cell<usize>,
4238    /// Test-only structural performance signal: outcome retention is enforced
4239    /// once per response rather than once per acknowledgement.
4240    #[cfg(test)]
4241    outcome_prune_count: Cell<usize>,
4242    /// Exact observer-transaction output drained by command/FFI hosts.
4243    progress: ProgressObserver,
4244    change_queue: VecDeque<ClientChangeBatch>,
4245    sync_intent_queue: VecDeque<SyncIntent>,
4246    /// Explicit exponential retry policy for transient transport failures.
4247    retry_delay_ms: u64,
4248    last_round: Option<DiagnosticLastRound>,
4249    last_change: Option<DiagnosticLastChange>,
4250}
4251
4252fn quote_ident(name: &str) -> String {
4253    format!("\"{}\"", name.replace('"', "\"\""))
4254}
4255
4256/// Seek by the existing key while retaining the exact legacy text match.
4257fn row_id_predicate(table: &TableSchema) -> String {
4258    let key = quote_ident(&table.primary_key);
4259    let text_match = format!("CAST({key} AS TEXT) = ?1");
4260    match table.columns[table.pk_index].ty {
4261        ColumnType::String | ColumnType::Json => {
4262            format!("{key} = ?1 AND {text_match}")
4263        }
4264        // Native columns have no declared SQLite affinity. Match the stored
4265        // integer bind type explicitly. Unary + removes expression affinity so
4266        // SQLite can seek the typeless key; the text check preserves exact IDs.
4267        ColumnType::Integer | ColumnType::Boolean => {
4268            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
4269        }
4270        // REAL-to-text conversion can round distinct values to the same text.
4271        // Narrowing those matches by numeric equality would change behavior.
4272        _ => text_match,
4273    }
4274}
4275
4276fn is_local_operation_code_like(value: &str) -> bool {
4277    let bytes = value.as_bytes();
4278    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
4279        && bytes
4280            .iter()
4281            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
4282}
4283
4284fn base_table(name: &str) -> String {
4285    quote_ident(&format!("_syncular_base_{name}"))
4286}
4287
4288/// §4.8: a stable, server-opaque key for a window base — table + variable +
4289/// canonical fixed scopes. Two `set_window` calls with the same base
4290/// address the same registry rows.
4291/// §4.8 deferred eviction record: (sub id, table, effective scope map).
4292type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
4293
4294fn window_base_key(base: &WindowBase) -> String {
4295    format!(
4296        "{}\0{}\0{}",
4297        base.table,
4298        base.variable,
4299        canonical_scope_json(&base.fixed_scopes)
4300    )
4301}
4302
4303/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
4304fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
4305    let mut scopes = base.fixed_scopes.clone();
4306    scopes.retain(|(k, _)| k != &base.variable);
4307    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
4308    scopes
4309}
4310
4311/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
4312/// echoed not interpreted by the server, so the exact hash is client
4313/// convention; SHA-256 matches the SPEC's worked example.
4314fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
4315    let canonical = canonical_scope_json(&unit_scopes(base, unit));
4316    let digest = Sha256::digest(canonical.as_bytes());
4317    let hex = bytes_to_hex(&digest);
4318    format!("w:{}:{}", base.table, &hex[..16])
4319}
4320
4321fn visible_table(name: &str) -> String {
4322    quote_ident(name)
4323}
4324
4325const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
4326
4327/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
4328/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
4329fn is_synced_table_name(name: &str) -> bool {
4330    if name.starts_with("sqlite_") {
4331        return false;
4332    }
4333    if name.starts_with("_syncular_base_") {
4334        return true; // the base half of a synced table pair
4335    }
4336    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
4337    !name.starts_with("_syncular_")
4338}
4339
4340/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
4341fn blob_id_for(bytes: &[u8]) -> String {
4342    let digest = Sha256::digest(bytes);
4343    format!("sha256:{}", bytes_to_hex(&digest))
4344}
4345
4346/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
4347/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
4348/// per row on the §5.6 bootstrap path), scalars bind owned.
4349enum RowParam<'a> {
4350    Cell(&'a Option<ColumnValue>),
4351    Version(i64),
4352}
4353
4354impl rusqlite::ToSql for RowParam<'_> {
4355    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
4356        Ok(match self {
4357            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
4358            RowParam::Cell(cell) => match cell {
4359                None => ToSqlOutput::Owned(SqlValue::Null),
4360                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
4361                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
4362                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
4363                Some(ColumnValue::Boolean(b)) => {
4364                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
4365                }
4366                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
4367                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
4368                }
4369                // §5.10: crdt bytes store as BLOB, like bytes.
4370                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
4371                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
4372                }
4373            },
4374        })
4375    }
4376}
4377
4378// Retain a typed primary key across the current import chunk without copying row payloads.
4379fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
4380    match param.to_sql().map_err(|error| error.to_string())? {
4381        ToSqlOutput::Owned(value) => Ok(value),
4382        ToSqlOutput::Borrowed(value) => Ok(value.into()),
4383        _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
4384    }
4385}
4386
4387/// §5.3 image cell → bind parameter, strict per the declared column type
4388/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
4389/// nullable). Mismatches are image-producer violations. Returns the cell
4390/// borrowed when the stored representation already matches what the row
4391/// codec would write, or the normalized scalar (boolean → 0/1, float from
4392/// INTEGER → REAL) otherwise — the local write is byte-identical to the
4393/// old convert-then-insert path without allocating per cell.
4394fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
4395    use ssp2::segment::ColumnType;
4396    let mismatch = || {
4397        Err(format!(
4398            "image column {:?} holds a value of the wrong type",
4399            column.name
4400        ))
4401    };
4402    match value {
4403        ValueRef::Null => {
4404            if !column.nullable {
4405                return Err(format!(
4406                    "image column {:?} is NULL but not nullable",
4407                    column.name
4408                ));
4409            }
4410            Ok(ToSqlOutput::Owned(SqlValue::Null))
4411        }
4412        ValueRef::Integer(i) => match column.ty {
4413            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
4414            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
4415            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
4416            _ => mismatch(),
4417        },
4418        ValueRef::Real(_) => match column.ty {
4419            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
4420            _ => mismatch(),
4421        },
4422        ValueRef::Text(t) => {
4423            std::str::from_utf8(t)
4424                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
4425            match column.ty {
4426                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
4427                    Ok(ToSqlOutput::Borrowed(value))
4428                }
4429                _ => mismatch(),
4430            }
4431        }
4432        ValueRef::Blob(_) => match column.ty {
4433            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
4434            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
4435            _ => mismatch(),
4436        },
4437    }
4438}
4439
4440fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
4441    use rusqlite::types::ValueRef;
4442    match value {
4443        ValueRef::Null => Value::Null,
4444        ValueRef::Integer(i) => match column.ty {
4445            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
4446            ssp2::segment::ColumnType::Float => {
4447                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
4448            }
4449            _ => Value::from(i),
4450        },
4451        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
4452        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
4453        ValueRef::Blob(b) => {
4454            let mut map = Map::new();
4455            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
4456            Value::Object(map)
4457        }
4458    }
4459}
4460
4461/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
4462/// Objects are accepted in lossless `{"$bytes": hex}` and
4463/// `{"$bigint": decimal}` envelope forms.
4464fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
4465    Ok(match value {
4466        Value::Null => SqlValue::Null,
4467        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
4468        Value::Number(n) => {
4469            if let Some(i) = n.as_i64() {
4470                SqlValue::Integer(i)
4471            } else if let Some(f) = n.as_f64() {
4472                SqlValue::Real(f)
4473            } else {
4474                return Err(format!("query param number {n} is out of range"));
4475            }
4476        }
4477        Value::String(s) => SqlValue::Text(s.clone()),
4478        Value::Object(_) => {
4479            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
4480                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
4481            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
4482                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
4483                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
4484                })?)
4485            } else {
4486                return Err(
4487                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
4488                        .to_owned(),
4489                );
4490            }
4491        }
4492        Value::Array(_) => return Err("query array params are not supported".to_owned()),
4493    })
4494}
4495
4496/// Map a rusqlite value with no schema column to consult (arbitrary query
4497/// output): integers/reals/text pass through by stored affinity, blobs ride
4498/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
4499/// schema column type to recover booleans/floats/json.
4500fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
4501    use rusqlite::types::ValueRef;
4502    match value {
4503        ValueRef::Null => Value::Null,
4504        ValueRef::Integer(i) => {
4505            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
4506            // ordinary UI-sized integers ergonomic and envelope only values
4507            // beyond JavaScript's safe range.
4508            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
4509            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
4510                Value::from(i)
4511            } else {
4512                let mut map = Map::new();
4513                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
4514                Value::Object(map)
4515            }
4516        }
4517        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
4518        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
4519        ValueRef::Blob(b) => {
4520            let mut map = Map::new();
4521            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
4522            Value::Object(map)
4523        }
4524    }
4525}
4526
4527fn query_connection(
4528    conn: &Connection,
4529    sql: &str,
4530    params: &[QueryValue],
4531) -> Result<Vec<QueryRow>, String> {
4532    crate::query_guard::assert_read_only_query(sql)?;
4533    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
4534    let bound: Vec<SqlValue> = params
4535        .iter()
4536        .map(json_param_to_sql)
4537        .collect::<Result<_, _>>()?;
4538    let mut stmt = conn.prepare(&lowered_sql).map_err(|e| e.to_string())?;
4539    let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
4540    let bound_refs: Vec<&dyn rusqlite::ToSql> =
4541        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
4542    let mut sql_rows = stmt
4543        .query(bound_refs.as_slice())
4544        .map_err(|e| e.to_string())?;
4545    let mut out = Vec::new();
4546    while let Some(row) = sql_rows.next().map_err(|e| e.to_string())? {
4547        let mut record = Map::new();
4548        for (i, name) in column_names.iter().enumerate() {
4549            let value = row.get_ref(i).map_err(|e| e.to_string())?;
4550            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
4551        }
4552        out.push(record);
4553    }
4554    Ok(out)
4555}
4556
4557fn persisted_window_state(conn: &Connection, base: &WindowBase) -> Result<WindowState, String> {
4558    let mut stmt = conn
4559        .prepare(
4560            "SELECT windows.unit, subscriptions.state_json
4561               FROM _syncular_windows AS windows
4562               JOIN _syncular_subscriptions AS subscriptions
4563                 ON subscriptions.id = windows.sub_id
4564              WHERE windows.base = ?1
4565              ORDER BY windows.unit ASC",
4566        )
4567        .map_err(|error| error.to_string())?;
4568    let rows = stmt
4569        .query_map(rusqlite::params![window_base_key(base)], |row| {
4570            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
4571        })
4572        .map_err(|error| error.to_string())?;
4573    let mut units = Vec::new();
4574    let mut pending = Vec::new();
4575    for row in rows {
4576        let (unit, raw) = row.map_err(|error| error.to_string())?;
4577        let state: Value = serde_json::from_str(&raw)
4578            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
4579        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
4580            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
4581            || state
4582                .get("bootstrapState")
4583                .is_some_and(|value| !value.is_null());
4584        if is_pending {
4585            pending.push(unit.clone());
4586        }
4587        units.push(unit);
4588    }
4589    Ok(WindowState { units, pending })
4590}
4591
4592fn snapshot_connection(
4593    conn: &Connection,
4594    sql: &str,
4595    params: &[Value],
4596    coverage: &[WindowCoverage],
4597) -> Result<QuerySnapshot, String> {
4598    conn.execute_batch("SAVEPOINT syncular_snapshot_read")
4599        .map_err(|error| error.to_string())?;
4600    let result = (|| {
4601        let revision = conn
4602            .query_row(
4603                "SELECT value FROM _syncular_meta WHERE key = ?1",
4604                rusqlite::params![LOCAL_REVISION_KEY],
4605                |row| row.get::<_, String>(0),
4606            )
4607            .ok()
4608            .and_then(|value| value.parse::<u64>().ok())
4609            .unwrap_or(0);
4610        let rows = query_connection(conn, sql, params)?;
4611        let mut pending = Vec::new();
4612        let mut missing = Vec::new();
4613        for requested in coverage {
4614            let base_key = window_base_key(&requested.base);
4615            let state = persisted_window_state(conn, &requested.base)?;
4616            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
4617                let reference = WindowUnitRef {
4618                    base_key: base_key.clone(),
4619                    unit: unit.clone(),
4620                };
4621                if !state.units.iter().any(|held| held == &unit) {
4622                    missing.push(reference);
4623                } else if state.pending.iter().any(|held| held == &unit) {
4624                    pending.push(reference);
4625                }
4626            }
4627        }
4628        Ok(QuerySnapshot {
4629            revision: revision.to_string(),
4630            rows,
4631            coverage: CoverageSnapshot {
4632                complete: pending.is_empty() && missing.is_empty(),
4633                pending,
4634                missing,
4635            },
4636        })
4637    })();
4638    match result {
4639        Ok(snapshot) => {
4640            conn.execute_batch("RELEASE syncular_snapshot_read")
4641                .map_err(|error| error.to_string())?;
4642            Ok(snapshot)
4643        }
4644        Err(error) => {
4645            let _ = conn.execute_batch(
4646                "ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read",
4647            );
4648            Err(error)
4649        }
4650    }
4651}
4652
4653/// A long-lived read-only SQLite sidecar for latency-critical native views.
4654/// Network rounds stay serialized on the mutable core owner, while atomic
4655/// query snapshots use this independent connection and therefore never queue
4656/// behind HTTP/WebSocket latency.
4657pub struct FileQuerySnapshotReader {
4658    path: String,
4659    conn: Option<Connection>,
4660}
4661
4662impl FileQuerySnapshotReader {
4663    #[must_use]
4664    pub fn new(path: impl Into<String>) -> Self {
4665        Self {
4666            path: path.into(),
4667            conn: None,
4668        }
4669    }
4670
4671    fn connection(&mut self) -> Result<&Connection, String> {
4672        if self.conn.is_none() {
4673            let conn = Connection::open_with_flags(
4674                &self.path,
4675                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
4676            )
4677            .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
4678            conn.busy_timeout(std::time::Duration::from_millis(250))
4679                .map_err(|error| error.to_string())?;
4680            self.conn = Some(conn);
4681        }
4682        self.conn
4683            .as_ref()
4684            .ok_or_else(|| "read sidecar connection missing".to_owned())
4685    }
4686
4687    pub fn query_snapshot(
4688        &mut self,
4689        sql: &str,
4690        params: &[Value],
4691        coverage: &[WindowCoverage],
4692    ) -> Result<QuerySnapshot, String> {
4693        snapshot_connection(self.connection()?, sql, params, coverage)
4694    }
4695}
4696
4697impl SyncClient {
4698    pub fn new_with_identity(
4699        client_id: Option<String>,
4700        schema_json: &Value,
4701        limits: ClientLimits,
4702    ) -> Result<Self, String> {
4703        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
4704        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
4705        Self::with_connection(resolved, schema_json, limits, conn)
4706    }
4707
4708    pub fn new(
4709        client_id: String,
4710        schema_json: &Value,
4711        limits: ClientLimits,
4712    ) -> Result<Self, String> {
4713        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
4714        Self::with_connection(client_id, schema_json, limits, conn)
4715    }
4716
4717    /// Build a client backed by an on-disk SQLite database at `path` — the
4718    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
4719    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
4720    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
4721    /// of the command router's dependency set (the router only holds a path).
4722    pub fn open_path(
4723        client_id: String,
4724        schema_json: &Value,
4725        limits: ClientLimits,
4726        path: &str,
4727    ) -> Result<Self, String> {
4728        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
4729        Self::with_connection(client_id, schema_json, limits, conn)
4730    }
4731
4732    pub fn open_path_with_identity(
4733        client_id: Option<String>,
4734        schema_json: &Value,
4735        limits: ClientLimits,
4736        path: &str,
4737    ) -> Result<Self, String> {
4738        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
4739        // File-backed native clients use an independent read connection for
4740        // latency-critical snapshots. WAL is SQLite's intended reader/writer
4741        // concurrency mode: a view read never holds a rollback-journal lock
4742        // that delays the mutable client's next commit, and a short busy
4743        // timeout absorbs the tiny checkpoint/schema-lock windows.
4744        conn.busy_timeout(std::time::Duration::from_millis(250))
4745            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
4746        conn.pragma_update(None, "journal_mode", "WAL")
4747            .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
4748        let persisted = conn
4749            .query_row(
4750                "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
4751                [],
4752                |row| row.get::<_, String>(0),
4753            )
4754            .ok();
4755        let resolved = persisted
4756            .clone()
4757            .or(client_id.clone())
4758            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
4759        if let (Some(existing), Some(requested)) = (persisted, client_id) {
4760            if existing != requested {
4761                return Err(format!(
4762                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
4763                ));
4764            }
4765        }
4766        Self::with_connection(resolved, schema_json, limits, conn)
4767    }
4768
4769    #[must_use]
4770    pub fn client_id(&self) -> &str {
4771        &self.client_id
4772    }
4773
4774    /// Build a client over a caller-supplied rusqlite connection — the seam a
4775    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
4776    /// with an on-disk database (`Connection::open(path)`) rather than the
4777    /// default `:memory:`. The connection MUST be fresh (no pre-existing
4778    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
4779    /// the same file across process restarts reuses the persisted rows.
4780    pub fn with_connection(
4781        client_id: String,
4782        schema_json: &Value,
4783        limits: ClientLimits,
4784        conn: Connection,
4785    ) -> Result<Self, String> {
4786        if limits.outcome_retention_max_entries == Some(0) {
4787            return Err(
4788                "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
4789            );
4790        }
4791        let schema = parse_schema_json(schema_json)?;
4792        let mut client = SyncClient {
4793            #[cfg(feature = "bench-internals")]
4794            benchmark_phases: Recorder::default(),
4795            conn,
4796            schema,
4797            client_id,
4798            limits,
4799            subs: Vec::new(),
4800            outbox: Vec::new(),
4801            conflicts: Vec::new(),
4802            rejections: Vec::new(),
4803            schema_floor: None,
4804            lease_state: None,
4805            stopped: false,
4806            upgrading: false,
4807            sync_needed: false,
4808            realtime_connected: false,
4809            presence: HashMap::new(),
4810            now_ms: None,
4811            encryption: crate::values::EncryptionConfig::default(),
4812            security_preflight: false,
4813            insert_sql: RefCell::new(HashMap::new()),
4814            overlay_dirty: Cell::new(false),
4815            #[cfg(test)]
4816            overlay_rebuild_count: Cell::new(0),
4817            #[cfg(test)]
4818            outcome_prune_count: Cell::new(0),
4819            progress: ProgressObserver::default(),
4820            change_queue: VecDeque::new(),
4821            sync_intent_queue: VecDeque::new(),
4822            retry_delay_ms: 250,
4823            last_round: None,
4824            last_change: None,
4825        };
4826        // The row write path leans on the prepared-statement cache (two
4827        // insert statements per synced table, plus the bookkeeping
4828        // statements); size it so a multi-table schema never thrashes.
4829        client
4830            .conn
4831            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
4832        // Protected bookkeeping must exist before inspecting the persisted
4833        // schema marker. New app indexes may reference columns that only
4834        // exist after a version-bump reset.
4835        client.create_bookkeeping_tables()?;
4836        match client.get_meta(CLIENT_ID_KEY) {
4837            Some(existing) if existing != client.client_id => {
4838                return Err(format!(
4839                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
4840                    client.client_id
4841                ));
4842            }
4843            None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
4844            _ => {}
4845        }
4846        client.restore_persisted_state()?;
4847        let marker = client
4848            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
4849            .and_then(|value| value.parse::<i32>().ok());
4850        match marker {
4851            None => {
4852                client.create_synced_tables()?;
4853                client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
4854            }
4855            Some(version) if version == client.schema.version => {
4856                client.create_synced_tables()?;
4857            }
4858            Some(_) => client.run_schema_reset()?,
4859        }
4860        client.clear_satisfied_persisted_schema_floor();
4861        client.prune_unknown_subscriptions()?;
4862        if marker == Some(client.schema.version) && !client.outbox.is_empty() {
4863            // Reconstruct the visible optimistic overlay from the durable base
4864            // plus outbox instead of trusting a process-interrupted mirror.
4865            client.overlay_dirty.set(true);
4866            client.rebuild_overlay();
4867        }
4868        // Every persisted active subscription needs one catch-up pull on open:
4869        // realtime only covers changes after connection, while an idempotent
4870        // setWindow correctly creates no fresh command effect. Pending outbox
4871        // work has the same restart requirement. The core owns this intent so
4872        // native hosts never poll or require an application-issued sync().
4873        client.enqueue_startup_sync_if_needed()?;
4874        Ok(client)
4875    }
4876
4877    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
4878    /// against this instead of system time (conformance virtual clock).
4879    pub fn set_now_ms(&mut self, now_ms: i64) {
4880        self.now_ms = Some(now_ms);
4881    }
4882
4883    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
4884    /// The command router parses these from the `create` command's
4885    /// `encryption` config (keys as `{$bytes: hex}`).
4886    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
4887        self.encryption = encryption;
4888    }
4889
4890    #[must_use]
4891    pub fn security_lifecycle(&self) -> &'static str {
4892        if self.security_preflight {
4893            "preflight"
4894        } else {
4895            "active"
4896        }
4897    }
4898
4899    #[must_use]
4900    pub fn security_preflight(&self) -> bool {
4901        self.security_preflight
4902    }
4903
4904    /// Quarantine this replica: enter the fail-closed gate, release all
4905    /// core-owned key material, and record the gate in the database so
4906    /// reopening re-enters preflight until `activate_security` clears it.
4907    ///
4908    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
4909    /// which must NOT leave that durable mark.
4910    pub fn begin_security_preflight(&mut self) {
4911        self.seal_security_on_teardown();
4912        // Persist the quarantine so it survives handle teardown and restart:
4913        // reopening this replica re-enters preflight until activation clears it.
4914        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
4915    }
4916
4917    /// Release core-owned key material as a host tears the client down,
4918    /// WITHOUT recording a quarantine.
4919    ///
4920    /// Shutting an activated client down is not a quarantine event. Persisting
4921    /// the gate here marks every cleanly closed replica as pending, and the
4922    /// next plain `create` is then refused permanently: the reopen path
4923    /// restores the flag from the marker and the create guard rejects it. The
4924    /// in-memory flag still closes the gate for anything still holding this
4925    /// instance, which is all a teardown barrier needs.
4926    pub fn seal_security_on_teardown(&mut self) {
4927        self.security_preflight = true;
4928        self.encryption = crate::values::EncryptionConfig::default();
4929        self.sync_intent_queue.clear();
4930    }
4931
4932    /// Install the post-authentication keyring and release the host loop.
4933    pub fn activate_security(
4934        &mut self,
4935        encryption: crate::values::EncryptionConfig,
4936    ) -> Result<(), String> {
4937        if !self.security_preflight {
4938            return Err(
4939                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
4940            );
4941        }
4942        self.enqueue_startup_sync_if_needed()?;
4943        self.encryption = encryption;
4944        self.security_preflight = false;
4945        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
4946        Ok(())
4947    }
4948
4949    fn clock_now_ms(&self) -> i64 {
4950        self.now_ms.unwrap_or_else(|| {
4951            std::time::SystemTime::now()
4952                .duration_since(std::time::UNIX_EPOCH)
4953                .map(|d| d.as_millis() as i64)
4954                .unwrap_or(0)
4955        })
4956    }
4957
4958    fn create_bookkeeping_tables(&self) -> Result<(), String> {
4959        // Durable client bookkeeping (outbox + subscription + meta).
4960        self.conn
4961            .execute_batch(
4962                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
4963                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
4964                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
4965                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
4966                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
4967                   client_commit_id TEXT NOT NULL UNIQUE,
4968                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
4969                   recorded_at_ms INTEGER NOT NULL,
4970                   results_json TEXT NOT NULL,
4971                   operations_json TEXT,
4972                   resolution TEXT NOT NULL DEFAULT 'active'
4973                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
4974                   resolved_at_ms INTEGER,
4975                   replacement_client_commit_id TEXT);
4976                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
4977                   ON _syncular_commit_outcomes(resolution, seq);
4978                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
4979                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
4980                 CREATE TABLE IF NOT EXISTS _syncular_meta (
4981                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
4982                 CREATE TABLE IF NOT EXISTS _syncular_windows (
4983                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
4984                   PRIMARY KEY (base, unit));
4985                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
4986                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
4987                   effective_scopes TEXT NOT NULL);
4988                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
4989                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
4990                   PRIMARY KEY(commit_id, blob_id));
4991                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
4992                   ON _syncular_blob_commit_refs(blob_id);",
4993            )
4994            .map_err(|e| e.to_string())?;
4995        // Migrate an outcome journal created before failed aggregate
4996        // envelopes were retained. Historical rows intentionally stay NULL.
4997        let _ = self
4998            .conn
4999            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
5000        if self.get_meta(LOCAL_REVISION_KEY).is_none() {
5001            self.set_meta(LOCAL_REVISION_KEY, "0");
5002        }
5003        // §5.9.7 blob cache. Mutable upload state stays off the body row so
5004        // completing a large upload does not rewrite its BLOB record.
5005        if self.schema_has_blobs() {
5006            let exists = self
5007                .conn
5008                .query_row(
5009                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
5010                    [],
5011                    |row| row.get::<_, bool>(0),
5012                )
5013                .map_err(|e| e.to_string())?;
5014            if exists {
5015                let mut statement = self
5016                    .conn
5017                    .prepare("PRAGMA table_info(_syncular_blobs)")
5018                    .map_err(|e| e.to_string())?;
5019                let mut columns = statement
5020                    .query_map([], |row| row.get::<_, String>(1))
5021                    .map_err(|e| e.to_string())?
5022                    .collect::<Result<Vec<_>, _>>()
5023                    .map_err(|e| e.to_string())?;
5024                columns.sort();
5025                let mut expected = vec![
5026                    "blob_id",
5027                    "byte_length",
5028                    "bytes",
5029                    "created_at_ms",
5030                    "media_type",
5031                ];
5032                expected.sort_unstable();
5033                if columns != expected {
5034                    return Err(
5035                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
5036                    );
5037                }
5038            } else {
5039                self.conn
5040                    .execute_batch(
5041                        "CREATE TABLE _syncular_blobs(
5042                           blob_id TEXT PRIMARY KEY,
5043                           bytes BLOB NOT NULL,
5044                           byte_length INTEGER NOT NULL,
5045                           media_type TEXT,
5046                           created_at_ms INTEGER NOT NULL);",
5047                    )
5048                    .map_err(|e| e.to_string())?;
5049            }
5050            self.conn
5051                .execute_batch(
5052                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
5053                       blob_id TEXT PRIMARY KEY,
5054                       media_type TEXT,
5055                       created_at_ms INTEGER NOT NULL);",
5056                )
5057                .map_err(|e| e.to_string())?;
5058        }
5059        Ok(())
5060    }
5061
5062    /// True iff any synced table declares a `blob_ref` column (§5.9).
5063    fn schema_has_blobs(&self) -> bool {
5064        self.schema
5065            .tables
5066            .iter()
5067            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
5068    }
5069
5070    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
5071
5072    fn get_meta(&self, key: &str) -> Option<String> {
5073        self.conn
5074            .query_row(
5075                "SELECT value FROM _syncular_meta WHERE key = ?1",
5076                rusqlite::params![key],
5077                |row| row.get::<_, String>(0),
5078            )
5079            .ok()
5080    }
5081
5082    fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
5083        self.conn
5084            .query_row(
5085                "SELECT value FROM _syncular_meta WHERE key = ?1",
5086                rusqlite::params![key],
5087                |row| row.get::<_, String>(0),
5088            )
5089            .optional()
5090            .map_err(|_| {
5091                "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
5092            })
5093    }
5094
5095    fn set_meta(&self, key: &str, value: &str) {
5096        let _ = self.conn.execute(
5097            "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
5098            rusqlite::params![key, value],
5099        );
5100    }
5101
5102    fn delete_meta(&self, key: &str) {
5103        let _ = self.conn.execute(
5104            "DELETE FROM _syncular_meta WHERE key = ?1",
5105            rusqlite::params![key],
5106        );
5107    }
5108
5109    fn restore_persisted_state(&mut self) -> Result<(), String> {
5110        self.subs = {
5111            let mut stmt = self
5112                .conn
5113                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
5114                .map_err(|error| error.to_string())?;
5115            let rows = stmt
5116                .query_map([], |row| {
5117                    Ok((
5118                        row.get::<_, String>(0)?,
5119                        row.get::<_, String>(1)?,
5120                        row.get::<_, String>(2)?,
5121                    ))
5122                })
5123                .map_err(|error| error.to_string())?;
5124            let mut subscriptions = Vec::new();
5125            for row in rows {
5126                let (id, table, raw) = row.map_err(|error| error.to_string())?;
5127                let state: Value = serde_json::from_str(&raw)
5128                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
5129                let requested = json_to_scope_map(
5130                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
5131                )?;
5132                let effective = state
5133                    .get("effectiveScopes")
5134                    .filter(|value| !value.is_null())
5135                    .map(json_to_scope_map)
5136                    .transpose()?;
5137                subscriptions.push(Subscription {
5138                    id,
5139                    table,
5140                    requested,
5141                    params: state
5142                        .get("params")
5143                        .and_then(Value::as_str)
5144                        .map(str::to_owned),
5145                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
5146                    bootstrap_state: state
5147                        .get("bootstrapState")
5148                        .and_then(Value::as_str)
5149                        .map(str::to_owned),
5150                    state: SubState::parse(
5151                        state
5152                            .get("status")
5153                            .and_then(Value::as_str)
5154                            .unwrap_or("active"),
5155                    ),
5156                    reason_code: state
5157                        .get("reasonCode")
5158                        .and_then(Value::as_str)
5159                        .map(str::to_owned),
5160                    effective,
5161                    synced_once: state
5162                        .get("syncedOnce")
5163                        .and_then(Value::as_bool)
5164                        .unwrap_or(false),
5165                });
5166            }
5167            subscriptions
5168        };
5169
5170        self.outbox = {
5171            let mut stmt = self
5172                .conn
5173                .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
5174                .map_err(|error| error.to_string())?;
5175            let rows = stmt
5176                .query_map([], |row| {
5177                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5178                })
5179                .map_err(|error| error.to_string())?;
5180            let mut commits = Vec::new();
5181            for row in rows {
5182                let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
5183                let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
5184                    format!("invalid persisted outbox {client_commit_id:?}: {error}")
5185                })?;
5186                let mut ops = Vec::with_capacity(entries.len());
5187                for entry in entries {
5188                    let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
5189                    ops.push(OutboxOp {
5190                        upsert: op == "upsert",
5191                        table: entry
5192                            .get("table")
5193                            .and_then(Value::as_str)
5194                            .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
5195                            .to_owned(),
5196                        row_id: entry
5197                            .get("rowId")
5198                            .and_then(Value::as_str)
5199                            .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
5200                            .to_owned(),
5201                        base_version: entry.get("baseVersion").and_then(Value::as_i64),
5202                        // A legacy entry's `changedFields` key is ignored:
5203                        // the values map is the presence set (§6.1).
5204                        values: entry.get("values").and_then(Value::as_object).cloned(),
5205                    });
5206                }
5207                commits.push(OutboxCommit {
5208                    client_commit_id,
5209                    ops,
5210                });
5211            }
5212            commits
5213        };
5214
5215        self.prune_commit_outcomes()?;
5216        let active = self.commit_outcomes(CommitOutcomeQuery {
5217            active_only: true,
5218            ..CommitOutcomeQuery::default()
5219        })?;
5220        self.conflicts = active
5221            .iter()
5222            .flat_map(|outcome| outcome.results.iter())
5223            .filter_map(|result| match result {
5224                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
5225                _ => None,
5226            })
5227            .collect();
5228        self.rejections = active
5229            .iter()
5230            .flat_map(|outcome| outcome.results.iter())
5231            .filter_map(|result| match result {
5232                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
5233                _ => None,
5234            })
5235            .collect();
5236
5237        self.lease_state = self
5238            .get_meta(LEASE_STATE_KEY)
5239            .map(|raw| serde_json::from_str(&raw))
5240            .transpose()
5241            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
5242        self.schema_floor = self
5243            .get_meta(SCHEMA_FLOOR_KEY)
5244            .map(|raw| serde_json::from_str(&raw))
5245            .transpose()
5246            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
5247        self.stopped = self.schema_floor.is_some();
5248        // A replica left in unactivated preflight reopens gated: the quarantine
5249        // decision persists with the data across handle teardown and restart.
5250        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
5251            self.security_preflight = true;
5252        }
5253        Ok(())
5254    }
5255
5256    /// Remove registrations for tables the running schema no longer knows.
5257    /// Otherwise the server rejects every pull with `sync.unknown_table`.
5258    fn prune_unknown_subscriptions(&mut self) -> Result<(), String> {
5259        let valid_tables: BTreeSet<String> = self
5260            .schema
5261            .tables
5262            .iter()
5263            .map(|table| table.name.clone())
5264            .collect();
5265        let stale_ids: Vec<String> = self
5266            .subs
5267            .iter()
5268            .filter(|sub| !valid_tables.contains(&sub.table))
5269            .map(|sub| sub.id.clone())
5270            .collect();
5271        for id in &stale_ids {
5272            self.conn
5273                .execute(
5274                    "DELETE FROM _syncular_windows WHERE sub_id = ?1",
5275                    rusqlite::params![id],
5276                )
5277                .map_err(|error| error.to_string())?;
5278            self.conn
5279                .execute(
5280                    "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
5281                    rusqlite::params![id],
5282                )
5283                .map_err(|error| error.to_string())?;
5284            self.conn
5285                .execute(
5286                    "DELETE FROM _syncular_subscriptions WHERE id = ?1",
5287                    rusqlite::params![id],
5288                )
5289                .map_err(|error| error.to_string())?;
5290        }
5291        self.subs.retain(|sub| valid_tables.contains(&sub.table));
5292        Ok(())
5293    }
5294
5295    #[must_use]
5296    pub fn local_revision(&self) -> u64 {
5297        self.get_meta(LOCAL_REVISION_KEY)
5298            .and_then(|value| value.parse().ok())
5299            .unwrap_or(0)
5300    }
5301
5302    #[must_use]
5303    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
5304        SyncStatusSnapshot {
5305            current_schema_version: self.schema.version,
5306            outbox: self.outbox.len(),
5307            upgrading: self.upgrading,
5308            lease_state: self.lease_state.clone(),
5309            schema_floor: self.schema_floor.clone(),
5310            sync_needed: self.sync_needed,
5311        }
5312    }
5313
5314    pub fn diagnostics_snapshot(
5315        &self,
5316        request: &ClientDiagnosticsRequest,
5317    ) -> Result<ClientDiagnosticsSnapshot, String> {
5318        if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
5319            return Err(format!(
5320                "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
5321            ));
5322        }
5323        let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
5324        for sub in &self.subs {
5325            let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
5326            let complete =
5327                sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
5328            let state = match sub.state {
5329                SubState::Revoked => "revoked",
5330                SubState::Failed => "failed",
5331                SubState::Active if reset => "reset",
5332                SubState::Active if complete => "complete",
5333                SubState::Active => "bootstrapping",
5334            };
5335            subscriptions.insert(
5336                sub.id.clone(),
5337                DiagnosticSubscription {
5338                    id: sub.id.clone(),
5339                    table: sub.table.clone(),
5340                    state: state.to_owned(),
5341                    complete,
5342                    cursor: Some(sub.cursor),
5343                    reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
5344                },
5345            );
5346        }
5347        for expected in &request.expected_subscriptions {
5348            if expected.id.is_empty() || expected.table.is_empty() {
5349                return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
5350            }
5351            if subscriptions
5352                .get(&expected.id)
5353                .is_some_and(|registered| registered.table != expected.table)
5354            {
5355                subscriptions.insert(
5356                    expected.id.clone(),
5357                    DiagnosticSubscription {
5358                        id: expected.id.clone(),
5359                        table: expected.table.clone(),
5360                        state: "failed".to_owned(),
5361                        complete: false,
5362                        cursor: None,
5363                        reason_code: Some("client.subscription_intent_mismatch".to_owned()),
5364                    },
5365                );
5366            } else {
5367                subscriptions.entry(expected.id.clone()).or_insert_with(|| {
5368                    DiagnosticSubscription {
5369                        id: expected.id.clone(),
5370                        table: expected.table.clone(),
5371                        state: "unregistered".to_owned(),
5372                        complete: false,
5373                        cursor: None,
5374                        reason_code: None,
5375                    }
5376                });
5377            }
5378        }
5379        let mut ordered_subscriptions = Vec::new();
5380        let mut included = BTreeSet::new();
5381        for expected in &request.expected_subscriptions {
5382            if included.insert(expected.id.clone()) {
5383                if let Some(subscription) = subscriptions.get(&expected.id) {
5384                    ordered_subscriptions.push(subscription.clone());
5385                }
5386            }
5387        }
5388        for (id, subscription) in &subscriptions {
5389            if included.insert(id.clone()) {
5390                ordered_subscriptions.push(subscription.clone());
5391            }
5392        }
5393        let subscriptions_truncated =
5394            ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
5395        ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
5396        let captured_at_ms = self.clock_now_ms();
5397        let lease = if let Some(error_code) = self
5398            .lease_state
5399            .as_ref()
5400            .and_then(|state| state.error_code.clone())
5401        {
5402            ClientDiagnosticsLease {
5403                state: "stopped".to_owned(),
5404                expires_at_ms: self
5405                    .lease_state
5406                    .as_ref()
5407                    .and_then(|state| state.expires_at_ms),
5408                error_code: Some(Self::diagnostic_code(&error_code)),
5409            }
5410        } else if let Some(expires_at_ms) = self
5411            .lease_state
5412            .as_ref()
5413            .and_then(|state| state.expires_at_ms)
5414        {
5415            ClientDiagnosticsLease {
5416                state: if expires_at_ms <= captured_at_ms {
5417                    "expired".to_owned()
5418                } else {
5419                    "active".to_owned()
5420                },
5421                expires_at_ms: Some(expires_at_ms),
5422                error_code: None,
5423            }
5424        } else {
5425            ClientDiagnosticsLease {
5426                state: "none".to_owned(),
5427                expires_at_ms: None,
5428                error_code: None,
5429            }
5430        };
5431        let connectivity = match self.last_round.as_ref() {
5432            Some(round) if round.status == "succeeded" => "online",
5433            Some(round)
5434                if round.status == "failed"
5435                    && round
5436                        .error_code
5437                        .as_deref()
5438                        .is_some_and(Self::retryable_transport_code) =>
5439            {
5440                "offline"
5441            }
5442            _ => "unknown",
5443        };
5444        Ok(ClientDiagnosticsSnapshot {
5445            version: CLIENT_DIAGNOSTICS_VERSION,
5446            captured_at_ms,
5447            host: ClientDiagnosticsHost {
5448                kind: "direct".to_owned(),
5449                role: "single".to_owned(),
5450                connectivity: connectivity.to_owned(),
5451                realtime: if self.realtime_connected {
5452                    "connected".to_owned()
5453                } else {
5454                    "disconnected".to_owned()
5455                },
5456            },
5457            security_lifecycle: self.security_lifecycle().to_owned(),
5458            schema: ClientDiagnosticsSchema {
5459                current_version: self.schema.version,
5460                upgrading: self.upgrading,
5461                required_version: self
5462                    .schema_floor
5463                    .as_ref()
5464                    .and_then(|floor| floor.required_schema_version),
5465                latest_version: self
5466                    .schema_floor
5467                    .as_ref()
5468                    .and_then(|floor| floor.latest_schema_version),
5469            },
5470            replica: ClientDiagnosticsReplica {
5471                local_revision: self.local_revision().to_string(),
5472                sync_needed: self.sync_needed,
5473                pending_outbox: self.outbox.len(),
5474            },
5475            lease,
5476            subscriptions: ordered_subscriptions,
5477            subscriptions_truncated,
5478            last_round: self.last_round.clone(),
5479            last_change: self.last_change.clone(),
5480            storage: self.diagnostics_storage(),
5481        })
5482    }
5483
5484    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
5485        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
5486            let page_count: i64 = self
5487                .conn
5488                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
5489            let page_size: i64 = self
5490                .conn
5491                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
5492            // Reuse compiled aggregates; every invocation still reads current storage.
5493            let outbox_bytes: i64 = self
5494                .conn
5495                .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
5496                .query_row([], |row| row.get(0))?;
5497            let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
5498                "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
5499            )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
5500            let blob_bytes = if self.schema_has_blobs() {
5501                self.conn
5502                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
5503                    .query_row([], |row| row.get(0))?
5504            } else {
5505                0
5506            };
5507            let pressure = self
5508                .limits
5509                .blob_cache_max_bytes
5510                .is_some_and(|limit| blob_bytes > limit);
5511            Ok(ClientDiagnosticsStorage {
5512                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
5513                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
5514                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
5515                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
5516                retained_outcome_entries: Some(outcome_entries.max(0)),
5517                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
5518                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
5519            })
5520        };
5521        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
5522            status: "unreadable".to_owned(),
5523            database_bytes_approx: None,
5524            pending_outbox_bytes_approx: None,
5525            retained_outcome_bytes_approx: None,
5526            retained_outcome_entries: None,
5527            blob_cache_bytes_approx: None,
5528            pressure_reason_code: None,
5529        })
5530    }
5531
5532    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
5533        self.change_queue.drain(..).collect()
5534    }
5535
5536    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
5537        self.sync_intent_queue.drain(..).collect()
5538    }
5539
5540    fn schedule_background_retry(&mut self) {
5541        self.sync_intent_queue.push_back(SyncIntent::Background {
5542            delay_ms: self.retry_delay_ms,
5543        });
5544        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
5545    }
5546
5547    fn reset_background_retry(&mut self) {
5548        self.retry_delay_ms = 250;
5549    }
5550
5551    fn retryable_transport_code(code: &str) -> bool {
5552        code == "transport.failed"
5553            || code == "transport.unavailable"
5554            || code == "sync.transport_failed"
5555    }
5556
5557    fn diagnostic_code(code: &str) -> String {
5558        let valid = !code.is_empty()
5559            && code.len() <= 96
5560            && code.contains('.')
5561            && code
5562                .bytes()
5563                .next()
5564                .is_some_and(|byte| byte.is_ascii_lowercase())
5565            && code.bytes().all(|byte| {
5566                byte.is_ascii_lowercase()
5567                    || byte.is_ascii_digit()
5568                    || matches!(byte, b'.' | b'_' | b'-')
5569            });
5570        if valid {
5571            code.to_owned()
5572        } else {
5573            "client.unknown_failure".to_owned()
5574        }
5575    }
5576
5577    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
5578        if self.sync_needed != value {
5579            if self.begin_observation("syncular_status").is_ok() {
5580                self.sync_needed = value;
5581                let batch = ChangeAccumulator {
5582                    status: true,
5583                    ..ChangeAccumulator::default()
5584                };
5585                if self.finish_observation("syncular_status", batch).is_err() {
5586                    self.rollback_observation("syncular_status");
5587                }
5588            } else {
5589                self.sync_needed = value;
5590            }
5591        }
5592        if value && interactive {
5593            self.sync_intent_queue.push_back(SyncIntent::Interactive);
5594        }
5595    }
5596
5597    fn begin_observation(&self, name: &str) -> Result<(), String> {
5598        self.conn
5599            .execute_batch(&format!("SAVEPOINT {name}"))
5600            .map_err(|error| error.to_string())
5601    }
5602
5603    fn rollback_observation(&self, name: &str) {
5604        let _ = self
5605            .conn
5606            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"));
5607    }
5608
5609    fn finish_observation(&mut self, name: &str, batch: ChangeAccumulator) -> Result<(), String> {
5610        #[cfg(feature = "bench-internals")]
5611        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
5612        if !batch.touched() {
5613            self.conn
5614                .execute_batch(&format!("RELEASE {name}"))
5615                .map_err(|error| error.to_string())?;
5616            return Ok(());
5617        }
5618        let revision = self
5619            .local_revision()
5620            .checked_add(1)
5621            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
5622        self.conn
5623            .execute(
5624                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
5625                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
5626            )
5627            .map_err(|error| error.to_string())?;
5628        let status = batch.status.then(|| self.status_snapshot());
5629        let event = ClientChangeBatch {
5630            revision: revision.to_string(),
5631            tables: batch
5632                .tables
5633                .into_iter()
5634                .map(|(table, scope_keys)| TableChange {
5635                    table,
5636                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
5637                })
5638                .collect(),
5639            windows: batch
5640                .windows
5641                .into_iter()
5642                .map(|((base_key, table), units)| WindowChange {
5643                    base_key,
5644                    table,
5645                    units: units.into_iter().collect(),
5646                })
5647                .collect(),
5648            status,
5649            conflicts_changed: batch.conflicts,
5650            rejections_changed: batch.rejections,
5651            outcomes_changed: batch.outcomes,
5652        };
5653        self.conn
5654            .execute_batch(&format!("RELEASE {name}"))
5655            .map_err(|error| error.to_string())?;
5656        let mut diagnostic_tables = event
5657            .tables
5658            .iter()
5659            .map(|entry| entry.table.clone())
5660            .collect::<BTreeSet<_>>()
5661            .into_iter()
5662            .collect::<Vec<_>>();
5663        let mut diagnostic_windows = event
5664            .windows
5665            .iter()
5666            .map(|entry| entry.table.clone())
5667            .collect::<BTreeSet<_>>()
5668            .into_iter()
5669            .collect::<Vec<_>>();
5670        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
5671            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
5672        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
5673        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
5674        self.last_change = Some(DiagnosticLastChange {
5675            revision: event.revision.clone(),
5676            recorded_at_ms: self.clock_now_ms(),
5677            tables: diagnostic_tables,
5678            windows: diagnostic_windows,
5679            domains_truncated,
5680            status_changed: event.status.is_some(),
5681            conflicts_changed: event.conflicts_changed,
5682            rejections_changed: event.rejections_changed,
5683            outcomes_changed: event.outcomes_changed,
5684        });
5685        self.change_queue.push_back(event);
5686        Ok(())
5687    }
5688
5689    fn record_scope_map(
5690        &self,
5691        batch: &mut ChangeAccumulator,
5692        table_name: &str,
5693        scopes: &[(String, Vec<String>)],
5694    ) {
5695        let Some(table) = self.schema.table(table_name) else {
5696            return;
5697        };
5698        for (variable, values) in scopes {
5699            let Some(scope) = table
5700                .scope_variables
5701                .iter()
5702                .find(|scope| &scope.variable == variable)
5703            else {
5704                continue;
5705            };
5706            for value in values {
5707                batch.scope(table_name, format!("{}:{value}", scope.prefix));
5708            }
5709        }
5710    }
5711
5712    /// Record a row's current scope keys from the base or visible table.
5713    fn record_row_scopes(
5714        &self,
5715        batch: &mut ChangeAccumulator,
5716        table_name: &str,
5717        row_id: &str,
5718        base: bool,
5719    ) -> bool {
5720        let Some(table) = self.schema.table(table_name) else {
5721            return false;
5722        };
5723        if table.scope_variables.is_empty() {
5724            return false;
5725        }
5726        let columns = table
5727            .scope_variables
5728            .iter()
5729            .map(|scope| quote_ident(&scope.column))
5730            .collect::<Vec<_>>()
5731            .join(", ");
5732        let full_table = if base {
5733            base_table(table_name)
5734        } else {
5735            visible_table(table_name)
5736        };
5737        let sql = format!(
5738            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
5739            row_id_predicate(table)
5740        );
5741        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
5742            return false;
5743        };
5744        let values = stmt.query_row(rusqlite::params![row_id], |row| {
5745            let mut values = Vec::with_capacity(table.scope_variables.len());
5746            for index in 0..table.scope_variables.len() {
5747                values.push(row.get::<_, Option<String>>(index)?);
5748            }
5749            Ok(values)
5750        });
5751        let Ok(values) = values else {
5752            return false;
5753        };
5754        let mut recorded = false;
5755        for (scope, value) in table.scope_variables.iter().zip(values) {
5756            if let Some(value) = value {
5757                batch.scope(table_name, format!("{}:{value}", scope.prefix));
5758                recorded = true;
5759            }
5760        }
5761        recorded
5762    }
5763
5764    fn record_commit_changes(
5765        &self,
5766        batch: &mut ChangeAccumulator,
5767        tables: &[String],
5768        changes: &[ssp2::model::Change],
5769    ) {
5770        #[cfg(feature = "bench-internals")]
5771        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
5772        for change in changes {
5773            let Some(table_name) = tables.get(change.table_index as usize) else {
5774                continue;
5775            };
5776            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
5777            if let Some(table) = self.schema.table(table_name) {
5778                for (variable, value) in &change.scopes {
5779                    if let Some(scope) = table
5780                        .scope_variables
5781                        .iter()
5782                        .find(|scope| &scope.variable == variable)
5783                    {
5784                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
5785                        precise = true;
5786                    }
5787                }
5788            }
5789            if !precise {
5790                batch.table(table_name);
5791            }
5792        }
5793    }
5794
5795    fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
5796        if effective.is_empty() {
5797            return false;
5798        }
5799        let Some(table) = self.schema.table(table_name) else {
5800            return false;
5801        };
5802        let mut clauses = Vec::new();
5803        let mut params = Vec::new();
5804        for (variable, values) in effective {
5805            let Some(column) = table.scope_column(variable) else {
5806                return false;
5807            };
5808            if values.is_empty() {
5809                return false;
5810            }
5811            let placeholders = values
5812                .iter()
5813                .map(|value| {
5814                    params.push(SqlValue::Text(value.clone()));
5815                    "?"
5816                })
5817                .collect::<Vec<_>>()
5818                .join(", ");
5819            clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
5820        }
5821        let sql = format!(
5822            "SELECT 1 FROM {} WHERE {} LIMIT 1",
5823            base_table(table_name),
5824            clauses.join(" AND ")
5825        );
5826        self.conn
5827            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
5828            .is_ok()
5829    }
5830
5831    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
5832    pub fn upgrading(&self) -> bool {
5833        self.upgrading
5834    }
5835
5836    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
5837    /// keeping this client's local database (identity, outbox, tables). The
5838    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
5839    /// version changed. Mirrors the TS client's boot-time detection —
5840    /// the Rust core has no persistent restart, so recreation IS the boot.
5841    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
5842        let new_schema = parse_schema_json(schema_json)?;
5843        let marker: Option<i32> = self
5844            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
5845            .and_then(|v| v.parse().ok());
5846        self.schema = new_schema;
5847        self.create_bookkeeping_tables()?;
5848        if marker != Some(self.schema.version) {
5849            self.run_schema_reset()?;
5850        }
5851        self.prune_unknown_subscriptions()?;
5852        // The conformance recreate is the in-memory equivalent of reopening a
5853        // durable client. Apply the same startup catch-up contract even when
5854        // the schema itself did not change.
5855        self.enqueue_startup_sync_if_needed()?;
5856        Ok(())
5857    }
5858
5859    fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
5860        let startup_work = !self.stopped
5861            && (!self.outbox.is_empty()
5862                || !self.load_pending_evictions()?.is_empty()
5863                || self.subs.iter().any(|sub| sub.state == SubState::Active));
5864        if startup_work {
5865            self.sync_needed = true;
5866            self.sync_intent_queue.push_back(SyncIntent::Interactive);
5867        }
5868        Ok(())
5869    }
5870
5871    /// A native client persists schema-floor stops across process restarts.
5872    /// Once the running app already satisfies that floor, the persisted stop
5873    /// is only stale evidence from an older server round (for example, the
5874    /// server was restarted after catching up to an app that was ahead).
5875    /// Clear it and let the normal startup pull re-negotiate. If the server is
5876    /// still incompatible it will return the floor again in that first round.
5877    fn clear_satisfied_persisted_schema_floor(&mut self) {
5878        let satisfied = self
5879            .schema_floor
5880            .as_ref()
5881            .and_then(|floor| floor.required_schema_version)
5882            .is_some_and(|required| self.schema.version >= required);
5883        if !satisfied {
5884            return;
5885        }
5886        self.schema_floor = None;
5887        self.stopped = false;
5888        self.delete_meta(SCHEMA_FLOOR_KEY);
5889    }
5890
5891    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
5892    /// and leaseState. Drops/recreates every synced table from the new
5893    /// schema, resets subscription sync-state (keeping registrations), clears
5894    /// the schema-floor stop state, rewrites the marker, drops outbox commits
5895    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
5896    fn run_schema_reset(&mut self) -> Result<(), String> {
5897        self.begin_observation("syncular_schema_reset")?;
5898        let mut batch = ChangeAccumulator::default();
5899        let result = self.run_schema_reset_observed(&mut batch, true);
5900        if let Err(error) = result {
5901            self.rollback_observation("syncular_schema_reset");
5902            return Err(error);
5903        }
5904        if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
5905            self.rollback_observation("syncular_schema_reset");
5906            return Err(error);
5907        }
5908        Ok(())
5909    }
5910
5911    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
5912        let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
5913        self.begin_observation("syncular_log_epoch_reset")?;
5914        let mut batch = ChangeAccumulator::default();
5915        let result = self.run_schema_reset_observed(&mut batch, false);
5916        if let Err(error) = result {
5917            self.rollback_observation("syncular_log_epoch_reset");
5918            return Err(error);
5919        }
5920        self.set_meta(LOG_EPOCH_KEY, log_epoch);
5921        self.sync_needed = true;
5922        batch.status = true;
5923        if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
5924            self.rollback_observation("syncular_log_epoch_reset");
5925            return Err(error);
5926        }
5927        self.sync_intent_queue.push_back(SyncIntent::Interactive);
5928        Ok(resets)
5929    }
5930
5931    fn run_schema_reset_observed(
5932        &mut self,
5933        batch: &mut ChangeAccumulator,
5934        drop_incompatible: bool,
5935    ) -> Result<(), String> {
5936        self.upgrading = true;
5937        batch.status = true;
5938        for table in &self.schema.tables {
5939            batch.table(&table.name);
5940        }
5941        for (base_key, unit, table) in self.load_registered_window_units() {
5942            batch.window(&base_key, &table, &unit);
5943        }
5944        // The per-table insert SQL is derived from the OLD column lists.
5945        self.insert_sql.borrow_mut().clear();
5946        self.overlay_dirty.set(true);
5947        // Drop every synced table (base + visible) that currently exists —
5948        // discovered from sqlite_master so a bump that adds/removes tables is
5949        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
5950        // and the blob cache) are preserved; base tables are `_syncular_base_*`
5951        // so they are matched explicitly, not by the bookkeeping filter.
5952        // Drop virtual tables first so SQLite removes every FTS shadow table
5953        // atomically instead of the generic discovery tearing it apart.
5954        let virtual_tables: Vec<String> = {
5955            let mut stmt = self
5956                .conn
5957                .prepare(
5958                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
5959                )
5960                .map_err(|e| e.to_string())?;
5961            let rows = stmt
5962                .query_map([], |row| row.get::<_, String>(0))
5963                .map_err(|e| e.to_string())?;
5964            rows.filter_map(Result::ok)
5965                .filter(|name| is_synced_table_name(name))
5966                .collect()
5967        };
5968        for name in virtual_tables {
5969            self.conn
5970                .execute_batch(&format!(
5971                    "DROP TABLE IF EXISTS {}",
5972                    quote_ident(&format!("_syncular_fts_{name}"))
5973                ))
5974                .map_err(|e| e.to_string())?;
5975            self.conn
5976                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
5977                .map_err(|e| e.to_string())?;
5978        }
5979        let existing: Vec<String> = {
5980            let mut stmt = self
5981                .conn
5982                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
5983                .map_err(|e| e.to_string())?;
5984            let rows = stmt
5985                .query_map([], |row| row.get::<_, String>(0))
5986                .map_err(|e| e.to_string())?;
5987            rows.filter_map(Result::ok)
5988                .filter(|name| is_synced_table_name(name))
5989                .collect()
5990        };
5991        for name in &existing {
5992            if let Some(table) = name.strip_prefix("_syncular_base_") {
5993                batch.table(table);
5994            } else if !name.starts_with("_syncular_") {
5995                batch.table(name);
5996            }
5997        }
5998        for name in existing {
5999            self.conn
6000                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
6001                .map_err(|e| e.to_string())?;
6002        }
6003        // Recreate the synced tables from the NEW schema.
6004        self.create_synced_tables()?;
6005        // Reset every subscription's sync-state, keeping the registration.
6006        for sub in &mut self.subs {
6007            sub.cursor = -1;
6008            sub.bootstrap_state = None;
6009            sub.effective = None;
6010            sub.state = SubState::Active;
6011            sub.reason_code = None;
6012            sub.synced_once = false;
6013        }
6014        let subs = self.subs.clone();
6015        for sub in &subs {
6016            self.persist_sub(sub)?;
6017        }
6018        // The stop state is over: this client now ships a servable schema.
6019        self.stopped = false;
6020        self.schema_floor = None;
6021        self.delete_meta(SCHEMA_FLOOR_KEY);
6022        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
6023        self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
6024        // §7.4.4: drop outbox commits that cannot re-encode under the new
6025        // schema (a referenced column/table the bump removed), surfacing each
6026        // as a `sync.outbox_incompatible` rejection.
6027        if drop_incompatible && self.drop_incompatible_outbox()? {
6028            batch.rejections = true;
6029            batch.status = true;
6030            batch.outcomes = true;
6031        }
6032        // Re-apply the surviving outbox optimistically over the empty tables.
6033        self.rebuild_overlay();
6034        Ok(())
6035    }
6036
6037    /// §7.4.4: a persisted upsert whose values reference a column the current
6038    /// schema lacks (or a removed table) cannot be encoded. Drop the commit
6039    /// and raise a client-local `sync.outbox_incompatible` rejection.
6040    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
6041        let schema = &self.schema;
6042        let incompatible = self
6043            .outbox
6044            .iter()
6045            .filter(|commit| {
6046                commit.ops.iter().any(|op| {
6047                    if !op.upsert {
6048                        return false;
6049                    }
6050                    match schema.table(&op.table) {
6051                        None => true,
6052                        Some(table) => op.values.as_ref().is_some_and(|values| {
6053                            values
6054                                .keys()
6055                                .any(|key| !table.columns.iter().any(|c| &c.name == key))
6056                        }),
6057                    }
6058                })
6059            })
6060            .cloned()
6061            .collect::<Vec<_>>();
6062        if incompatible.is_empty() {
6063            return Ok(false);
6064        }
6065        let mut rejections = Vec::new();
6066        for commit in &incompatible {
6067            let results = commit
6068                .ops
6069                .iter()
6070                .enumerate()
6071                .map(|(op_index, operation)| {
6072                    let rejection = RejectionRecord {
6073                        client_commit_id: commit.client_commit_id.clone(),
6074                        op_index: op_index as i32,
6075                        code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
6076                        message: "the persisted commit cannot encode under the current schema"
6077                            .to_owned(),
6078                        retryable: false,
6079                        details: None,
6080                        operation: Some(CommitOperation::from(operation)),
6081                    };
6082                    rejections.push(rejection.clone());
6083                    CommitOperationOutcome::Error { rejection }
6084                })
6085                .collect::<Vec<_>>();
6086            self.persist_commit_outcome(
6087                &commit.client_commit_id,
6088                CommitOutcomeStatus::Rejected,
6089                &results,
6090                Some(&commit.ops),
6091            )?;
6092            self.delete_outbox_persisted(&commit.client_commit_id)?;
6093        }
6094        self.prune_commit_outcomes()?;
6095        let incompatible_ids = incompatible
6096            .iter()
6097            .map(|commit| commit.client_commit_id.as_str())
6098            .collect::<BTreeSet<_>>();
6099        self.outbox
6100            .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
6101        self.rejections.extend(rejections);
6102        Ok(true)
6103    }
6104
6105    /// §7.4.3: (re)create the base + visible table pair for every synced
6106    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
6107    fn create_synced_tables(&self) -> Result<(), String> {
6108        for table in &self.schema.tables {
6109            // The base half + the visible half form the synced-table pair. An
6110            // index name is global in SQLite, so the base half's indexes are
6111            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
6112            for (full, index_prefix) in [
6113                (base_table(&table.name), "_syncular_base_"),
6114                (visible_table(&table.name), ""),
6115            ] {
6116                let mut cols: Vec<String> =
6117                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
6118                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
6119                let sql = format!(
6120                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
6121                    cols.join(", "),
6122                    quote_ident(&table.primary_key)
6123                );
6124                self.conn.execute(&sql, []).map_err(|e| e.to_string())?;
6125                // Local secondary indexes (CREATE INDEX subset). Created on
6126                // both halves so mirror reads hit an index on either. Runs on
6127                // both the initial create and the §7.4.3 reset recreate path.
6128                for index in &table.indexes {
6129                    let unique = if index.unique { "UNIQUE " } else { "" };
6130                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
6131                    let cols_sql = index
6132                        .columns
6133                        .iter()
6134                        .map(|c| quote_ident(c))
6135                        .collect::<Vec<_>>()
6136                        .join(", ");
6137                    let index_sql = format!(
6138                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
6139                    );
6140                    self.conn
6141                        .execute(&index_sql, [])
6142                        .map_err(|e| e.to_string())?;
6143                }
6144            }
6145        }
6146        // FTS exists only on the visible half. It is a local search
6147        // projection, never a synced/base table or a wire-schema member.
6148        for table in &self.schema.tables {
6149            for index in &table.fts_indexes {
6150                self.create_fts_projection(table, index)?;
6151            }
6152        }
6153        Ok(())
6154    }
6155
6156    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
6157        let count: i64 = self
6158            .conn
6159            .query_row(
6160                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
6161                rusqlite::params![index.name],
6162                |row| row.get(0),
6163            )
6164            .map_err(|e| e.to_string())?;
6165        Ok(count > 0)
6166    }
6167
6168    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
6169        for suffix in ["ai", "ad", "au"] {
6170            self.conn
6171                .execute(
6172                    &format!(
6173                        "DROP TRIGGER IF EXISTS {}",
6174                        quote_ident(&format!("{}_{suffix}", index.name))
6175                    ),
6176                    [],
6177                )
6178                .map_err(|e| e.to_string())?;
6179        }
6180        Ok(())
6181    }
6182
6183    fn create_fts_triggers(
6184        &self,
6185        table: &TableSchema,
6186        index: &FtsIndexSchema,
6187    ) -> Result<(), String> {
6188        let fts = quote_ident(&index.name);
6189        let source = visible_table(&table.name);
6190        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
6191        let pk = quote_ident(&table.primary_key);
6192        let projection_columns = std::iter::once(source_id.clone())
6193            .chain(index.columns.iter().map(|column| quote_ident(column)))
6194            .collect::<Vec<_>>()
6195            .join(", ");
6196        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
6197            .chain(
6198                index
6199                    .columns
6200                    .iter()
6201                    .map(|column| format!("new.{}", quote_ident(column))),
6202            )
6203            .collect::<Vec<_>>()
6204            .join(", ");
6205        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
6206        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)");
6207        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)");
6208        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})");
6209        let bi = quote_ident(&format!("{}_bi", index.name));
6210        let ai = quote_ident(&format!("{}_ai", index.name));
6211        let ad = quote_ident(&format!("{}_ad", index.name));
6212        let au = quote_ident(&format!("{}_au", index.name));
6213        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
6214        let sql = format!(
6215            "DROP TRIGGER IF EXISTS {bi};
6216             DROP TRIGGER IF EXISTS {ai};
6217             DROP TRIGGER IF EXISTS {ad};
6218             DROP TRIGGER IF EXISTS {au};
6219             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
6220             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
6221             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
6222             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
6223        );
6224        self.conn.execute_batch(&sql).map_err(|e| e.to_string())
6225    }
6226
6227    fn rebuild_fts_projection(
6228        &self,
6229        table: &TableSchema,
6230        index: &FtsIndexSchema,
6231    ) -> Result<(), String> {
6232        let fts = quote_ident(&index.name);
6233        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
6234        let pk = quote_ident(&table.primary_key);
6235        let indexed_columns = index
6236            .columns
6237            .iter()
6238            .map(|column| quote_ident(column))
6239            .collect::<Vec<_>>();
6240        let projection_columns = std::iter::once(source_id.clone())
6241            .chain(indexed_columns.iter().cloned())
6242            .collect::<Vec<_>>()
6243            .join(", ");
6244        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
6245        let sql = format!(
6246            "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};",
6247            columns = indexed_columns.join(", "),
6248            source = visible_table(&table.name),
6249        );
6250        self.conn.execute_batch(&sql).map_err(|e| e.to_string())
6251    }
6252
6253    fn create_fts_projection(
6254        &self,
6255        table: &TableSchema,
6256        index: &FtsIndexSchema,
6257    ) -> Result<(), String> {
6258        self.begin_observation("syncular_fts_schema")?;
6259        let result = (|| {
6260            let existed = self.fts_projection_exists(index)?;
6261            let tokenizer = index.tokenize.replace('\'', "''");
6262            let columns = index
6263                .columns
6264                .iter()
6265                .map(|column| quote_ident(column))
6266                .collect::<Vec<_>>()
6267                .join(", ");
6268            let sql = format!(
6269            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
6270            fts = quote_ident(&index.name),
6271            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
6272        );
6273            self.conn.execute(&sql, []).map_err(|error| {
6274                format!(
6275                    "cannot create local FTS5 projection {:?}: {error}",
6276                    index.name
6277                )
6278            })?;
6279            let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
6280            let mapped: bool = self
6281                .conn
6282                .query_row(
6283                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
6284                    [format!("_syncular_fts_{}", index.name)],
6285                    |row| row.get(0),
6286                )
6287                .map_err(|e| e.to_string())?;
6288            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())?;
6289            if !mapped && existed {
6290                self.conn
6291                    .execute_batch(&format!(
6292                        "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
6293                        quote_ident(FTS_SOURCE_ID_COLUMN),
6294                        quote_ident(&index.name)
6295                    ))
6296                    .map_err(|e| e.to_string())?;
6297            }
6298            self.create_fts_triggers(table, index)?;
6299            if !existed {
6300                self.rebuild_fts_projection(table, index)?;
6301            }
6302            Ok(())
6303        })();
6304        if let Err(error) = result {
6305            self.rollback_observation("syncular_fts_schema");
6306            return Err(error);
6307        }
6308        if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
6309            self.rollback_observation("syncular_fts_schema");
6310            return Err(error.to_string());
6311        }
6312        Ok(())
6313    }
6314
6315    // -- persistence write-through --------------------------------------------
6316
6317    fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
6318        let state = serde_json::json!({
6319            "requested": scope_map_to_json(&sub.requested),
6320            "params": sub.params,
6321            "cursor": sub.cursor,
6322            "bootstrapState": sub.bootstrap_state,
6323            "status": sub.state.name(),
6324            "reasonCode": sub.reason_code,
6325            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
6326            "syncedOnce": sub.synced_once,
6327        });
6328        self.conn.execute(
6329            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
6330            rusqlite::params![sub.id, sub.table, state.to_string()],
6331        ).map(|_| ()).map_err(|error| error.to_string())
6332    }
6333
6334    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
6335        let ops: Vec<Value> = commit
6336            .ops
6337            .iter()
6338            .map(|op| {
6339                serde_json::json!({
6340                    "op": if op.upsert { "upsert" } else { "delete" },
6341                    "table": op.table,
6342                    "rowId": op.row_id,
6343                    "baseVersion": op.base_version,
6344                    "values": op.values.clone().map(Value::Object),
6345                })
6346            })
6347            .collect();
6348        self.conn
6349            .execute(
6350                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
6351                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
6352            )
6353            .map_err(|error| error.to_string())?;
6354        for operation in &commit.ops {
6355            let Some(values) = &operation.values else {
6356                continue;
6357            };
6358            let Some(table) = self.schema.table(&operation.table) else {
6359                continue;
6360            };
6361            for column in table
6362                .columns
6363                .iter()
6364                .filter(|column| column.ty == ColumnType::BlobRef)
6365            {
6366                let Some(Value::String(raw)) = values.get(&column.name) else {
6367                    continue;
6368                };
6369                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
6370                    continue;
6371                };
6372                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
6373                    continue;
6374                };
6375                self.conn
6376                    .execute(
6377                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
6378                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
6379                        rusqlite::params![commit.client_commit_id, blob_id],
6380                    )
6381                    .map_err(|error| error.to_string())?;
6382            }
6383        }
6384        Ok(())
6385    }
6386
6387    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
6388        self.conn
6389            .execute(
6390                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
6391                rusqlite::params![client_commit_id],
6392            )
6393            .map_err(|error| error.to_string())?;
6394        self.conn
6395            .execute(
6396                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
6397                rusqlite::params![client_commit_id],
6398            )
6399            .map(|_| ())
6400            .map_err(|error| error.to_string())
6401    }
6402
6403    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
6404        match status {
6405            CommitOutcomeStatus::Applied => "applied",
6406            CommitOutcomeStatus::Cached => "cached",
6407            CommitOutcomeStatus::Conflict => "conflict",
6408            CommitOutcomeStatus::Rejected => "rejected",
6409        }
6410    }
6411
6412    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
6413        match resolution {
6414            CommitOutcomeResolution::Active => "active",
6415            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
6416            CommitOutcomeResolution::Superseded => "superseded",
6417            CommitOutcomeResolution::Dismissed => "dismissed",
6418        }
6419    }
6420
6421    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
6422        match value {
6423            "applied" => Ok(CommitOutcomeStatus::Applied),
6424            "cached" => Ok(CommitOutcomeStatus::Cached),
6425            "conflict" => Ok(CommitOutcomeStatus::Conflict),
6426            "rejected" => Ok(CommitOutcomeStatus::Rejected),
6427            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
6428        }
6429    }
6430
6431    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
6432        match value {
6433            "active" => Ok(CommitOutcomeResolution::Active),
6434            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
6435            "superseded" => Ok(CommitOutcomeResolution::Superseded),
6436            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
6437            _ => Err(format!(
6438                "invalid persisted commit outcome resolution {value:?}"
6439            )),
6440        }
6441    }
6442
6443    fn persist_commit_outcome(
6444        &self,
6445        client_commit_id: &str,
6446        status: CommitOutcomeStatus,
6447        results: &[CommitOperationOutcome],
6448        operations: Option<&[OutboxOp]>,
6449    ) -> Result<(), String> {
6450        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
6451        let operations_json = operations
6452            .map(|items| {
6453                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
6454            })
6455            .transpose()
6456            .map_err(|error| error.to_string())?;
6457        self.conn
6458            .execute(
6459                "INSERT INTO _syncular_commit_outcomes (
6460                   client_commit_id, status, recorded_at_ms, results_json,
6461                   operations_json, resolution
6462                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
6463                rusqlite::params![
6464                    client_commit_id,
6465                    Self::outcome_status_name(status),
6466                    self.clock_now_ms(),
6467                    results_json,
6468                    operations_json
6469                ],
6470            )
6471            .map(|_| ())
6472            .map_err(|error| error.to_string())
6473    }
6474
6475    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
6476        let StoredCommitOutcomeRow {
6477            sequence,
6478            client_commit_id,
6479            status,
6480            recorded_at_ms,
6481            results_json,
6482            operations_json,
6483            resolution,
6484            resolved_at_ms,
6485            replacement_client_commit_id,
6486        } = row;
6487        Ok(CommitOutcome {
6488            sequence,
6489            client_commit_id,
6490            status: Self::parse_outcome_status(&status)?,
6491            recorded_at_ms,
6492            results: serde_json::from_str(&results_json)
6493                .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
6494            operations: operations_json
6495                .map(|value| {
6496                    serde_json::from_str(&value).map_err(|error| {
6497                        format!("invalid persisted commit outcome operations: {error}")
6498                    })
6499                })
6500                .transpose()?,
6501            resolution: Self::parse_outcome_resolution(&resolution)?,
6502            resolved_at_ms,
6503            replacement_client_commit_id,
6504        })
6505    }
6506
6507    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
6508        let row = self
6509            .conn
6510            .query_row(
6511                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
6512                        resolution, resolved_at_ms, replacement_client_commit_id
6513                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
6514                rusqlite::params![client_commit_id],
6515                |row| {
6516                    Ok(StoredCommitOutcomeRow {
6517                        sequence: row.get(0)?,
6518                        client_commit_id: row.get(1)?,
6519                        status: row.get(2)?,
6520                        recorded_at_ms: row.get(3)?,
6521                        results_json: row.get(4)?,
6522                        operations_json: row.get(5)?,
6523                        resolution: row.get(6)?,
6524                        resolved_at_ms: row.get(7)?,
6525                        replacement_client_commit_id: row.get(8)?,
6526                    })
6527                },
6528            )
6529            .optional()
6530            .map_err(|error| error.to_string())?;
6531        row.map(Self::outcome_from_row).transpose()
6532    }
6533
6534    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
6535        if query.limit == Some(0) {
6536            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
6537        }
6538        let mut sql = String::from(
6539            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
6540                    resolution, resolved_at_ms, replacement_client_commit_id
6541               FROM _syncular_commit_outcomes",
6542        );
6543        if query.active_only {
6544            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
6545        }
6546        sql.push_str(" ORDER BY seq DESC");
6547        if let Some(limit) = query.limit {
6548            sql.push_str(&format!(" LIMIT {limit}"));
6549        }
6550        let mut stmt = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
6551        let rows = stmt
6552            .query_map([], |row| {
6553                Ok(StoredCommitOutcomeRow {
6554                    sequence: row.get(0)?,
6555                    client_commit_id: row.get(1)?,
6556                    status: row.get(2)?,
6557                    recorded_at_ms: row.get(3)?,
6558                    results_json: row.get(4)?,
6559                    operations_json: row.get(5)?,
6560                    resolution: row.get(6)?,
6561                    resolved_at_ms: row.get(7)?,
6562                    replacement_client_commit_id: row.get(8)?,
6563                })
6564            })
6565            .map_err(|error| error.to_string())?;
6566        let mut outcomes = Vec::new();
6567        for row in rows {
6568            outcomes.push(Self::outcome_from_row(
6569                row.map_err(|error| error.to_string())?,
6570            )?);
6571        }
6572        Ok(outcomes)
6573    }
6574
6575    fn prune_commit_outcomes(&self) -> Result<(), String> {
6576        #[cfg(test)]
6577        self.outcome_prune_count
6578            .set(self.outcome_prune_count.get() + 1);
6579        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
6580        let count = self
6581            .conn
6582            .query_row(
6583                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
6584                [],
6585                |row| row.get::<_, i64>(0),
6586            )
6587            .map_err(|error| error.to_string())? as usize;
6588        let excess = count.saturating_sub(max_entries);
6589        if excess == 0 {
6590            return Ok(());
6591        }
6592        let mut stmt = self
6593            .conn
6594            .prepare(
6595                "SELECT seq FROM _syncular_commit_outcomes
6596                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
6597                  ORDER BY seq ASC LIMIT ?1",
6598            )
6599            .map_err(|error| error.to_string())?;
6600        let rows = stmt
6601            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
6602            .map_err(|error| error.to_string())?;
6603        let sequences = rows
6604            .collect::<Result<Vec<_>, _>>()
6605            .map_err(|error| error.to_string())?;
6606        drop(stmt);
6607        for sequence in sequences {
6608            self.conn
6609                .execute(
6610                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
6611                    rusqlite::params![sequence],
6612                )
6613                .map_err(|error| error.to_string())?;
6614        }
6615        Ok(())
6616    }
6617
6618    // -- driver surface ---------------------------------------------------------
6619
6620    pub fn subscribe(
6621        &mut self,
6622        id: String,
6623        table: String,
6624        scopes: Vec<(String, Vec<String>)>,
6625        params: Option<String>,
6626    ) -> Result<(), String> {
6627        if self.schema.table(&table).is_none() {
6628            return Err(format!("unknown table {table:?}"));
6629        }
6630        if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
6631            let same_intent = existing.table == table
6632                && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
6633                && existing.params == params;
6634            if same_intent {
6635                return Ok(());
6636            }
6637            return Err(
6638                "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
6639                    .to_owned(),
6640            );
6641        }
6642        let sub = Subscription {
6643            id: id.clone(),
6644            table,
6645            requested: scopes,
6646            params,
6647            cursor: -1,
6648            bootstrap_state: None,
6649            state: SubState::Active,
6650            reason_code: None,
6651            effective: None,
6652            synced_once: false,
6653        };
6654        self.persist_sub(&sub)?;
6655        self.subs.push(sub);
6656        Ok(())
6657    }
6658
6659    pub fn unsubscribe(&mut self, id: &str) {
6660        self.subs.retain(|s| s.id != id);
6661        let _ = self.conn.execute(
6662            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
6663            rusqlite::params![id],
6664        );
6665    }
6666
6667    // -- windowed subscriptions (§4.8) ------------------------------------------
6668
6669    /// §4.8: set the live window units for a base — a value-sharded family
6670    /// of subscriptions, one per unit. Added units get fresh subscriptions
6671    /// (image-lane bootstrap on the next sync); removed units are
6672    /// unsubscribed with their first eviction chunk in one transaction (E1–E4).
6673    /// Idempotent; re-entry cancels any deferred eviction.
6674    pub fn set_window(
6675        &mut self,
6676        base: &WindowBase,
6677        units: &[String],
6678    ) -> Result<CommandEffects, String> {
6679        let table = self
6680            .schema
6681            .table(&base.table)
6682            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
6683        if table.scope_column(&base.variable).is_none() {
6684            return Err(format!(
6685                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
6686                base.table, base.variable
6687            ));
6688        }
6689        let base_key = window_base_key(base);
6690        let wanted: std::collections::HashSet<&String> = units.iter().collect();
6691        let live = self.load_window_units(&base_key);
6692        self.begin_observation("syncular_window")?;
6693        let mut batch = ChangeAccumulator::default();
6694        let mut changed = false;
6695
6696        let prior_subs = self.subs.clone();
6697        let widened = (|| {
6698            // Widen: units wanted but not live → fresh subscription + registry row.
6699            for unit in units {
6700                if live.iter().any(|(u, _)| u == unit) {
6701                    continue;
6702                }
6703                let sub_id = derive_sub_id(base, unit);
6704                self.delete_pending_evict(&sub_id)?;
6705                self.insert_window_unit(&base_key, unit, &sub_id)?;
6706                self.subscribe(
6707                    sub_id,
6708                    base.table.clone(),
6709                    unit_scopes(base, unit),
6710                    base.params.clone(),
6711                )?;
6712                batch.window(&base_key, &base.table, unit);
6713                changed = true;
6714            }
6715
6716            self.finish_observation("syncular_window", batch)
6717        })();
6718        if let Err(error) = widened {
6719            self.rollback_observation("syncular_window");
6720            self.subs = prior_subs;
6721            return Err(error);
6722        }
6723        for (unit, sub_id) in live {
6724            if wanted.contains(&unit) {
6725                continue;
6726            }
6727            self.rebuild_overlay_if_dirty();
6728            let prior_subs = self.subs.clone();
6729            self.begin_observation("syncular_window")?;
6730            let effective = self
6731                .subs
6732                .iter()
6733                .find(|sub| sub.id == sub_id)
6734                .and_then(|sub| sub.effective.clone())
6735                .unwrap_or_else(|| unit_scopes(base, &unit));
6736            let mut batch = ChangeAccumulator::default();
6737            self.record_scope_map(&mut batch, &base.table, &effective);
6738            batch.window(&base_key, &base.table, &unit);
6739            let result = self.evict_unit(&base_key, base, &unit, &sub_id);
6740            let remaining = match result {
6741                Ok(remaining) => remaining,
6742                Err(error) => {
6743                    self.rollback_observation("syncular_window");
6744                    self.subs = prior_subs;
6745                    return Err(error);
6746                }
6747            };
6748            if let Err(error) = self.finish_observation("syncular_window", batch) {
6749                self.rollback_observation("syncular_window");
6750                self.subs = prior_subs;
6751                return Err(error);
6752            }
6753            if remaining {
6754                self.drain_pending_evictions()?;
6755            }
6756            changed = true;
6757        }
6758        Ok(if changed {
6759            CommandEffects::interactive()
6760        } else {
6761            CommandEffects::none()
6762        })
6763    }
6764
6765    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
6766    /// the subset still bootstrap-pending. Registration alone is not
6767    /// completeness — a unit is pending until its subscription completes a
6768    /// bootstrap round (cursor advances past -1 with no resume token held).
6769    pub fn window_state(&self, base: &WindowBase) -> WindowState {
6770        let mut units = Vec::new();
6771        let mut pending = Vec::new();
6772        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
6773            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
6774                Some(sub) => {
6775                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
6776                }
6777                None => true,
6778            };
6779            if is_pending {
6780                pending.push(unit.clone());
6781            }
6782            units.push(unit);
6783        }
6784        WindowState { units, pending }
6785    }
6786
6787    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
6788        let mut stmt = match self
6789            .conn
6790            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
6791        {
6792            Ok(stmt) => stmt,
6793            Err(_) => return Vec::new(),
6794        };
6795        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
6796            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6797        });
6798        match rows {
6799            Ok(rows) => rows.filter_map(Result::ok).collect(),
6800            Err(_) => Vec::new(),
6801        }
6802    }
6803
6804    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
6805        let mut stmt = match self.conn.prepare(
6806            "SELECT windows.base, windows.unit, subscriptions.tbl
6807               FROM _syncular_windows AS windows
6808               JOIN _syncular_subscriptions AS subscriptions
6809                 ON subscriptions.id = windows.sub_id
6810               ORDER BY windows.base, windows.unit",
6811        ) {
6812            Ok(stmt) => stmt,
6813            Err(_) => return Vec::new(),
6814        };
6815        let rows = stmt.query_map([], |row| {
6816            Ok((
6817                row.get::<_, String>(0)?,
6818                row.get::<_, String>(1)?,
6819                row.get::<_, String>(2)?,
6820            ))
6821        });
6822        match rows {
6823            Ok(rows) => rows.filter_map(Result::ok).collect(),
6824            Err(_) => Vec::new(),
6825        }
6826    }
6827
6828    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
6829        self.conn
6830            .query_row(
6831                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
6832                rusqlite::params![sub_id],
6833                |row| Ok((row.get(0)?, row.get(1)?)),
6834            )
6835            .ok()
6836    }
6837
6838    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
6839        self.conn
6840            .execute(
6841                "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
6842                rusqlite::params![base_key, unit, sub_id],
6843            )
6844            .map(|_| ())
6845            .map_err(|error| error.to_string())
6846    }
6847
6848    fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
6849        self.conn
6850            .execute(
6851                "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
6852                rusqlite::params![base_key, unit],
6853            )
6854            .map(|_| ())
6855            .map_err(|error| error.to_string())
6856    }
6857
6858    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
6859    /// Deletes the unit's rows except those pinned by a pending outbox
6860    /// commit (E1); records a deferred eviction if any pin remains; discards
6861    /// the subscription's cursor/resume/effective-echo (E3) and version
6862    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
6863    /// nothing.
6864    fn evict_unit(
6865        &mut self,
6866        base_key: &str,
6867        base: &WindowBase,
6868        unit: &str,
6869        sub_id: &str,
6870    ) -> Result<bool, String> {
6871        let effective = self
6872            .subs
6873            .iter()
6874            .find(|s| s.id == sub_id)
6875            .and_then(|s| s.effective.clone())
6876            .unwrap_or_else(|| unit_scopes(base, unit));
6877        let pinned = self.pinned_row_ids(&base.table);
6878        let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
6879        self.delete_window_unit(base_key, unit)?;
6880        self.conn
6881            .execute(
6882                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
6883                [sub_id],
6884            )
6885            .map_err(|error| error.to_string())?;
6886        self.subs.retain(|sub| sub.id != sub_id);
6887        if deferred {
6888            self.save_pending_evict(sub_id, &base.table, &effective)?;
6889        } else {
6890            self.delete_pending_evict(sub_id)?;
6891        }
6892        Ok(remaining)
6893    }
6894
6895    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
6896    /// primary keys. Returns (more unpinned work, any scoped rows remain).
6897    /// A missing scope-column mapping fails without deleting rows.
6898    fn evict_scope_rows(
6899        &self,
6900        table_name: &str,
6901        effective: &[(String, Vec<String>)],
6902        pinned: &std::collections::HashSet<String>,
6903    ) -> Result<(bool, bool), String> {
6904        if effective.is_empty() {
6905            return Ok((false, false));
6906        }
6907        let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
6908        let mut clauses = Vec::new();
6909        let mut params: Vec<SqlValue> = Vec::new();
6910        for (variable, values) in effective {
6911            let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
6912            if values.is_empty() {
6913                return Ok((false, false));
6914            }
6915            let holes = values
6916                .iter()
6917                .map(|v| {
6918                    params.push(SqlValue::Text(v.clone()));
6919                    "?"
6920                })
6921                .collect::<Vec<_>>()
6922                .join(", ");
6923            clauses.push(format!("{} IN ({holes})", quote_ident(column)));
6924        }
6925        let scope = clauses.join(" AND ");
6926        let scope_params = params.len();
6927        let pk = quote_ident(&table.primary_key);
6928        if !pinned.is_empty() {
6929            let holes = pinned
6930                .iter()
6931                .map(|id| {
6932                    params.push(SqlValue::Text(id.clone()));
6933                    "?"
6934                })
6935                .collect::<Vec<_>>()
6936                .join(", ");
6937            clauses.push(format!("{pk} NOT IN ({holes})"));
6938        }
6939        let predicate = clauses.join(" AND ");
6940        let base = base_table(table_name);
6941        let visible = visible_table(table_name);
6942        let mut ids: Vec<SqlValue> = Vec::new();
6943        for source in [&base, &visible] {
6944            let mut stmt = self
6945                .conn
6946                .prepare_cached(&format!(
6947                    "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
6948                ))
6949                .map_err(|e| e.to_string())?;
6950            ids = stmt
6951                .query_map(rusqlite::params_from_iter(&params), |row| row.get(0))
6952                .map_err(|e| e.to_string())?
6953                .collect::<Result<_, _>>()
6954                .map_err(|e| e.to_string())?;
6955            if !ids.is_empty() {
6956                break;
6957            }
6958        }
6959        if !ids.is_empty() {
6960            let holes = vec!["?"; ids.len()].join(", ");
6961            for source in [&base, &visible] {
6962                self.conn
6963                    .execute(
6964                        &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
6965                        rusqlite::params_from_iter(&ids),
6966                    )
6967                    .map_err(|e| e.to_string())?;
6968            }
6969        }
6970        let mut remaining = false;
6971        let mut deferred = false;
6972        for source in [&base, &visible] {
6973            remaining |= self
6974                .conn
6975                .query_row(
6976                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
6977                    rusqlite::params_from_iter(&params),
6978                    |row| row.get::<_, bool>(0),
6979                )
6980                .map_err(|e| e.to_string())?;
6981            deferred |= self
6982                .conn
6983                .query_row(
6984                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
6985                    rusqlite::params_from_iter(&params[..scope_params]),
6986                    |row| row.get::<_, bool>(0),
6987                )
6988                .map_err(|e| e.to_string())?;
6989        }
6990        Ok((remaining, deferred))
6991    }
6992
6993    /// Resume committed eviction chunks, retaining outbox pins and their durable marker.
6994    fn drain_pending_evictions(&mut self) -> Result<(), String> {
6995        let pending = self.load_pending_evictions()?;
6996        if pending.is_empty() {
6997            return Ok(());
6998        }
6999        self.rebuild_overlay_if_dirty();
7000        for (sub_id, table_name, effective) in pending {
7001            if self.schema.table(&table_name).is_none() {
7002                self.delete_pending_evict(&sub_id)?;
7003                continue;
7004            }
7005            loop {
7006                std::thread::yield_now();
7007                self.begin_observation("syncular_evict")?;
7008                let pinned = self.pinned_row_ids(&table_name);
7009                let result = self.evict_scope_rows(&table_name, &effective, &pinned);
7010                let (remaining, deferred) = match result {
7011                    Ok(result) => result,
7012                    Err(error) => {
7013                        self.rollback_observation("syncular_evict");
7014                        return Err(error);
7015                    }
7016                };
7017                if !deferred {
7018                    if let Err(error) = self.delete_pending_evict(&sub_id) {
7019                        self.rollback_observation("syncular_evict");
7020                        return Err(error);
7021                    }
7022                }
7023                let mut batch = ChangeAccumulator::default();
7024                self.record_scope_map(&mut batch, &table_name, &effective);
7025                if let Err(error) = self.finish_observation("syncular_evict", batch) {
7026                    self.rollback_observation("syncular_evict");
7027                    return Err(error);
7028                }
7029                if !remaining {
7030                    break;
7031                }
7032            }
7033        }
7034        Ok(())
7035    }
7036
7037    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
7038    /// commit — rows that MUST NOT be evicted until the commit drains.
7039    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
7040        let mut pinned = std::collections::HashSet::new();
7041        for commit in &self.outbox {
7042            for op in &commit.ops {
7043                if op.table == table {
7044                    pinned.insert(op.row_id.clone());
7045                }
7046            }
7047        }
7048        pinned
7049    }
7050
7051    fn save_pending_evict(
7052        &self,
7053        sub_id: &str,
7054        table: &str,
7055        effective: &[(String, Vec<String>)],
7056    ) -> Result<(), String> {
7057        self.conn.execute(
7058            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
7059            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
7060        ).map(|_| ()).map_err(|error| error.to_string())
7061    }
7062
7063    fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
7064        self.conn
7065            .execute(
7066                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
7067                rusqlite::params![sub_id],
7068            )
7069            .map(|_| ())
7070            .map_err(|error| error.to_string())
7071    }
7072
7073    fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
7074        let mut stmt = self
7075            .conn
7076            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
7077            .map_err(|error| error.to_string())?;
7078        let rows = stmt
7079            .query_map([], |row| {
7080                Ok((
7081                    row.get::<_, String>(0)?,
7082                    row.get::<_, String>(1)?,
7083                    row.get::<_, String>(2)?,
7084                ))
7085            })
7086            .map_err(|error| error.to_string())?;
7087        rows.map(|row| {
7088            let (sub_id, table, json) = row.map_err(|error| error.to_string())?;
7089            let value: Value = serde_json::from_str(&json)
7090                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
7091            let effective = json_to_scope_map(&value)
7092                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
7093            Ok((sub_id, table, effective))
7094        })
7095        .collect()
7096    }
7097
7098    /// Record one atomic local commit (§7.1) and apply it optimistically.
7099    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
7100        if mutations.is_empty() {
7101            return Err("a commit must contain at least one operation (§6.1)".to_owned());
7102        }
7103        let mut ops = Vec::with_capacity(mutations.len());
7104        for mutation in mutations {
7105            match mutation {
7106                Mutation::Upsert {
7107                    table,
7108                    values,
7109                    base_version,
7110                } => {
7111                    let schema_table = self
7112                        .schema
7113                        .table(&table)
7114                        .ok_or_else(|| format!("unknown table {table:?}"))?;
7115                    // §5: value keys are accepted in snake_case AND the
7116                    // generated row types' camelCase; normalize to SQL truth
7117                    // before the pk lookup / codec see them.
7118                    let values = normalize_values_casing(schema_table, values)?;
7119                    let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
7120                    // §6.7 `mutate` marks every column present: fill missing
7121                    // nullable columns with NULL before the payload is built.
7122                    let values = full_row_values(schema_table, values)?;
7123                    // Validate the payload encodes with the current codec
7124                    // (and, §5.11, that the encrypt seam has its keys).
7125                    encode_sparse_row_json(schema_table, &row_id, &values, &self.encryption)?;
7126                    ops.push(OutboxOp {
7127                        upsert: true,
7128                        table,
7129                        row_id,
7130                        base_version,
7131                        values: Some(values),
7132                    });
7133                }
7134                Mutation::Delete {
7135                    table,
7136                    row_id,
7137                    base_version,
7138                } => {
7139                    if self.schema.table(&table).is_none() {
7140                        return Err(format!("unknown table {table:?}"));
7141                    }
7142                    ops.push(OutboxOp {
7143                        upsert: false,
7144                        table,
7145                        row_id,
7146                        base_version,
7147                        values: None,
7148                    });
7149                }
7150            }
7151        }
7152        self.record_outbox_commit(ops)
7153    }
7154
7155    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
7156        let commit = OutboxCommit {
7157            client_commit_id: uuid::Uuid::new_v4().to_string(),
7158            ops,
7159        };
7160        self.begin_observation("syncular_mutation")?;
7161        let mut batch = ChangeAccumulator::default();
7162        for op in &commit.ops {
7163            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
7164            if let Some(values) = &op.values {
7165                if let Some(table) = self.schema.table(&op.table) {
7166                    for scope in &table.scope_variables {
7167                        if let Some(Value::String(value)) = values.get(&scope.column) {
7168                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
7169                            precise = true;
7170                        }
7171                    }
7172                }
7173            }
7174            if !precise {
7175                batch.table(&op.table);
7176            }
7177        }
7178        if let Err(error) = self.persist_outbox_insert(&commit) {
7179            self.rollback_observation("syncular_mutation");
7180            return Err(error);
7181        }
7182        let was_dirty = self.overlay_dirty.get();
7183        // A clean overlay already contains the FIFO fold of every previous
7184        // commit. Appending a commit only needs its own operations applied.
7185        if !was_dirty {
7186            self.apply_outbox_ops(&commit.ops);
7187        }
7188        let id = commit.client_commit_id.clone();
7189        self.outbox.push(commit);
7190        if was_dirty {
7191            self.rebuild_overlay();
7192        }
7193        batch.status = true;
7194        if let Err(error) = self.finish_observation("syncular_mutation", batch) {
7195            self.rollback_observation("syncular_mutation");
7196            self.outbox.pop();
7197            self.overlay_dirty.set(was_dirty);
7198            return Err(error);
7199        }
7200        Ok(id)
7201    }
7202
7203    /// Record one §6.1 sparse upsert: the primary key plus the supplied
7204    /// non-scope columns are present; every other column is absent and stays
7205    /// untouched on the server and in the local overlay. The row need not be
7206    /// locally present — an absent local row leaves the overlay absent (§7.1)
7207    /// and the server answers per §5.2.
7208    pub fn patch(
7209        &mut self,
7210        table: &str,
7211        row_id: &str,
7212        partial: Map<String, Value>,
7213        base_version: Option<i64>,
7214    ) -> Result<String, String> {
7215        let schema_table = self
7216            .schema
7217            .table(table)
7218            .ok_or_else(|| format!("unknown table {table:?}"))?;
7219        let partial = normalize_values_casing(schema_table, partial)?;
7220        if let Some(pk) = partial.get(&schema_table.primary_key) {
7221            if pk.as_str() != Some(row_id) {
7222                return Err(format!(
7223                    "sync.invalid_request: table {table:?}: patch cannot change the primary key"
7224                ));
7225            }
7226        }
7227        // User's scope columns are server-emitted only (§3.4); a client patch
7228        // never carries one.
7229        for scope in &schema_table.scope_variables {
7230            if partial.contains_key(&scope.column) {
7231                return Err(format!(
7232                    "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
7233                    scope.column
7234                ));
7235            }
7236        }
7237        let mut values = partial;
7238        values.insert(
7239            schema_table.primary_key.clone(),
7240            Value::from(row_id.to_owned()),
7241        );
7242        encode_sparse_row_json(schema_table, row_id, &values, &self.encryption)?;
7243        self.record_outbox_commit(vec![OutboxOp {
7244            upsert: true,
7245            table: table.to_owned(),
7246            row_id: row_id.to_owned(),
7247            base_version,
7248            values: Some(values),
7249        }])
7250    }
7251
7252    pub fn pending_commit_ids(&self) -> Vec<String> {
7253        self.outbox
7254            .iter()
7255            .map(|c| c.client_commit_id.clone())
7256            .collect()
7257    }
7258
7259    /// §6.1: the sparse push payload bytes of every pending upsert operation,
7260    /// FIFO — exactly what the next push round would put on the wire. Used by
7261    /// conformance B.21(h) to pin cross-core byte identity.
7262    pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
7263        let mut payloads = Vec::new();
7264        for commit in &self.outbox {
7265            for op in &commit.ops {
7266                let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
7267                    continue;
7268                };
7269                let Some(table) = self.schema.table(&op.table) else {
7270                    continue;
7271                };
7272                if let Ok(payload) =
7273                    encode_sparse_row_json(table, &op.row_id, values, &self.encryption)
7274                {
7275                    payloads.push(payload);
7276                }
7277            }
7278        }
7279        payloads
7280    }
7281
7282    pub fn conflicts(&self) -> &[ConflictRecord] {
7283        &self.conflicts
7284    }
7285
7286    pub fn rejections(&self) -> &[RejectionRecord] {
7287        &self.rejections
7288    }
7289
7290    pub fn resolve_commit_outcome(
7291        &mut self,
7292        input: ResolveCommitOutcomeInput,
7293    ) -> Result<CommitOutcome, String> {
7294        let current = self
7295            .commit_outcome(&input.client_commit_id)?
7296            .ok_or_else(|| {
7297                format!(
7298                    "sync.outcome_not_found: no durable outcome exists for {:?}",
7299                    input.client_commit_id
7300                )
7301            })?;
7302        if current.resolution != CommitOutcomeResolution::Active {
7303            return Ok(current);
7304        }
7305        if input.resolution == CommitOutcomeResolution::Active {
7306            return Err("sync.invalid_request: resolution must leave active state".to_owned());
7307        }
7308        match input.resolution {
7309            CommitOutcomeResolution::Superseded => {
7310                let replacement = input
7311                    .replacement_client_commit_id
7312                    .as_deref()
7313                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
7314                    .ok_or_else(|| {
7315                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
7316                            .to_owned()
7317                    })?;
7318                let _ = replacement;
7319            }
7320            _ if input.replacement_client_commit_id.is_some() => {
7321                return Err(
7322                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
7323                        .to_owned(),
7324                );
7325            }
7326            _ => {}
7327        }
7328        let allowed = match current.status {
7329            CommitOutcomeStatus::Conflict => matches!(
7330                input.resolution,
7331                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
7332            ),
7333            CommitOutcomeStatus::Rejected => {
7334                input.resolution == CommitOutcomeResolution::Superseded
7335            }
7336            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
7337                input.resolution == CommitOutcomeResolution::Dismissed
7338            }
7339        };
7340        if !allowed {
7341            return Err(format!(
7342                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
7343                input.resolution, current.status
7344            ));
7345        }
7346
7347        self.begin_observation("syncular_outcome_resolution")?;
7348        let result = (|| {
7349            self.conn
7350                .execute(
7351                    "UPDATE _syncular_commit_outcomes
7352                        SET resolution = ?1, resolved_at_ms = ?2,
7353                            replacement_client_commit_id = ?3
7354                      WHERE client_commit_id = ?4 AND resolution = 'active'",
7355                    rusqlite::params![
7356                        Self::outcome_resolution_name(input.resolution),
7357                        self.clock_now_ms(),
7358                        input.replacement_client_commit_id,
7359                        input.client_commit_id
7360                    ],
7361                )
7362                .map_err(|error| error.to_string())?;
7363            let resolved = self
7364                .commit_outcome(&current.client_commit_id)?
7365                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
7366            self.prune_commit_outcomes()?;
7367            Ok(resolved)
7368        })();
7369        let resolved = match result {
7370            Ok(outcome) => outcome,
7371            Err(error) => {
7372                self.rollback_observation("syncular_outcome_resolution");
7373                return Err(error);
7374            }
7375        };
7376        self.conflicts
7377            .retain(|record| record.client_commit_id != current.client_commit_id);
7378        self.rejections
7379            .retain(|record| record.client_commit_id != current.client_commit_id);
7380        let batch = ChangeAccumulator {
7381            conflicts: current.status == CommitOutcomeStatus::Conflict,
7382            rejections: current.status == CommitOutcomeStatus::Rejected,
7383            outcomes: true,
7384            ..ChangeAccumulator::default()
7385        };
7386        if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
7387            self.rollback_observation("syncular_outcome_resolution");
7388            return Err(error);
7389        }
7390        Ok(resolved)
7391    }
7392
7393    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
7394        self.schema_floor.as_ref()
7395    }
7396
7397    /// §7.3.5: the client's opaque auth-lease state, if any.
7398    pub fn lease_state(&self) -> Option<&LeaseState> {
7399        self.lease_state.as_ref()
7400    }
7401
7402    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
7403    /// the two lease codes set it; other errors leave leaseState untouched.
7404    fn record_lease_error(&mut self, code: &str) {
7405        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
7406            return;
7407        }
7408        let mut next = self.lease_state.clone().unwrap_or_default();
7409        next.error_code = Some(code.to_owned());
7410        self.set_lease_state(Some(next));
7411    }
7412
7413    fn set_lease_state(&mut self, next: Option<LeaseState>) {
7414        if self.lease_state == next {
7415            return;
7416        }
7417        if self.begin_observation("syncular_lease").is_err() {
7418            return;
7419        }
7420        self.lease_state = next;
7421        if let Some(lease) = &self.lease_state {
7422            if let Ok(json) = serde_json::to_string(lease) {
7423                self.set_meta(LEASE_STATE_KEY, &json);
7424            }
7425        } else {
7426            self.delete_meta(LEASE_STATE_KEY);
7427        }
7428        let batch = ChangeAccumulator {
7429            status: true,
7430            ..ChangeAccumulator::default()
7431        };
7432        if self.finish_observation("syncular_lease", batch).is_err() {
7433            self.rollback_observation("syncular_lease");
7434        }
7435    }
7436
7437    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
7438        if self.schema_floor == next {
7439            return;
7440        }
7441        if self.begin_observation("syncular_schema_floor").is_err() {
7442            return;
7443        }
7444        self.schema_floor = next;
7445        self.stopped = self.schema_floor.is_some();
7446        if let Some(floor) = &self.schema_floor {
7447            if let Ok(json) = serde_json::to_string(floor) {
7448                self.set_meta(SCHEMA_FLOOR_KEY, &json);
7449            }
7450        } else {
7451            self.delete_meta(SCHEMA_FLOOR_KEY);
7452        }
7453        let batch = ChangeAccumulator {
7454            status: true,
7455            ..ChangeAccumulator::default()
7456        };
7457        if self
7458            .finish_observation("syncular_schema_floor", batch)
7459            .is_err()
7460        {
7461            self.rollback_observation("syncular_schema_floor");
7462        }
7463    }
7464
7465    fn set_upgrading(&mut self, value: bool) {
7466        if self.upgrading == value {
7467            return;
7468        }
7469        if self.begin_observation("syncular_upgrading").is_err() {
7470            return;
7471        }
7472        self.upgrading = value;
7473        let batch = ChangeAccumulator {
7474            status: true,
7475            ..ChangeAccumulator::default()
7476        };
7477        if self
7478            .finish_observation("syncular_upgrading", batch)
7479            .is_err()
7480        {
7481            self.rollback_observation("syncular_upgrading");
7482        }
7483    }
7484
7485    pub fn sync_needed(&self) -> bool {
7486        self.sync_needed
7487    }
7488
7489    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
7490        let sub = self.subs.iter().find(|s| s.id == id)?;
7491        Some(SubscriptionStateView {
7492            id: sub.id.clone(),
7493            table: sub.table.clone(),
7494            status: sub.state.name().to_owned(),
7495            cursor: sub.cursor,
7496            has_resume_token: sub.bootstrap_state.is_some(),
7497            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
7498            reason_code: sub.reason_code.clone(),
7499        })
7500    }
7501
7502    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
7503        let schema_table = self
7504            .schema
7505            .table(table)
7506            .ok_or_else(|| format!("unknown table {table:?}"))?;
7507        let sql = format!(
7508            "SELECT * FROM {} ORDER BY {} ASC",
7509            visible_table(table),
7510            quote_ident(&schema_table.primary_key)
7511        );
7512        let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?;
7513        let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
7514        let mut out = Vec::new();
7515        while let Some(row) = rows.next().map_err(|e| e.to_string())? {
7516            let mut values = Map::new();
7517            for (i, column) in schema_table.columns.iter().enumerate() {
7518                let value = row.get_ref(i).map_err(|e| e.to_string())?;
7519                values.insert(column.name.clone(), sql_ref_to_json(column, value));
7520            }
7521            let version: i64 = row
7522                .get(schema_table.columns.len())
7523                .map_err(|e| e.to_string())?;
7524            let row_id = match values.get(&schema_table.primary_key) {
7525                Some(Value::String(s)) => s.clone(),
7526                Some(Value::Number(n)) => n.to_string(),
7527                Some(Value::Bool(b)) => b.to_string(),
7528                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
7529            };
7530            out.push(RowState {
7531                row_id,
7532                version,
7533                values,
7534            });
7535        }
7536        Ok(out)
7537    }
7538
7539    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
7540    //
7541    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
7542    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
7543    // the op with `yrs`, re-encodes the whole doc state, and records a
7544    // baseVersion-less sparse upsert carrying only the primary key and the
7545    // crdt column (§5.10.3, §6.6). No local merge — merging is server-side;
7546    // the overlay re-materializes the edit immediately (optimistic apply,
7547    // §7.1) and the server-merged bytes arrive on the next pull, idempotently.
7548    // Byte-compatible with `@syncular/crdt-yjs`.
7549
7550    /// The current stored value of a `crdt` column for one row — the visible
7551    /// (optimistic) bytes, or `None` when the row is absent or the column is
7552    /// NULL (the empty document, §5.10.1). Errors if the column is not a
7553    /// `crdt` column (guards the app against a typo'd column name).
7554    #[cfg(feature = "crdt-yjs")]
7555    fn crdt_column_bytes(
7556        &self,
7557        table: &str,
7558        row_id: &str,
7559        column: &str,
7560    ) -> Result<Option<Vec<u8>>, String> {
7561        let schema_table = self
7562            .schema
7563            .table(table)
7564            .ok_or_else(|| format!("unknown table {table:?}"))?;
7565        let col = schema_table
7566            .columns
7567            .iter()
7568            .find(|c| c.name == column)
7569            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
7570        if col.ty != ColumnType::Crdt {
7571            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
7572        }
7573        let sql = format!(
7574            "SELECT {} FROM {} WHERE {}",
7575            quote_ident(column),
7576            visible_table(table),
7577            row_id_predicate(schema_table)
7578        );
7579        let bytes: Option<Vec<u8>> = self
7580            .conn
7581            .prepare_cached(&sql)
7582            .map_err(|error| error.to_string())?
7583            .query_row(rusqlite::params![row_id], |row| {
7584                row.get::<_, Option<Vec<u8>>>(0)
7585            })
7586            .map_err(|e| match e {
7587                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
7588                other => other.to_string(),
7589            })?;
7590        Ok(bytes)
7591    }
7592
7593    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
7594    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
7595    /// An absent row / NULL column is the empty document (empty string).
7596    #[cfg(feature = "crdt-yjs")]
7597    pub fn crdt_text(
7598        &self,
7599        table: &str,
7600        row_id: &str,
7601        column: &str,
7602        name: &str,
7603    ) -> Result<String, String> {
7604        let bytes = self
7605            .crdt_column_bytes(table, row_id, column)?
7606            .unwrap_or_default();
7607        crate::crdt::text(&bytes, name)
7608    }
7609
7610    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and
7611    /// record the resulting crdt-only sparse operation. Returns the enqueued
7612    /// `clientCommitId`.
7613    #[cfg(feature = "crdt-yjs")]
7614    pub fn crdt_insert_text(
7615        &mut self,
7616        table: &str,
7617        row_id: &str,
7618        column: &str,
7619        name: &str,
7620        index: u32,
7621        value: &str,
7622    ) -> Result<String, String> {
7623        let current = self
7624            .crdt_column_bytes(table, row_id, column)?
7625            .unwrap_or_default();
7626        let update = crate::crdt::insert_text(&current, name, index, value)?;
7627        self.crdt_push_update(table, row_id, column, &update)
7628    }
7629
7630    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and
7631    /// record the resulting crdt-only sparse operation. Returns the enqueued
7632    /// `clientCommitId`.
7633    #[cfg(feature = "crdt-yjs")]
7634    pub fn crdt_delete_text(
7635        &mut self,
7636        table: &str,
7637        row_id: &str,
7638        column: &str,
7639        name: &str,
7640        index: u32,
7641        len: u32,
7642    ) -> Result<String, String> {
7643        let current = self
7644            .crdt_column_bytes(table, row_id, column)?
7645            .unwrap_or_default();
7646        let update = crate::crdt::delete_text(&current, name, index, len)?;
7647        self.crdt_push_update(table, row_id, column, &update)
7648    }
7649
7650    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
7651    /// `crdt` column's current state and record the resulting crdt-only sparse
7652    /// operation. The app authored the update with its own `yrs` model.
7653    /// Returns the enqueued `clientCommitId`.
7654    #[cfg(feature = "crdt-yjs")]
7655    pub fn crdt_apply_update(
7656        &mut self,
7657        table: &str,
7658        row_id: &str,
7659        column: &str,
7660        update: &[u8],
7661    ) -> Result<String, String> {
7662        let current = self
7663            .crdt_column_bytes(table, row_id, column)?
7664            .unwrap_or_default();
7665        let next = crate::crdt::apply_update(&current, update)?;
7666        self.crdt_push_update(table, row_id, column, &next)
7667    }
7668
7669    /// Shared tail of the crdt edit methods: record a §6.1 sparse crdt-only
7670    /// upsert — the primary key plus the crdt column, no `baseVersion` — and
7671    /// enqueue it. The server merges the crdt column and leaves every other
7672    /// column untouched (§5.10.3), so a baseVersion-less crdt edit cannot
7673    /// clobber a concurrent edit to another column. A locally absent row
7674    /// records the same partial operation; the server answers per §6.2.
7675    #[cfg(feature = "crdt-yjs")]
7676    fn crdt_push_update(
7677        &mut self,
7678        table: &str,
7679        row_id: &str,
7680        column: &str,
7681        crdt_bytes: &[u8],
7682    ) -> Result<String, String> {
7683        let mut bytes_obj = Map::new();
7684        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
7685        let mut partial = Map::new();
7686        partial.insert(column.to_owned(), Value::Object(bytes_obj));
7687        self.patch(table, row_id, partial, None)
7688    }
7689
7690    /// Run an arbitrary read-only SQL query against the local database and
7691    /// return each row as a `column-name → JSON value` map. This is the seam
7692    /// the React `useSyncQuery` live-query API needs (it takes app-authored
7693    /// SQL over the visible tables/views, not a fixed table read like
7694    /// [`read_rows`]).
7695    ///
7696    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
7697    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
7698    /// same envelope the command surface uses everywhere else. Output BLOB
7699    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
7700    ///
7701    /// The result column typing is dynamic (SQLite's stored affinity), because
7702    /// arbitrary SQL can alias, join, and compute — there is no schema column
7703    /// to consult per output cell, unlike [`read_rows`].
7704    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
7705        query_connection(&self.conn, sql, params)
7706    }
7707
7708    /// Repository benchmark and conformance access, present only with `bench-internals`.
7709    #[cfg(feature = "bench-internals")]
7710    #[doc(hidden)]
7711    pub fn benchmark_connection(&mut self) -> &mut Connection {
7712        &mut self.conn
7713    }
7714
7715    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
7716    #[cfg(feature = "bench-internals")]
7717    #[doc(hidden)]
7718    pub fn benchmark_phases(
7719        &self,
7720        enabled: Option<bool>,
7721        reset: bool,
7722    ) -> Result<Option<Value>, String> {
7723        self.benchmark_phases.configure(enabled, reset)?;
7724        Ok(self.benchmark_phases.snapshot())
7725    }
7726
7727    /// Rows, coverage, and local revision from one SQLite read snapshot.
7728    pub fn query_snapshot(
7729        &mut self,
7730        sql: &str,
7731        params: &[QueryValue],
7732        coverage: &[WindowCoverage],
7733    ) -> Result<QuerySnapshot, String> {
7734        snapshot_connection(&self.conn, sql, params, coverage)
7735    }
7736
7737    // -- request building ---------------------------------------------------------
7738
7739    fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
7740        #[cfg(feature = "bench-internals")]
7741        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
7742        let log_epoch = self.get_meta(LOG_EPOCH_KEY);
7743        let mut frames = vec![Frame::ReqHeader {
7744            client_id: self.client_id.clone(),
7745            schema_version: self.schema.version,
7746            log_epoch: log_epoch.clone(),
7747        }];
7748        let mut pushed_ids = Vec::new();
7749        let mut ops_in_request = 0usize;
7750        let mut deferred_commits = 0usize;
7751        #[cfg(feature = "bench-internals")]
7752        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
7753        for (index, commit) in self
7754            .outbox
7755            .iter()
7756            .take(if log_epoch.is_some() {
7757                self.outbox.len()
7758            } else {
7759                0
7760            })
7761            .enumerate()
7762        {
7763            // §6.1 splitBatch: stop at the operation cap — commits apply in
7764            // order, so everything from the first non-fitting commit on is
7765            // deferred to the next round. A single over-cap commit still goes
7766            // alone (commits are atomic and cannot be split).
7767            if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
7768                deferred_commits = self.outbox.len() - index;
7769                break;
7770            }
7771            ops_in_request += commit.ops.len();
7772            let operations = commit
7773                .ops
7774                .iter()
7775                .map(|op| {
7776                    let payload = op.values.as_ref().and_then(|values| {
7777                        let table = self.schema.table(&op.table)?;
7778                        // §0: outbox entries encode at send time with the
7779                        // current codec (validated at mutate()). §6.1: the
7780                        // values map's keys are the sparse presence set.
7781                        // §5.11: encrypted columns are encrypted here.
7782                        encode_sparse_row_json(table, &op.row_id, values, &self.encryption).ok()
7783                    });
7784                    ssp2::model::Operation {
7785                        table: op.table.clone(),
7786                        row_id: op.row_id.clone(),
7787                        op: if op.upsert { Op::Upsert } else { Op::Delete },
7788                        base_version: op.base_version,
7789                        payload,
7790                    }
7791                })
7792                .collect();
7793            frames.push(Frame::PushCommit {
7794                client_commit_id: commit.client_commit_id.clone(),
7795                operations,
7796            });
7797            pushed_ids.push(commit.client_commit_id.clone());
7798        }
7799        #[cfg(feature = "bench-internals")]
7800        drop(outbox_phase);
7801        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
7802        // bare URL — capability negotiation, decided per transport.
7803        let accept = self.limits.accept.unwrap_or(if url_capable {
7804            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
7805        } else {
7806            DEFAULT_ACCEPT
7807        });
7808        frames.push(Frame::PullHeader {
7809            limit_commits: self.limits.limit_commits.unwrap_or(0),
7810            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
7811            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
7812            accept,
7813        });
7814        let mut fresh = Vec::new();
7815        for sub in &self.subs {
7816            if sub.state != SubState::Active {
7817                continue;
7818            }
7819            let mut scopes = sub.requested.clone();
7820            sort_scope_map(&mut scopes);
7821            frames.push(Frame::Subscription {
7822                id: sub.id.clone(),
7823                table: sub.table.clone(),
7824                scopes,
7825                params: sub.params.clone().map(RawJson),
7826                cursor: sub.cursor,
7827                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
7828            });
7829            fresh.push((
7830                sub.id.clone(),
7831                sub.cursor < 0 && sub.bootstrap_state.is_none(),
7832            ));
7833        }
7834        let message = Message {
7835            wire_version: WIRE_VERSION,
7836            msg_kind: MsgKind::Request,
7837            frames,
7838        };
7839        (
7840            message,
7841            RequestMeta {
7842                pushed_ids,
7843                fresh,
7844                accept,
7845                deferred_commits,
7846            },
7847        )
7848    }
7849
7850    // -- sync -------------------------------------------------------------------
7851
7852    /// Cloneable observation handle; no client lock is needed to read it.
7853    pub fn progress(&self) -> ProgressObserver {
7854        self.progress.clone()
7855    }
7856
7857    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
7858        self.progress.start();
7859        let started_at_ms = self.clock_now_ms();
7860        let outcome = self.sync_inner(transport);
7861        self.progress.update(|p| match &outcome {
7862            SyncOutcome::Ok(report) if report.failed.is_empty() => {
7863                p.state = ProgressState::Complete
7864            }
7865            SyncOutcome::Ok(_) => {
7866                p.state = ProgressState::Failed;
7867                p.error_code = Some("sync.scope_revoked".into());
7868            }
7869            SyncOutcome::Failed { error_code, .. } => {
7870                p.state = ProgressState::Failed;
7871                p.error_code = Some(Self::diagnostic_code(error_code));
7872            }
7873        });
7874        let completed_at_ms = self.clock_now_ms();
7875        self.last_round = Some(match &outcome {
7876            SyncOutcome::Ok(report) => DiagnosticLastRound {
7877                status: "succeeded".to_owned(),
7878                started_at_ms,
7879                completed_at_ms,
7880                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
7881                counters: Some(DiagnosticRoundCounters {
7882                    pushed: report.pushed,
7883                    applied: report.applied.len(),
7884                    rejected: report.rejected.len(),
7885                    retryable: report.retryable.len(),
7886                    conflicts: report.conflicts,
7887                    commits_applied: report.commits_applied,
7888                    segment_rows_applied: report.segment_rows_applied,
7889                    bootstrapping: report.bootstrapping.len(),
7890                    resets: report.resets.len(),
7891                    revoked: report.revoked.len(),
7892                    failed: report.failed.len(),
7893                    deferred_commits: report.deferred_commits,
7894                }),
7895                error_code: None,
7896            },
7897            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
7898                status: "failed".to_owned(),
7899                started_at_ms,
7900                completed_at_ms,
7901                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
7902                counters: None,
7903                error_code: Some(Self::diagnostic_code(error_code)),
7904            },
7905        });
7906        outcome
7907    }
7908
7909    fn sync_inner(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
7910        if self.stopped {
7911            // §1.6: the client stopped at the schema floor; syncing is inert
7912            // until an upgrade. The outbox is preserved for replay.
7913            return SyncOutcome::Ok(SyncReport {
7914                schema_floor: self.schema_floor.clone(),
7915                ..SyncReport::default()
7916            });
7917        }
7918        // §8.4: the coalesced sync-needed signal clears when a pull round
7919        // BEGINS, so a wake-up landing mid-round survives it.
7920        self.set_sync_needed(false, false);
7921        // §5.9.7 B4: upload pending blobs before pushing the referencing
7922        // rows, so the server-side existence check (§6.6) passes.
7923        if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
7924            if let Err(TransportError { code, message }) = self.flush_blob_uploads(transport) {
7925                if Self::retryable_transport_code(&code) {
7926                    self.schedule_background_retry();
7927                }
7928                return SyncOutcome::Failed {
7929                    error_code: code,
7930                    message,
7931                };
7932            }
7933        }
7934        if let Err(message) = self.drain_pending_evictions() {
7935            return SyncOutcome::Failed {
7936                error_code: "storage.failed".into(),
7937                message,
7938            };
7939        }
7940        let (message, meta) = self.build_request(transport.supports_url_fetch());
7941        let request_bytes = {
7942            #[cfg(feature = "bench-internals")]
7943            let _phase = self.benchmark_phases.start(Phase::RequestEncode);
7944            encode_message(&message)
7945        };
7946        // §8.7: rounds ride the socket whenever it is connected (one
7947        // loop, no fallback pair); the transport seam stays bytes-in /
7948        // bytes-out either way. Registration-at-round-end is server-side.
7949        let round = if self.realtime_connected {
7950            transport.realtime_sync(&request_bytes)
7951        } else {
7952            transport.sync(&request_bytes)
7953        };
7954        let response_bytes = match round {
7955            Ok(bytes) => bytes,
7956            Err(TransportError { code, message }) => {
7957                // §7.3.5: a request-level lease code stops-and-surfaces —
7958                // record it in leaseState (no local-data purge, §7.3.4).
7959                self.record_lease_error(&code);
7960                if Self::retryable_transport_code(&code) {
7961                    self.schedule_background_retry();
7962                }
7963                return SyncOutcome::Failed {
7964                    error_code: code,
7965                    message,
7966                };
7967            }
7968        };
7969        #[cfg(feature = "bench-internals")]
7970        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
7971        let response = match decode_message(&response_bytes) {
7972            Ok(message) => message,
7973            Err(error) => {
7974                // §1.2 rule 1 / §1.4 rule 5: truncated or malformed
7975                // responses abort without persisting anything.
7976                return SyncOutcome::Failed {
7977                    error_code: error.code.as_str().to_owned(),
7978                    message: error.detail,
7979                };
7980            }
7981        };
7982        #[cfg(feature = "bench-internals")]
7983        drop(decode_phase);
7984        if response.msg_kind != MsgKind::Response {
7985            return SyncOutcome::Failed {
7986                error_code: "sync.invalid_request".to_owned(),
7987                message: "expected a response message".to_owned(),
7988            };
7989        }
7990        let mut outcome = self.process_response(transport, response, &meta);
7991        if let SyncOutcome::Ok(report) = &mut outcome {
7992            report.deferred_commits = meta.deferred_commits;
7993        }
7994        match &outcome {
7995            SyncOutcome::Ok(_) => self.reset_background_retry(),
7996            SyncOutcome::Failed { error_code, .. }
7997                if Self::retryable_transport_code(error_code) =>
7998            {
7999                self.schedule_background_retry();
8000            }
8001            SyncOutcome::Failed { .. } => {}
8002        }
8003        if meta.deferred_commits > 0 {
8004            // §6.1 splitBatch: commits past the operation cap wait for the
8005            // next round — keep the host's sync signal raised until then.
8006            self.set_sync_needed(true, true);
8007        }
8008        outcome
8009    }
8010
8011    pub fn sync_until_idle(
8012        &mut self,
8013        transport: &mut dyn Transport,
8014        max_rounds: Option<u32>,
8015    ) -> SyncOutcome {
8016        let rounds = max_rounds.unwrap_or(20).max(1);
8017        let mut aggregate = SyncReport::default();
8018        let mut spent = 0;
8019        while spent < rounds {
8020            spent += 1;
8021            let before = self
8022                .subs
8023                .iter()
8024                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
8025                .collect::<Vec<_>>();
8026            match self.sync(transport) {
8027                SyncOutcome::Failed {
8028                    error_code,
8029                    message,
8030                } => {
8031                    return SyncOutcome::Failed {
8032                        error_code,
8033                        message,
8034                    };
8035                }
8036                SyncOutcome::Ok(report) => {
8037                    if max_rounds.is_none()
8038                        && report.segment_rows_applied > 0
8039                        && !report.bootstrapping.is_empty()
8040                        && before
8041                            != self
8042                                .subs
8043                                .iter()
8044                                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
8045                                .collect::<Vec<_>>()
8046                    {
8047                        spent = 0;
8048                    }
8049                    aggregate.pushed += report.pushed;
8050                    aggregate.applied.extend(report.applied.iter().cloned());
8051                    aggregate.rejected.extend(report.rejected.iter().cloned());
8052                    aggregate.retryable.extend(report.retryable.iter().cloned());
8053                    aggregate.conflicts += report.conflicts;
8054                    aggregate.commits_applied += report.commits_applied;
8055                    aggregate.segment_rows_applied += report.segment_rows_applied;
8056                    aggregate.bootstrapping = report.bootstrapping.clone();
8057                    aggregate.resets.extend(report.resets.iter().cloned());
8058                    aggregate.revoked.extend(report.revoked.iter().cloned());
8059                    aggregate.failed.extend(report.failed.iter().cloned());
8060                    aggregate.deferred_commits = report.deferred_commits;
8061                    if report.schema_floor.is_some() {
8062                        aggregate.schema_floor = report.schema_floor.clone();
8063                    }
8064                    // §4.5: pull again whenever the response contained
8065                    // commits or segments; resets re-bootstrap; a pending
8066                    // resume token continues paging (§4.7); a raised
8067                    // sync-needed signal covers §6.1 splitBatch remainders
8068                    // (deferred outbox commits push on the next round).
8069                    let more = !report.bootstrapping.is_empty()
8070                        || report.commits_applied > 0
8071                        || report.segment_rows_applied > 0
8072                        || !report.resets.is_empty()
8073                        || self.sync_needed;
8074                    if !more {
8075                        return SyncOutcome::Ok(aggregate);
8076                    }
8077                }
8078            }
8079        }
8080        SyncOutcome::Failed {
8081            error_code: "sync.invalid_request".into(),
8082            message: "sync did not reach idle within the round budget".into(),
8083        }
8084    }
8085
8086    fn process_response(
8087        &mut self,
8088        transport: &mut dyn Transport,
8089        response: Message,
8090        meta: &RequestMeta,
8091    ) -> SyncOutcome {
8092        #[cfg(feature = "bench-internals")]
8093        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
8094        let mut report = SyncReport {
8095            pushed: meta.pushed_ids.len() as u32,
8096            ..SyncReport::default()
8097        };
8098        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
8099            HashMap::new();
8100        let pushed_ids = meta
8101            .pushed_ids
8102            .iter()
8103            .map(String::as_str)
8104            .collect::<HashSet<_>>();
8105        let mut last_final_push_result_id: Option<String> = None;
8106        for frame in &response.frames {
8107            match frame {
8108                Frame::PushResultDetails {
8109                    client_commit_id,
8110                    entries,
8111                } => {
8112                    let details = rejection_details_by_commit
8113                        .entry(client_commit_id.clone())
8114                        .or_default();
8115                    for entry in entries {
8116                        let parsed = match RejectionDetails::parse(&entry.details.0) {
8117                            Ok(value) => value,
8118                            Err(message) => {
8119                                return SyncOutcome::Failed {
8120                                    error_code: "sync.invalid_request".to_owned(),
8121                                    message,
8122                                };
8123                            }
8124                        };
8125                        details.insert(entry.op_index, parsed);
8126                    }
8127                }
8128                Frame::PushResult {
8129                    client_commit_id,
8130                    status,
8131                    results,
8132                    ..
8133                } if pushed_ids.contains(client_commit_id.as_str())
8134                    && Self::push_result_is_final(*status, results) =>
8135                {
8136                    last_final_push_result_id = Some(client_commit_id.clone());
8137                }
8138                _ => {}
8139            }
8140        }
8141        let mut frames = response.frames.into_iter().peekable();
8142        if response.wire_version < 2 {
8143            return SyncOutcome::Failed {
8144                error_code: "client.invalid_host_response".to_owned(),
8145                message: "server response does not carry wire version 2 log-epoch state".to_owned(),
8146            };
8147        }
8148        match frames.next() {
8149            Some(Frame::RespHeader {
8150                required_schema_version,
8151                latest_schema_version,
8152                log_epoch,
8153                reset_required,
8154            }) => {
8155                let Some(log_epoch) = log_epoch else {
8156                    return SyncOutcome::Failed {
8157                        error_code: "client.invalid_host_response".to_owned(),
8158                        message: "response header omits logEpoch".to_owned(),
8159                    };
8160                };
8161                let Some(reset_required) = reset_required else {
8162                    return SyncOutcome::Failed {
8163                        error_code: "client.invalid_host_response".to_owned(),
8164                        message: "response header omits resetRequired".to_owned(),
8165                    };
8166                };
8167                if let Some(required) = required_schema_version {
8168                    // §1.6 schema-floor response: nothing else is processed;
8169                    // stop syncing and surface the upgrade requirement.
8170                    let floor = SchemaFloor {
8171                        required_schema_version: Some(required),
8172                        latest_schema_version,
8173                    };
8174                    self.set_schema_floor(Some(floor.clone()));
8175                    report.schema_floor = Some(floor);
8176                    return SyncOutcome::Ok(report);
8177                }
8178                if reset_required {
8179                    if frames.next().is_some() {
8180                        return SyncOutcome::Failed {
8181                            error_code: "client.invalid_host_response".to_owned(),
8182                            message: "log-epoch reset response contains body frames".to_owned(),
8183                        };
8184                    }
8185                    match self.run_log_epoch_reset(&log_epoch) {
8186                        Ok(resets) => {
8187                            report.resets = resets;
8188                            return SyncOutcome::Ok(report);
8189                        }
8190                        Err(message) => {
8191                            return SyncOutcome::Failed {
8192                                error_code: "sync.local_corrupt".to_owned(),
8193                                message,
8194                            };
8195                        }
8196                    }
8197                }
8198                if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
8199                    return SyncOutcome::Failed {
8200                        error_code: "client.invalid_host_response".to_owned(),
8201                        message: "server changed logEpoch without requiring a reset".to_owned(),
8202                    };
8203                }
8204            }
8205            _ => {
8206                return SyncOutcome::Failed {
8207                    error_code: "sync.invalid_request".to_owned(),
8208                    message: "response does not start with RESP_HEADER".to_owned(),
8209                };
8210            }
8211        }
8212
8213        let mut failure: Option<(String, String)> = None;
8214        while let Some(frame) = frames.next() {
8215            match frame {
8216                frame @ Frame::PushResult { .. } => {
8217                    let successful = matches!(
8218                        &frame,
8219                        Frame::PushResult {
8220                            status: PushStatus::Applied | PushStatus::Cached,
8221                            ..
8222                        }
8223                    );
8224                    let mut results = vec![frame];
8225                    if successful {
8226                        while matches!(
8227                            frames.peek(),
8228                            Some(Frame::PushResult {
8229                                status: PushStatus::Applied | PushStatus::Cached,
8230                                ..
8231                            })
8232                        ) {
8233                            if let Some(next) = frames.next() {
8234                                results.push(next);
8235                            }
8236                        }
8237                    }
8238                    if self
8239                        .handle_push_results(
8240                            &results,
8241                            &pushed_ids,
8242                            &rejection_details_by_commit,
8243                            &mut report,
8244                            last_final_push_result_id.as_deref(),
8245                        )
8246                        .is_err()
8247                    {
8248                        failure = Some((
8249                            "client.outcome_persistence_failed".to_owned(),
8250                            "local commit outcome could not be persisted".to_owned(),
8251                        ));
8252                        break;
8253                    }
8254                }
8255                Frame::PushResultDetails { .. } => {}
8256                Frame::SubStart {
8257                    id,
8258                    status,
8259                    reason_code,
8260                    effective_scopes,
8261                    bootstrap: _,
8262                } => {
8263                    let mut body = Vec::new();
8264                    let mut sub_end: Option<(i64, Option<String>)> = None;
8265                    for inner in frames.by_ref() {
8266                        match inner {
8267                            Frame::SubEnd {
8268                                next_cursor,
8269                                bootstrap_state,
8270                            } => {
8271                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
8272                                break;
8273                            }
8274                            Frame::Unknown { .. } => {}
8275                            other => body.push(other),
8276                        }
8277                    }
8278                    if let Err(SectionError::Abort(code, message)) = self.process_section(
8279                        transport,
8280                        &id,
8281                        status,
8282                        &reason_code,
8283                        effective_scopes,
8284                        body,
8285                        sub_end,
8286                        meta,
8287                        &mut report,
8288                    ) {
8289                        failure = Some((code, message));
8290                        break;
8291                    }
8292                }
8293                Frame::Lease {
8294                    lease_id,
8295                    expires_at_ms,
8296                } => {
8297                    // §7.3.5: persist the opaque lease; a fresh lease clears
8298                    // any prior lease error (the outage/revocation is over).
8299                    self.set_lease_state(Some(LeaseState {
8300                        lease_id: Some(lease_id),
8301                        expires_at_ms: Some(expires_at_ms),
8302                        error_code: None,
8303                    }));
8304                }
8305                Frame::Error { code, message, .. } => {
8306                    // §1.6: the whole request failed; already-completed
8307                    // subscriptions keep their applied data and cursors.
8308                    failure = Some((code, message));
8309                    break;
8310                }
8311                Frame::Unknown { .. } => {}
8312                _ => {
8313                    failure = Some((
8314                        "sync.invalid_request".to_owned(),
8315                        "unexpected frame in response".to_owned(),
8316                    ));
8317                    break;
8318                }
8319            }
8320        }
8321
8322        // §7.1: reconcile the visible overlay once at the response boundary
8323        // after all outbox acknowledgements and server data applied so a
8324        // batch of PUSH_RESULT frames cannot trigger repeated full-table
8325        // rebuilds. This also covers a round that aborted mid-way.
8326        if self.overlay_dirty.get() {
8327            self.rebuild_overlay();
8328        }
8329        if let Some((error_code, message)) = failure {
8330            return SyncOutcome::Failed {
8331                error_code,
8332                message,
8333            };
8334        }
8335        // §4.8 E1: the push half may have drained commits that pinned rows of
8336        // a shrunk window unit — retry any deferred evictions now.
8337        if let Err(message) = self.drain_pending_evictions() {
8338            return SyncOutcome::Failed {
8339                error_code: "storage.failed".into(),
8340                message,
8341            };
8342        }
8343        self.ack_after_pull(transport);
8344        // §7.4.5: the reset is over once the first post-reset pull round
8345        // leaves no subscription mid-bootstrap — the tables are rebuilt.
8346        if self.upgrading && report.bootstrapping.is_empty() {
8347            self.set_upgrading(false);
8348        }
8349        SyncOutcome::Ok(report)
8350    }
8351
8352    // -- push results (§6.3, §7.2) ------------------------------------------------
8353
8354    fn handle_push_results(
8355        &mut self,
8356        frames: &[Frame],
8357        pushed_ids: &HashSet<&str>,
8358        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
8359        report: &mut SyncReport,
8360        last_final_push_result_id: Option<&str>,
8361    ) -> Result<(), String> {
8362        self.begin_observation("syncular_push_results")?;
8363        let conflict_count = self.conflicts.len();
8364        let rejection_count = self.rejections.len();
8365        let applied_count = report.applied.len();
8366        let rejected_count = report.rejected.len();
8367        let retryable_count = report.retryable.len();
8368        let was_dirty = self.overlay_dirty.get();
8369        let mut removed = Vec::new();
8370        let mut batch = ChangeAccumulator::default();
8371        let persisted = (|| {
8372            for frame in frames {
8373                let Frame::PushResult {
8374                    client_commit_id,
8375                    status,
8376                    results,
8377                    ..
8378                } = frame
8379                else {
8380                    return Err("unexpected acknowledgement frame".to_owned());
8381                };
8382                if !pushed_ids.contains(client_commit_id.as_str()) {
8383                    continue;
8384                }
8385                let Some(index) = self
8386                    .outbox
8387                    .iter()
8388                    .position(|commit| commit.client_commit_id == *client_commit_id)
8389                else {
8390                    continue;
8391                };
8392                let status = *status;
8393                let rejection_details = rejection_details_by_commit.get(client_commit_id);
8394                let operations = self.outbox[index].ops.clone();
8395                match status {
8396                    PushStatus::Applied | PushStatus::Cached => {
8397                        // §7.2: a lost ack replays as `cached` — proceed as if the
8398                        // ack had arrived.
8399                        let journal_results = results
8400                            .iter()
8401                            .map(|result| {
8402                                let op_index = match result {
8403                                    OpResult::Applied { op_index }
8404                                    | OpResult::Conflict { op_index, .. }
8405                                    | OpResult::Error { op_index, .. } => *op_index,
8406                                };
8407                                CommitOperationOutcome::Applied { op_index }
8408                            })
8409                            .collect::<Vec<_>>();
8410                        let outcome_status = if status == PushStatus::Applied {
8411                            CommitOutcomeStatus::Applied
8412                        } else {
8413                            CommitOutcomeStatus::Cached
8414                        };
8415                        let persisted = self
8416                            .persist_commit_outcome(
8417                                client_commit_id,
8418                                outcome_status,
8419                                &journal_results,
8420                                None,
8421                            )
8422                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
8423                        persisted?;
8424                        self.overlay_dirty.set(true);
8425                        batch.status = true;
8426                        batch.outcomes = true;
8427                    }
8428                    PushStatus::Rejected => {
8429                        if results.iter().any(|result| {
8430                            matches!(
8431                                result,
8432                                OpResult::Error {
8433                                    code,
8434                                    retryable: true,
8435                                    ..
8436                                } if code == "sync.idempotency_cache_miss"
8437                            )
8438                        }) {
8439                            // §6.3/§7.2: a serving failure, not an outcome — keep the
8440                            // exact commit queued for an identical retry.
8441                            report.retryable.push(client_commit_id.to_owned());
8442                            continue;
8443                        }
8444
8445                        let mut journal_results = Vec::with_capacity(results.len());
8446                        let mut conflicts = Vec::new();
8447                        let mut rejections = Vec::new();
8448                        for result in results {
8449                            match result {
8450                                OpResult::Applied { op_index } => {
8451                                    journal_results.push(CommitOperationOutcome::Applied {
8452                                        op_index: *op_index,
8453                                    });
8454                                }
8455                                OpResult::Conflict {
8456                                    op_index,
8457                                    code,
8458                                    message,
8459                                    server_version,
8460                                    server_row,
8461                                    conflict_columns: conflict_columns_bitmap,
8462                                } => {
8463                                    let operation = operations
8464                                        .get(*op_index as usize)
8465                                        .map(CommitOperation::from);
8466                                    let (table, row_id) = operation
8467                                        .as_ref()
8468                                        .map(|op| (op.table.clone(), op.row_id.clone()))
8469                                        .unwrap_or_default();
8470                                    let server_row_json = self
8471                                        .schema
8472                                        .table(&table)
8473                                        .and_then(|t| {
8474                                            decode_row_bytes(t, server_row, &self.encryption)
8475                                                .ok()
8476                                                .map(|row| (t, row))
8477                                        })
8478                                        .map(|(t, row)| {
8479                                            let mut map = Map::new();
8480                                            for (i, column) in t.columns.iter().enumerate() {
8481                                                map.insert(
8482                                                    column.name.clone(),
8483                                                    column_value_to_json(
8484                                                        row.get(i).unwrap_or(&None),
8485                                                    ),
8486                                                );
8487                                            }
8488                                            map
8489                                        })
8490                                        .unwrap_or_default();
8491                                    // §6.3: decode the presence-layout bitmap
8492                                    // into the marked column names.
8493                                    let conflict_columns = self
8494                                        .schema
8495                                        .table(&table)
8496                                        .map(|t| {
8497                                            t.columns
8498                                                .iter()
8499                                                .enumerate()
8500                                                .filter(|(index, _)| {
8501                                                    conflict_columns_bitmap
8502                                                        .get(index / 8)
8503                                                        .is_some_and(|byte| {
8504                                                            byte & (1 << (index % 8)) != 0
8505                                                        })
8506                                                })
8507                                                .map(|(_, column)| column.name.clone())
8508                                                .collect::<Vec<_>>()
8509                                        })
8510                                        .unwrap_or_default();
8511                                    let conflict = ConflictRecord {
8512                                        client_commit_id: client_commit_id.to_owned(),
8513                                        op_index: *op_index,
8514                                        table,
8515                                        row_id,
8516                                        code: code.clone(),
8517                                        message: message.clone(),
8518                                        server_version: *server_version,
8519                                        server_row: server_row_json,
8520                                        conflict_columns,
8521                                        operation,
8522                                    };
8523                                    journal_results.push(CommitOperationOutcome::Conflict {
8524                                        conflict: conflict.clone(),
8525                                    });
8526                                    conflicts.push(conflict);
8527                                }
8528                                OpResult::Error {
8529                                    op_index,
8530                                    code,
8531                                    message,
8532                                    retryable,
8533                                } => {
8534                                    let rejection = RejectionRecord {
8535                                        client_commit_id: client_commit_id.to_owned(),
8536                                        op_index: *op_index,
8537                                        code: code.clone(),
8538                                        message: message.clone(),
8539                                        retryable: *retryable,
8540                                        details: rejection_details
8541                                            .and_then(|details| details.get(op_index))
8542                                            .cloned(),
8543                                        operation: operations
8544                                            .get(*op_index as usize)
8545                                            .map(CommitOperation::from),
8546                                    };
8547                                    journal_results.push(CommitOperationOutcome::Error {
8548                                        rejection: rejection.clone(),
8549                                    });
8550                                    rejections.push(rejection);
8551                                }
8552                            }
8553                        }
8554                        let outcome_status = if conflicts.is_empty() {
8555                            CommitOutcomeStatus::Rejected
8556                        } else {
8557                            CommitOutcomeStatus::Conflict
8558                        };
8559                        let persisted = self
8560                            .persist_commit_outcome(
8561                                client_commit_id,
8562                                outcome_status,
8563                                &journal_results,
8564                                Some(&operations),
8565                            )
8566                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
8567                        persisted?;
8568                        batch.conflicts = !conflicts.is_empty();
8569                        batch.rejections = !rejections.is_empty();
8570                        batch.status = true;
8571                        batch.outcomes = true;
8572                        self.conflicts.extend(conflicts);
8573                        self.rejections.extend(rejections);
8574                        self.overlay_dirty.set(true);
8575                    }
8576                }
8577                removed.push((index, self.outbox.remove(index)));
8578                if batch.status {
8579                    for operation in &operations {
8580                        if !self.record_row_scopes(
8581                            &mut batch,
8582                            &operation.table,
8583                            &operation.row_id,
8584                            false,
8585                        ) {
8586                            batch.table(&operation.table);
8587                        }
8588                    }
8589                }
8590                match status {
8591                    PushStatus::Applied | PushStatus::Cached => {
8592                        report.applied.push(client_commit_id.to_owned())
8593                    }
8594                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
8595                }
8596            }
8597            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)) {
8598                self.prune_commit_outcomes()?;
8599            }
8600            self.finish_observation("syncular_push_results", batch)
8601        })();
8602        if let Err(error) = persisted {
8603            self.rollback_observation("syncular_push_results");
8604            for (index, commit) in removed.into_iter().rev() {
8605                self.outbox.insert(index, commit);
8606            }
8607            self.conflicts.truncate(conflict_count);
8608            self.rejections.truncate(rejection_count);
8609            self.overlay_dirty.set(was_dirty);
8610            report.applied.truncate(applied_count);
8611            report.rejected.truncate(rejected_count);
8612            report.retryable.truncate(retryable_count);
8613            return Err(error);
8614        }
8615        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
8616        Ok(())
8617    }
8618
8619    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
8620        status != PushStatus::Rejected
8621            || !results.iter().any(|result| {
8622                matches!(
8623                    result,
8624                    OpResult::Error {
8625                        code,
8626                        retryable: true,
8627                        ..
8628                    } if code == "sync.idempotency_cache_miss"
8629                )
8630            })
8631    }
8632
8633    // -- subscription sections ------------------------------------------------------
8634
8635    #[allow(clippy::too_many_arguments)]
8636    fn process_section(
8637        &mut self,
8638        transport: &mut dyn Transport,
8639        id: &str,
8640        status: SubStatus,
8641        reason_code: &str,
8642        effective_scopes: Vec<(String, Vec<String>)>,
8643        body: Vec<Frame>,
8644        sub_end: Option<(i64, Option<String>)>,
8645        meta: &RequestMeta,
8646        report: &mut SyncReport,
8647    ) -> Result<(), SectionError> {
8648        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
8649            return Ok(()); // unknown echo: ignore
8650        };
8651        if status != SubStatus::Active && sub_end.is_none() {
8652            return Err(SectionError::Abort(
8653                "sync.invalid_request".into(),
8654                "subscription section without SUB_END".into(),
8655            ));
8656        }
8657        match status {
8658            SubStatus::Revoked => {
8659                self.begin_observation("syncular_revocation")
8660                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
8661                let previous = self.subs[sub_index].clone();
8662                let was_dirty = self.overlay_dirty.get();
8663                let previous_outbox = self.outbox.clone();
8664                let rejection_count = self.rejections.len();
8665                let revoked_count = report.revoked.len();
8666                let failed_count = report.failed.len();
8667                let result =
8668                    (|| {
8669                        let mut batch = ChangeAccumulator::default();
8670                        let registered = self.window_unit_by_sub_id(id);
8671                        // §3.3: stop pulling, purge exactly the last effective grant.
8672                        let (table, effective) = {
8673                            let sub = &self.subs[sub_index];
8674                            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
8675                        };
8676                        let purged = self.purge_scope_rows(&table, &effective);
8677                        match purged {
8678                            Ok(()) => {
8679                                self.record_scope_map(&mut batch, &table, &effective);
8680                                let sub = &mut self.subs[sub_index];
8681                                sub.state = SubState::Revoked;
8682                                sub.reason_code = Some(if reason_code.is_empty() {
8683                                    "sync.scope_revoked".to_owned()
8684                                } else {
8685                                    reason_code.to_owned()
8686                                });
8687                                report.revoked.push(id.to_owned());
8688                                let doomed_effective = effective;
8689                                let sub_table = table;
8690                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
8691                                    |message| SectionError::Abort("storage.failed".into(), message),
8692                                )?;
8693                                let dropped = self
8694                                    .drop_doomed_outbox(&sub_table, &doomed_effective)
8695                                    .map_err(|message| {
8696                                        SectionError::Abort("storage.failed".to_owned(), message)
8697                                    })?;
8698                                if dropped {
8699                                    batch.status = true;
8700                                    batch.rejections = true;
8701                                    batch.outcomes = true;
8702                                }
8703                                // §5.9.7 B2: revocation deletes now-unauthorized blob
8704                                // bodies (evicted ≠ revoked).
8705                                self.delete_unreferenced_cached_blobs();
8706                            }
8707                            Err(()) => {
8708                                // §3.3 fail closed: no local mapping — never clear by
8709                                // approximation; fatal configuration error.
8710                                let sub = &mut self.subs[sub_index];
8711                                sub.state = SubState::Failed;
8712                                sub.reason_code = Some("sync.scope_revoked".to_owned());
8713                                report.failed.push(id.to_owned());
8714                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
8715                                    |message| SectionError::Abort("storage.failed".into(), message),
8716                                )?;
8717                            }
8718                        }
8719                        if let Some((base_key, unit)) = registered {
8720                            batch.window(&base_key, &self.subs[sub_index].table, &unit);
8721                        }
8722                        self.rebuild_overlay_if_dirty();
8723                        self.finish_observation("syncular_revocation", batch)
8724                            .map_err(|message| {
8725                                SectionError::Abort("storage.failed".to_owned(), message)
8726                            })?;
8727                        Ok(())
8728                    })();
8729                if result.is_err() {
8730                    self.rollback_observation("syncular_revocation");
8731                    self.subs[sub_index] = previous;
8732                    self.overlay_dirty.set(was_dirty);
8733                    self.outbox = previous_outbox;
8734                    self.rejections.truncate(rejection_count);
8735                    report.revoked.truncate(revoked_count);
8736                    report.failed.truncate(failed_count);
8737                }
8738                result
8739            }
8740            SubStatus::Reset => {
8741                self.begin_observation("syncular_reset")
8742                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
8743                let previous = self.subs[sub_index].clone();
8744                let was_dirty = self.overlay_dirty.get();
8745                let reset_count = report.resets.len();
8746                let result = (|| {
8747                    let mut batch = ChangeAccumulator::default();
8748                    let registered = self.window_unit_by_sub_id(id);
8749                    // §4.6: discard cursor + bootstrap state, keep local rows —
8750                    // reset is a staleness signal, not a purge signal.
8751                    let sub = &mut self.subs[sub_index];
8752                    sub.cursor = -1;
8753                    sub.bootstrap_state = None;
8754                    report.resets.push(id.to_owned());
8755                    self.persist_sub(&self.subs[sub_index].clone())
8756                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
8757                    if let Some((base_key, unit)) = registered {
8758                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
8759                    }
8760                    self.finish_observation("syncular_reset", batch)
8761                        .map_err(|message| {
8762                            SectionError::Abort("storage.failed".to_owned(), message)
8763                        })?;
8764                    Ok(())
8765                })();
8766                if result.is_err() {
8767                    self.rollback_observation("syncular_reset");
8768                    self.subs[sub_index] = previous;
8769                    self.overlay_dirty.set(was_dirty);
8770                    report.resets.truncate(reset_count);
8771                }
8772                result
8773            }
8774            SubStatus::Active => {
8775                let fresh = meta
8776                    .fresh
8777                    .iter()
8778                    .find(|(fid, _)| fid == id)
8779                    .map(|(_, fresh)| *fresh)
8780                    .unwrap_or(false);
8781                let previous = self.subs[sub_index].clone();
8782                self.subs[sub_index].effective = Some(effective_scopes);
8783                let outcome = self
8784                    .apply_section_body(transport, sub_index, body, fresh, meta, report)
8785                    .and_then(|()| {
8786                        let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
8787                            SectionError::Abort(
8788                                "sync.invalid_request".into(),
8789                                "subscription section without SUB_END".into(),
8790                            )
8791                        })?;
8792                        self.apply_sub_end(sub_index, cursor, bootstrap_state)
8793                            .map_err(|message| {
8794                                SectionError::Abort("storage.failed".into(), message)
8795                            })
8796                    });
8797                match outcome {
8798                    Ok(()) => {
8799                        if self.subs[sub_index].bootstrap_state.is_some() {
8800                            report.bootstrapping.push(id.to_owned());
8801                        }
8802                        Ok(())
8803                    }
8804                    Err(SectionError::FailClosed) => {
8805                        self.subs[sub_index] = previous.clone();
8806                        self.begin_observation("syncular_section_failure")
8807                            .map_err(|message| {
8808                                SectionError::Abort("storage.failed".into(), message)
8809                            })?;
8810                        let persisted = (|| {
8811                            let mut batch = ChangeAccumulator::default();
8812                            if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
8813                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
8814                            }
8815                            self.subs[sub_index].state = SubState::Failed;
8816                            self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
8817                            self.persist_sub(&self.subs[sub_index])?;
8818                            self.finish_observation("syncular_section_failure", batch)
8819                        })();
8820                        if let Err(message) = persisted {
8821                            self.rollback_observation("syncular_section_failure");
8822                            self.subs[sub_index] = previous;
8823                            return Err(SectionError::Abort("storage.failed".into(), message));
8824                        }
8825                        report.failed.push(id.to_owned());
8826                        Ok(())
8827                    }
8828                    Err(error) => {
8829                        // Frame/block transactions already completed. Preserve their
8830                        // rows and revisions, but leave this subscription cursor unchanged.
8831                        self.subs[sub_index] = previous;
8832                        Err(error)
8833                    }
8834                }
8835            }
8836        }
8837    }
8838
8839    fn apply_sub_end(
8840        &mut self,
8841        sub_index: usize,
8842        cursor: i64,
8843        bootstrap_state: Option<String>,
8844    ) -> Result<(), String> {
8845        #[cfg(feature = "bench-internals")]
8846        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
8847        let previous = self.subs[sub_index].clone();
8848        self.begin_observation("syncular_sub_end")?;
8849        let persisted = (|| {
8850            let mut batch = ChangeAccumulator::default();
8851            let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
8852                && cursor >= 0
8853                && bootstrap_state.is_none();
8854            if completed {
8855                if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
8856                    batch.window(&base_key, &previous.table, &unit);
8857                }
8858            }
8859            let sub = &mut self.subs[sub_index];
8860            sub.cursor = cursor;
8861            sub.bootstrap_state = bootstrap_state;
8862            sub.synced_once = true;
8863            self.persist_sub(&self.subs[sub_index])?;
8864            self.finish_observation("syncular_sub_end", batch)
8865        })();
8866        if persisted.is_err() {
8867            self.rollback_observation("syncular_sub_end");
8868            self.subs[sub_index] = previous;
8869        }
8870        persisted
8871    }
8872
8873    fn apply_section_body(
8874        &mut self,
8875        transport: &mut dyn Transport,
8876        sub_index: usize,
8877        body: Vec<Frame>,
8878        fresh: bool,
8879        meta: &RequestMeta,
8880        report: &mut SyncReport,
8881    ) -> Result<(), SectionError> {
8882        let mut saw_segment = false;
8883        for frame in body {
8884            match frame {
8885                Frame::Commit {
8886                    tables, changes, ..
8887                } => {
8888                    self.apply_commit_frame(&tables, &changes)
8889                        .map_err(|(c, m)| SectionError::Abort(c, m))?;
8890                    report.commits_applied += 1;
8891                }
8892                Frame::SegmentInline { payload } => {
8893                    let segment = decode_rows_segment(&payload)
8894                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
8895                    self.progress.update(|p| {
8896                        p.phase = ProgressPhase::Import;
8897                        p.subscription_id = Some(self.subs[sub_index].id.clone());
8898                        p.table = Some(self.subs[sub_index].table.clone());
8899                        p.segment_id = None;
8900                        p.bytes_received = payload.len() as u64;
8901                        p.bytes_total = Some(payload.len() as u64);
8902                        p.rows_processed = 0;
8903                        p.rows_total =
8904                            Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
8905                    });
8906                    let first = !saw_segment;
8907                    saw_segment = true;
8908                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
8909                    report.segment_rows_applied += applied;
8910                }
8911                Frame::SegmentRef {
8912                    segment_id,
8913                    byte_length,
8914                    media_type,
8915                    table,
8916                    row_count,
8917                    as_of_commit_seq,
8918                    scope_digest,
8919                    row_cursor,
8920                    next_row_cursor,
8921                    url,
8922                    url_expires_at_ms,
8923                    ..
8924                } => {
8925                    // §4.2: reject a descriptor whose mediaType was not
8926                    // advertised — never skip or guess.
8927                    let advertised = match media_type {
8928                        MediaType::Rows => {
8929                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
8930                                || meta.accept & ACCEPT_INLINE_ROWS != 0
8931                        }
8932                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
8933                    };
8934                    if !advertised {
8935                        return Err(SectionError::Abort(
8936                            "sync.invalid_request".to_owned(),
8937                            format!(
8938                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
8939                                media_type.name()
8940                            ),
8941                        ));
8942                    }
8943                    self.progress.update(|p| {
8944                        p.phase = ProgressPhase::Download;
8945                        p.subscription_id = Some(self.subs[sub_index].id.clone());
8946                        p.table = Some(table.clone());
8947                        p.segment_id = Some(segment_id.clone());
8948                        p.bytes_received = 0;
8949                        p.bytes_total = u64::try_from(byte_length).ok();
8950                        p.rows_processed = 0;
8951                        p.rows_total = u64::try_from(row_count).ok();
8952                    });
8953                    let observer = self.progress.clone();
8954                    let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
8955                    let bytes = if let Some(url) = url {
8956                        // §5.4: a url-carrying descriptor MUST be fetched
8957                        // from that URL; failure invalidates the whole
8958                        // descriptor (no fall-through to §5.5 — re-pull
8959                        // recovers, §1.4 rule 5).
8960                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
8961                            return Err(SectionError::Abort(
8962                                "sync.invalid_request".to_owned(),
8963                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
8964                                    .to_owned(),
8965                            ));
8966                        }
8967                        // §5.4: MUST NOT start a fetch at/past expiry.
8968                        if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
8969                            return Err(SectionError::Abort(
8970                                "sync.segment_expired".to_owned(),
8971                                format!(
8972                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
8973                                ),
8974                            ));
8975                        }
8976                        transport
8977                            .fetch_url(&url, &mut on_progress)
8978                            .map_err(|e| SectionError::Abort(e.code, e.message))?
8979                    } else {
8980                        let requested_scopes_json =
8981                            canonical_scope_json(&self.subs[sub_index].requested);
8982                        transport
8983                            .download_segment(
8984                                &SegmentRequest {
8985                                    segment_id: segment_id.clone(),
8986                                    table,
8987                                    requested_scopes_json,
8988                                },
8989                                &mut on_progress,
8990                            )
8991                            .map_err(|e| SectionError::Abort(e.code, e.message))?
8992                    };
8993                    // §5.1: verify the content address before applying.
8994                    on_progress(bytes.len() as u64);
8995                    let digest = Sha256::digest(&bytes);
8996                    let expected = segment_id
8997                        .strip_prefix("sha256:")
8998                        .unwrap_or(segment_id.as_str());
8999                    if bytes_to_hex(&digest) != expected {
9000                        return Err(SectionError::Abort(
9001                            "sync.invalid_request".to_owned(),
9002                            "segment bytes do not match the content address (§5.1)".to_owned(),
9003                        ));
9004                    }
9005                    self.progress.update(|p| p.phase = ProgressPhase::Import);
9006                    if media_type == MediaType::Sqlite {
9007                        // §5.3: images are whole-table — a paged sqlite
9008                        // descriptor is invalid.
9009                        if row_cursor.is_some() || next_row_cursor.is_some() {
9010                            return Err(SectionError::Abort(
9011                                "sync.invalid_request".to_owned(),
9012                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
9013                                    .to_owned(),
9014                            ));
9015                        }
9016                        let first = !saw_segment;
9017                        saw_segment = true;
9018                        let applied = self.apply_sqlite_segment(
9019                            sub_index,
9020                            &bytes,
9021                            fresh && first,
9022                            row_count,
9023                            as_of_commit_seq,
9024                            &scope_digest,
9025                        )?;
9026                        report.segment_rows_applied += applied;
9027                    } else {
9028                        let segment = decode_rows_segment(&bytes).map_err(|e| {
9029                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
9030                        })?;
9031                        let first = row_cursor.is_none();
9032                        saw_segment = true;
9033                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
9034                        report.segment_rows_applied += applied;
9035                    }
9036                }
9037                Frame::Unknown { .. } => {}
9038                Frame::Error { code, message, .. } => {
9039                    return Err(SectionError::Abort(code, message))
9040                }
9041                _ => {
9042                    return Err(SectionError::Abort(
9043                        "sync.invalid_request".to_owned(),
9044                        "unexpected frame inside a subscription section".to_owned(),
9045                    ));
9046                }
9047            }
9048        }
9049        Ok(())
9050    }
9051
9052    // Both pull and realtime frames use the same durable observation boundary.
9053    fn apply_commit_frame(
9054        &mut self,
9055        tables: &[String],
9056        changes: &[ssp2::model::Change],
9057    ) -> Result<(), (String, String)> {
9058        #[cfg(feature = "bench-internals")]
9059        let _phase = self.benchmark_phases.start(Phase::CommitApply);
9060        let was_dirty = self.overlay_dirty.get();
9061        self.begin_observation("syncular_commit")
9062            .map_err(|message| ("storage.failed".into(), message))?;
9063        let applied = (|| {
9064            let mut batch = ChangeAccumulator::default();
9065            self.record_commit_changes(&mut batch, tables, changes);
9066            self.apply_commit_changes(tables, changes)?;
9067            self.rebuild_overlay_if_dirty();
9068            self.finish_observation("syncular_commit", batch)
9069                .map_err(|message| ("storage.failed".into(), message))
9070        })();
9071        if applied.is_err() {
9072            self.rollback_observation("syncular_commit");
9073            self.overlay_dirty.set(was_dirty);
9074        }
9075        applied
9076    }
9077
9078    fn apply_commit_changes(
9079        &mut self,
9080        tables: &[String],
9081        changes: &[ssp2::model::Change],
9082    ) -> Result<(), (String, String)> {
9083        // A clean overlay can reconcile changed primary keys independently
9084        // when their tables have no secondary unique constraints. Such a
9085        // constraint can change whether another row's pending write succeeds,
9086        // so those frames still require the complete FIFO replay below.
9087        let mirror_visible = !self.overlay_dirty.get()
9088            && (self.outbox.is_empty()
9089                || changes.iter().all(|change| {
9090                    tables
9091                        .get(change.table_index as usize)
9092                        .and_then(|name| self.schema.table(name))
9093                        .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
9094                }));
9095        for change in changes {
9096            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
9097                (
9098                    "sync.invalid_request".to_owned(),
9099                    "change tableIndex out of range".to_owned(),
9100                )
9101            })?;
9102            let table = self.schema.table(table_name).ok_or_else(|| {
9103                (
9104                    "sync.schema_mismatch".to_owned(),
9105                    format!("change targets unknown table {table_name:?}"),
9106                )
9107            })?;
9108            match change.op {
9109                Op::Upsert => {
9110                    let payload = change.row.as_ref().ok_or_else(|| {
9111                        (
9112                            "sync.invalid_request".to_owned(),
9113                            "upsert change without row payload".to_owned(),
9114                        )
9115                    })?;
9116                    // §5.11: decrypt encrypted columns on apply.
9117                    let row = {
9118                        #[cfg(feature = "bench-internals")]
9119                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
9120                        decode_row_bytes(table, payload, &self.encryption)
9121                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?
9122                    };
9123                    let version = change.row_version.unwrap_or(0);
9124                    let table_name = table.name.clone();
9125                    self.write_base_row(&table_name, &row, version)
9126                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
9127                    if mirror_visible {
9128                        self.write_row(&visible_table(&table_name), &table_name, &row, version)
9129                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
9130                    }
9131                }
9132                Op::Delete => {
9133                    self.delete_base_row(table_name, &change.row_id)
9134                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
9135                    if mirror_visible {
9136                        let sql = format!(
9137                            "DELETE FROM {} WHERE {}",
9138                            visible_table(table_name),
9139                            row_id_predicate(table)
9140                        );
9141                        self.conn
9142                            .prepare_cached(&sql)
9143                            .and_then(|mut statement| {
9144                                statement.execute(rusqlite::params![change.row_id])
9145                            })
9146                            .map_err(|error| {
9147                                ("sync.invalid_request".to_owned(), error.to_string())
9148                            })?;
9149                    }
9150                }
9151            }
9152        }
9153        if mirror_visible {
9154            if !self.outbox.is_empty() {
9155                let changed_rows: HashSet<(&str, &str)> = changes
9156                    .iter()
9157                    .map(|change| {
9158                        (
9159                            tables[change.table_index as usize].as_str(),
9160                            change.row_id.as_str(),
9161                        )
9162                    })
9163                    .collect();
9164                self.apply_outbox_ops(
9165                    self.outbox
9166                        .iter()
9167                        .flat_map(|commit| &commit.ops)
9168                        .filter(|op| {
9169                            changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
9170                        }),
9171                );
9172            }
9173            self.overlay_dirty.set(false);
9174        }
9175        Ok(())
9176    }
9177
9178    /// §5.6 segment application: validate against the generated schema,
9179    /// clear the grant on a fresh bootstrap's first page (fail closed
9180    /// without a mapping), then replace-or-upsert each row with its
9181    /// segment-carried server version (§5.2).
9182    fn apply_segment(
9183        &mut self,
9184        sub_index: usize,
9185        segment: &RowsSegment,
9186        first_fresh_page: bool,
9187    ) -> Result<u32, SectionError> {
9188        let (sub_table, effective) = {
9189            let sub = &self.subs[sub_index];
9190            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
9191        };
9192        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
9193            SectionError::Abort(
9194                "sync.schema_mismatch".to_owned(),
9195                format!("subscription table {sub_table:?} is not in the client schema"),
9196            )
9197        })?;
9198        // §5.2: the column table validates against the generated schema —
9199        // order, names, types, nullability; mismatch is fatal. §5.11: the
9200        // server sends the WIRE types (bytes for an encrypted column), so
9201        // validate against wire_columns.
9202        let matches = segment.table == table.name
9203            && segment.schema_version == self.schema.version
9204            && segment.columns.len() == table.wire_columns.len()
9205            && segment
9206                .columns
9207                .iter()
9208                .zip(table.wire_columns.iter())
9209                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
9210        if !matches {
9211            return Err(SectionError::Abort(
9212                "sync.schema_mismatch".to_owned(),
9213                "segment column table does not match the generated schema (§5.2)".to_owned(),
9214            ));
9215        }
9216        let mut applied = 0u32;
9217        let empty = Vec::new();
9218        let blocks = if segment.blocks.is_empty() {
9219            std::slice::from_ref(&empty)
9220        } else {
9221            &segment.blocks
9222        };
9223        for (index, block) in blocks.iter().enumerate() {
9224            let clear = first_fresh_page && index == 0;
9225            if !clear && block.is_empty() {
9226                continue;
9227            }
9228            let was_dirty = self.overlay_dirty.get();
9229            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
9230                && self
9231                    .outbox
9232                    .iter()
9233                    .flat_map(|commit| &commit.ops)
9234                    .any(|op| op.table == table.name);
9235            let mirror_visible = !reconcile_pending;
9236            self.begin_observation("syncular_segment_block")
9237                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9238            let outcome = (|| {
9239                self.rebuild_overlay_if_dirty();
9240                let mut changed_keys = Vec::new();
9241                let mut batch = ChangeAccumulator::default();
9242                if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
9243                    batch.table(&table.name);
9244                }
9245                if clear {
9246                    self.purge_scope_rows(&table.name, &effective)
9247                        .map_err(|()| SectionError::FailClosed)?;
9248                }
9249                for (row_index, row) in block.iter().enumerate() {
9250                    let decrypted;
9251                    let values = if table.has_encrypted_columns() {
9252                        let mut values = row.values.clone();
9253                        crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
9254                            .map_err(|message| {
9255                            SectionError::Abort("client.decrypt_failed".into(), message)
9256                        })?;
9257                        decrypted = values;
9258                        &decrypted
9259                    } else {
9260                        &row.values
9261                    };
9262                    self.write_base_row(&table.name, values, row.server_version)
9263                        .map_err(|message| {
9264                            SectionError::Abort("sync.invalid_request".into(), message)
9265                        })?;
9266                    if reconcile_pending {
9267                        changed_keys.push(
9268                            owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
9269                                |message| SectionError::Abort("storage.failed".into(), message),
9270                            )?,
9271                        );
9272                    }
9273                    if mirror_visible {
9274                        self.write_row(
9275                            &visible_table(&table.name),
9276                            &table.name,
9277                            values,
9278                            row.server_version,
9279                        )
9280                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9281                    }
9282                    let processed = u64::from(applied) + row_index as u64 + 1;
9283                    if processed.is_multiple_of(1024) {
9284                        self.progress.update(|p| p.rows_processed = processed);
9285                    }
9286                }
9287                if reconcile_pending {
9288                    self.reconcile_imported_rows(&table, &mut changed_keys)
9289                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9290                }
9291                self.apply_outbox_ops(
9292                    self.outbox
9293                        .iter()
9294                        .flat_map(|commit| &commit.ops)
9295                        .filter(|op| op.table == table.name),
9296                );
9297                self.overlay_dirty.set(false);
9298                self.finish_observation("syncular_segment_block", batch)
9299                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
9300            })();
9301            if let Err(error) = outcome {
9302                self.rollback_observation("syncular_segment_block");
9303                self.overlay_dirty.set(was_dirty);
9304                return Err(error);
9305            }
9306            applied += block.len() as u32;
9307            self.progress
9308                .update(|p| p.rows_processed = u64::from(applied));
9309            std::thread::yield_now();
9310        }
9311        self.progress
9312            .update(|p| p.rows_processed = u64::from(applied));
9313        Ok(applied)
9314    }
9315
9316    /// §5.3 sqlite-image application: validate the in-file metadata
9317    /// against the descriptor, validate column names/order against the
9318    /// generated schema, run the §5.6 first-page clear when fresh, then
9319    /// replace-or-upsert every image row with its `_syncular_version`.
9320    /// Mechanics: the image lands in a temp file read through a second
9321    /// rusqlite connection, equivalent to ATTACH + INSERT…SELECT.
9322    /// Each image chunk commits its writes and revision before readers continue.
9323    fn apply_sqlite_segment(
9324        &mut self,
9325        sub_index: usize,
9326        bytes: &[u8],
9327        first_fresh_page: bool,
9328        row_count: i64,
9329        as_of_commit_seq: i64,
9330        scope_digest: &str,
9331    ) -> Result<u32, SectionError> {
9332        let invalid = |detail: &str| {
9333            SectionError::Abort(
9334                "sync.invalid_request".to_owned(),
9335                format!("sqlite segment rejected: {detail} (§5.3)"),
9336            )
9337        };
9338        let (sub_table, effective) = {
9339            let sub = &self.subs[sub_index];
9340            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
9341        };
9342        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
9343            SectionError::Abort(
9344                "sync.schema_mismatch".to_owned(),
9345                format!("subscription table {sub_table:?} is not in the client schema"),
9346            )
9347        })?;
9348
9349        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
9350        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
9351        let img = match rusqlite::Connection::open_with_flags(
9352            &path,
9353            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
9354        ) {
9355            Ok(conn) => conn,
9356            Err(_) => {
9357                let _ = std::fs::remove_file(&path);
9358                return Err(invalid("bytes do not open as a SQLite database"));
9359            }
9360        };
9361        let outcome = self.apply_sqlite_image(
9362            &img,
9363            &table,
9364            first_fresh_page,
9365            &effective,
9366            row_count,
9367            as_of_commit_seq,
9368            scope_digest,
9369        );
9370        drop(img);
9371        let _ = std::fs::remove_file(&path);
9372        outcome
9373    }
9374
9375    #[allow(clippy::too_many_arguments)]
9376    fn apply_sqlite_image(
9377        &mut self,
9378        img: &rusqlite::Connection,
9379        table: &crate::schema::TableSchema,
9380        first_fresh_page: bool,
9381        effective: &[(String, Vec<String>)],
9382        row_count: i64,
9383        as_of_commit_seq: i64,
9384        scope_digest: &str,
9385    ) -> Result<u32, SectionError> {
9386        let invalid = |detail: String| {
9387            SectionError::Abort(
9388                "sync.invalid_request".to_owned(),
9389                format!("sqlite segment rejected: {detail} (§5.3)"),
9390            )
9391        };
9392
9393        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
9394        //    one row).
9395        type MetaRow = (i64, String, i64, i64, String, i64, i64);
9396        let meta: MetaRow = img
9397            .query_row(
9398                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
9399                        \"scopeDigest\", \"rowCount\",
9400                        (SELECT count(*) FROM _syncular_segment)
9401                 FROM _syncular_segment",
9402                [],
9403                |row| {
9404                    Ok((
9405                        row.get(0)?,
9406                        row.get(1)?,
9407                        row.get(2)?,
9408                        row.get(3)?,
9409                        row.get(4)?,
9410                        row.get(5)?,
9411                        row.get(6)?,
9412                    ))
9413                },
9414            )
9415            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
9416        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
9417        if meta_count != 1 {
9418            return Err(invalid(format!(
9419                "_syncular_segment must contain exactly one row, found {meta_count}"
9420            )));
9421        }
9422        if format != 1 {
9423            return Err(invalid(format!("format {format}")));
9424        }
9425        if meta_table != table.name {
9426            return Err(invalid(format!("image table {meta_table:?}")));
9427        }
9428        if schema_version != i64::from(self.schema.version) {
9429            return Err(invalid(format!("schemaVersion {schema_version}")));
9430        }
9431        if pin != as_of_commit_seq {
9432            return Err(invalid(format!("asOfCommitSeq {pin}")));
9433        }
9434        if digest != scope_digest {
9435            return Err(invalid("scopeDigest mismatch".to_owned()));
9436        }
9437        if meta_rows != row_count {
9438            return Err(invalid(format!("rowCount {meta_rows}")));
9439        }
9440
9441        // 2. Column names and order vs the generated schema (§5.3 rule 3).
9442        let mut names: Vec<String> = Vec::new();
9443        let mut primary_keys: Vec<String> = Vec::new();
9444        {
9445            let mut stmt = img
9446                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
9447                .map_err(|_| invalid("image data table missing".to_owned()))?;
9448            let mut rows = stmt
9449                .query([])
9450                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
9451            while let Some(row) = rows
9452                .next()
9453                .map_err(|_| invalid("image data table unreadable".to_owned()))?
9454            {
9455                if row.get::<_, i64>(5).map_err(|e| invalid(e.to_string()))? > 0 {
9456                    primary_keys.push(row.get(1).map_err(|e| invalid(e.to_string()))?);
9457                }
9458                names.push(
9459                    row.get::<_, String>(1)
9460                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
9461                );
9462            }
9463        }
9464        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
9465        expected.push("_syncular_version");
9466        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
9467            return Err(SectionError::Abort(
9468                "sync.schema_mismatch".to_owned(),
9469                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
9470            ));
9471        }
9472
9473        if primary_keys != vec![table.primary_key.clone()] {
9474            return Err(invalid(
9475                "image primary key does not match generated schema".into(),
9476            ));
9477        }
9478        let actual_count: i64 = img
9479            .query_row(
9480                &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
9481                [],
9482                |row| row.get(0),
9483            )
9484            .map_err(|e| invalid(e.to_string()))?;
9485        if actual_count != row_count {
9486            return Err(invalid("image row count does not match descriptor".into()));
9487        }
9488        let insert = self.insert_row_sql(&base_table(&table.name), table);
9489        let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
9490        let column_list = names
9491            .iter()
9492            .map(|n| quote_ident(n))
9493            .collect::<Vec<_>>()
9494            .join(", ");
9495        let mut stmt = img
9496            .prepare(&format!(
9497                "SELECT {column_list} FROM {} ORDER BY {}",
9498                quote_ident(&table.name),
9499                quote_ident(&table.primary_key)
9500            ))
9501            .map_err(|e| invalid(e.to_string()))?;
9502        let mut rows = stmt.query([]).map_err(|e| invalid(e.to_string()))?;
9503        let mut applied = 0u32;
9504        loop {
9505            let clear = applied == 0 && first_fresh_page;
9506            let was_dirty = self.overlay_dirty.get();
9507            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
9508                && self
9509                    .outbox
9510                    .iter()
9511                    .flat_map(|commit| &commit.ops)
9512                    .any(|op| op.table == table.name);
9513            let mirror_visible = !reconcile_pending;
9514            self.begin_observation("syncular_image_chunk")
9515                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9516            let result = (|| {
9517                self.rebuild_overlay_if_dirty();
9518                let mut changed_keys = Vec::new();
9519                let mut batch = ChangeAccumulator::default();
9520                if clear {
9521                    if self.scoped_rows_exist(&table.name, effective) {
9522                        batch.table(&table.name);
9523                    }
9524                    self.purge_scope_rows(&table.name, effective)
9525                        .map_err(|()| SectionError::FailClosed)?;
9526                }
9527                let mut processed = 0u32;
9528                {
9529                    let mut ins = self
9530                        .conn
9531                        .prepare_cached(&insert)
9532                        .map_err(|e| invalid(e.to_string()))?;
9533                    let mut mirror = if mirror_visible {
9534                        Some(
9535                            self.conn
9536                                .prepare_cached(&visible_insert)
9537                                .map_err(|e| invalid(e.to_string()))?,
9538                        )
9539                    } else {
9540                        None
9541                    };
9542                    for _ in 0..1024 {
9543                        let Some(row) = rows.next().map_err(|e| invalid(e.to_string()))? else {
9544                            break;
9545                        };
9546                        for (i, column) in table.columns.iter().enumerate() {
9547                            let cell = row.get_ref(i).map_err(|e| invalid(e.to_string()))?;
9548                            let param = image_cell_param(column, cell).map_err(&invalid)?;
9549                            ins.raw_bind_parameter(i + 1, &param)
9550                                .map_err(|e| invalid(e.to_string()))?;
9551                            if let Some(mirror) = &mut mirror {
9552                                mirror
9553                                    .raw_bind_parameter(i + 1, &param)
9554                                    .map_err(|e| invalid(e.to_string()))?;
9555                            }
9556                        }
9557                        let version_index = table.columns.len();
9558                        let version: i64 =
9559                            row.get(version_index).map_err(|e| invalid(e.to_string()))?;
9560                        if version < 1 {
9561                            return Err(invalid("image row version must be positive".into()));
9562                        }
9563                        ins.raw_bind_parameter(version_index + 1, version)
9564                            .map_err(|e| invalid(e.to_string()))?;
9565                        ins.raw_execute().map_err(|e| invalid(e.to_string()))?;
9566                        if let Some(mirror) = &mut mirror {
9567                            mirror
9568                                .raw_bind_parameter(version_index + 1, version)
9569                                .map_err(|e| invalid(e.to_string()))?;
9570                            mirror.raw_execute().map_err(|e| invalid(e.to_string()))?;
9571                        }
9572                        if reconcile_pending {
9573                            changed_keys.push(
9574                                row.get::<_, SqlValue>(table.pk_index)
9575                                    .map_err(|error| invalid(error.to_string()))?,
9576                            );
9577                        }
9578                        processed += 1;
9579                    }
9580                }
9581                if processed > 0 {
9582                    batch.table(&table.name);
9583                    self.overlay_dirty.set(true);
9584                }
9585                if reconcile_pending {
9586                    self.reconcile_imported_rows(table, &mut changed_keys)
9587                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9588                }
9589                self.apply_outbox_ops(
9590                    self.outbox
9591                        .iter()
9592                        .flat_map(|commit| &commit.ops)
9593                        .filter(|op| op.table == table.name),
9594                );
9595                self.overlay_dirty.set(false);
9596                self.finish_observation("syncular_image_chunk", batch)
9597                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9598                Ok(processed)
9599            })();
9600            let processed = match result {
9601                Ok(processed) => processed,
9602                Err(error) => {
9603                    self.rollback_observation("syncular_image_chunk");
9604                    self.overlay_dirty.set(was_dirty);
9605                    return Err(error);
9606                }
9607            };
9608            applied += processed;
9609            self.progress
9610                .update(|p| p.rows_processed = u64::from(applied));
9611            std::thread::yield_now();
9612            if i64::from(applied) == actual_count {
9613                break;
9614            }
9615        }
9616        Ok(applied)
9617    }
9618
9619    /// Restore only imported and pending row identities before FIFO replay. All pending
9620    /// identities participate: a server row can free a unique value for a different edit.
9621    /// The caller owns the chunk transaction and publishes its revision after replay.
9622    fn reconcile_imported_rows(
9623        &self,
9624        table: &TableSchema,
9625        keys: &mut Vec<SqlValue>,
9626    ) -> Result<(), String> {
9627        let visible = visible_table(&table.name);
9628        let base = base_table(&table.name);
9629        let pk = quote_ident(&table.primary_key);
9630        let predicate = row_id_predicate(table);
9631        let mut lookup = self.conn.prepare_cached(&format!(
9632            "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
9633        )).map_err(|error| error.to_string())?;
9634        let mut deleted = HashSet::new();
9635        for op in self
9636            .outbox
9637            .iter()
9638            .flat_map(|commit| &commit.ops)
9639            .filter(|op| op.table == table.name)
9640        {
9641            if op.upsert {
9642                if let Some(values) = &op.values {
9643                    let key = json_to_column_value(
9644                        &table.columns[table.pk_index],
9645                        values.get(&table.primary_key),
9646                    )?;
9647                    keys.push(owned_sql_value(RowParam::Cell(&key))?);
9648                }
9649            } else if deleted.insert(&op.row_id) {
9650                keys.extend(
9651                    lookup
9652                        .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
9653                        .map_err(|error| error.to_string())?
9654                        .collect::<Result<Vec<_>, _>>()
9655                        .map_err(|error| error.to_string())?,
9656                );
9657            }
9658        }
9659        // Delete the complete affected set before restoring any base value, so
9660        // old optimistic unique values cannot collide with the new server rows.
9661        for chunk in keys.chunks(400) {
9662            let holes = vec!["?"; chunk.len()].join(",");
9663            self.conn
9664                .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
9665                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
9666                .map_err(|error| error.to_string())?;
9667        }
9668        let updates = table
9669            .columns
9670            .iter()
9671            .map(|column| column.name.as_str())
9672            .chain(std::iter::once("_syncular_version"))
9673            .filter(|name| *name != table.primary_key)
9674            .map(|name| {
9675                let name = quote_ident(name);
9676                format!("{name}=excluded.{name}")
9677            })
9678            .collect::<Vec<_>>()
9679            .join(",");
9680        for chunk in keys.chunks(400) {
9681            let holes = vec!["?"; chunk.len()].join(",");
9682            self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
9683                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
9684                .map_err(|error| error.to_string())?;
9685        }
9686        Ok(())
9687    }
9688
9689    // -- application-authorized local purge ---------------------------------------
9690
9691    fn compile_local_data_purge(
9692        &self,
9693        input: &LocalDataPurgeInput,
9694    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
9695        let invalid = |message: String| format!("sync.invalid_request: {message}");
9696        if input.purge_id.is_empty()
9697            || input.purge_id.len() > 128
9698            || !is_local_operation_code_like(&input.purge_id)
9699        {
9700            return Err(invalid(
9701                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
9702            ));
9703        }
9704        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
9705            return Err(invalid(format!(
9706                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
9707            )));
9708        }
9709
9710        let mut deduplicated: BTreeMap<
9711            String,
9712            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
9713        > = BTreeMap::new();
9714        for target in &input.targets {
9715            let table = self.schema.table(&target.table).ok_or_else(|| {
9716                invalid(format!(
9717                    "local purge names unknown table {:?}",
9718                    target.table
9719                ))
9720            })?;
9721            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
9722                return Err(invalid(format!(
9723                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
9724                    target.table
9725                )));
9726            }
9727            let mut selectors = Vec::with_capacity(target.selectors.len());
9728            let mut canonical_selectors = BTreeMap::new();
9729            for (column_name, raw_values) in &target.selectors {
9730                let Some((column_index, column)) = table
9731                    .columns
9732                    .iter()
9733                    .enumerate()
9734                    .find(|(_, column)| column.name == *column_name)
9735                else {
9736                    return Err(invalid(format!(
9737                        "local purge target {:?} names unknown column {:?}",
9738                        target.table, column_name
9739                    )));
9740                };
9741                let encrypted = table
9742                    .encrypted_columns
9743                    .iter()
9744                    .any(|candidate| candidate.index == column_index);
9745                if column.ty != ColumnType::String || encrypted {
9746                    return Err(invalid(format!(
9747                        "local purge selector {:?}.{:?} must be a plaintext string column",
9748                        target.table, column_name
9749                    )));
9750                }
9751                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
9752                    return Err(invalid(format!(
9753                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
9754                        target.table, column_name
9755                    )));
9756                }
9757                let mut values = raw_values.clone();
9758                values.sort();
9759                values.dedup();
9760                if values.iter().any(|value| {
9761                    value.is_empty()
9762                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
9763                        || !is_local_operation_code_like(value)
9764                }) {
9765                    return Err(invalid(format!(
9766                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
9767                    )));
9768                }
9769                selectors.push((column_name.clone(), values.clone()));
9770                canonical_selectors.insert(column_name.clone(), values);
9771            }
9772            let canonical = LocalDataPurgeTarget {
9773                table: target.table.clone(),
9774                selectors: canonical_selectors,
9775            };
9776            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
9777            deduplicated.insert(
9778                key,
9779                (
9780                    CompiledLocalDataPurgeTarget {
9781                        table: target.table.clone(),
9782                        selectors,
9783                    },
9784                    canonical,
9785                ),
9786            );
9787        }
9788        let targets = deduplicated
9789            .values()
9790            .map(|(compiled, _)| compiled.clone())
9791            .collect::<Vec<_>>();
9792        let canonical = deduplicated
9793            .values()
9794            .map(|(_, target)| target.clone())
9795            .collect::<Vec<_>>();
9796        let canonical_plan =
9797            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
9798        Ok((targets, canonical_plan))
9799    }
9800
9801    fn local_purge_base_row_ids(
9802        &self,
9803        targets: &[CompiledLocalDataPurgeTarget],
9804    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
9805        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
9806        for target in targets {
9807            let table = self
9808                .schema
9809                .table(&target.table)
9810                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
9811            let mut clauses = Vec::with_capacity(target.selectors.len());
9812            let mut params = Vec::new();
9813            for (column, values) in &target.selectors {
9814                clauses.push(format!(
9815                    "{} IN ({})",
9816                    quote_ident(column),
9817                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
9818                ));
9819                params.extend(values.iter().cloned().map(SqlValue::Text));
9820            }
9821            let sql = format!(
9822                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
9823                quote_ident(&table.primary_key),
9824                base_table(&target.table),
9825                clauses.join(" AND ")
9826            );
9827            let mut statement = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
9828            let rows = statement
9829                .query_map(rusqlite::params_from_iter(params), |row| {
9830                    row.get::<_, String>(0)
9831                })
9832                .map_err(|error| error.to_string())?;
9833            let ids = by_table.entry(target.table.clone()).or_default();
9834            for row in rows {
9835                ids.insert(row.map_err(|error| error.to_string())?);
9836            }
9837        }
9838        Ok(by_table)
9839    }
9840
9841    fn local_purge_values_match(
9842        target: &CompiledLocalDataPurgeTarget,
9843        values: &Map<String, Value>,
9844    ) -> bool {
9845        target.selectors.iter().all(|(column, allowed)| {
9846            values
9847                .get(column)
9848                .and_then(Value::as_str)
9849                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
9850        })
9851    }
9852
9853    /// Apply one host-authorized local security purge. The host MUST gate the
9854    /// corresponding subscriptions first; this primitive owns local SQLite
9855    /// cleanup and never grants/revokes server authority by itself.
9856    pub fn purge_local_data(
9857        &mut self,
9858        input: &LocalDataPurgeInput,
9859    ) -> Result<LocalDataPurgeResult, String> {
9860        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
9861        let meta_key = format!("localPurge:{}", input.purge_id);
9862        let applied_plan = self
9863            .conn
9864            .query_row(
9865                "SELECT value FROM _syncular_meta WHERE key = ?1",
9866                rusqlite::params![meta_key],
9867                |row| row.get::<_, String>(0),
9868            )
9869            .optional()
9870            .map_err(|error| error.to_string())?;
9871        if let Some(applied_plan) = applied_plan {
9872            if applied_plan != canonical_plan {
9873                return Err(format!(
9874                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
9875                    input.purge_id
9876                ));
9877            }
9878            return Ok(LocalDataPurgeResult {
9879                already_applied: true,
9880                purged_rows: 0,
9881                dropped_commits: 0,
9882            });
9883        }
9884
9885        let prior_outbox = self.outbox.clone();
9886        let prior_rejection_count = self.rejections.len();
9887        let prior_overlay_dirty = self.overlay_dirty.get();
9888        self.begin_observation("syncular_local_purge")?;
9889        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
9890            let row_ids = self.local_purge_base_row_ids(&targets)?;
9891            let doomed = self
9892                .outbox
9893                .iter()
9894                .filter(|commit| {
9895                    commit.ops.iter().any(|operation| {
9896                        let matching_targets = targets
9897                            .iter()
9898                            .filter(|target| target.table == operation.table)
9899                            .collect::<Vec<_>>();
9900                        if matching_targets.is_empty() {
9901                            return false;
9902                        }
9903                        if row_ids
9904                            .get(&operation.table)
9905                            .is_some_and(|ids| ids.contains(&operation.row_id))
9906                        {
9907                            return true;
9908                        }
9909                        operation.values.as_ref().is_some_and(|values| {
9910                            matching_targets
9911                                .iter()
9912                                .any(|target| Self::local_purge_values_match(target, values))
9913                        })
9914                    })
9915                })
9916                .cloned()
9917                .collect::<Vec<_>>();
9918            let doomed_ids = doomed
9919                .iter()
9920                .map(|commit| commit.client_commit_id.clone())
9921                .collect::<BTreeSet<_>>();
9922            let mut batch = ChangeAccumulator::default();
9923            let mut rejections = Vec::new();
9924            for commit in &doomed {
9925                for operation in &commit.ops {
9926                    batch.table(&operation.table);
9927                }
9928                let results = commit
9929                    .ops
9930                    .iter()
9931                    .enumerate()
9932                    .map(|(op_index, operation)| {
9933                        let rejection = RejectionRecord {
9934                            client_commit_id: commit.client_commit_id.clone(),
9935                            op_index: op_index as i32,
9936                            code: "client.local_data_purged".to_owned(),
9937                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
9938                            retryable: false,
9939                            details: None,
9940                            operation: Some(CommitOperation::from(operation)),
9941                        };
9942                        rejections.push(rejection.clone());
9943                        CommitOperationOutcome::Error { rejection }
9944                    })
9945                    .collect::<Vec<_>>();
9946                self.persist_commit_outcome(
9947                    &commit.client_commit_id,
9948                    CommitOutcomeStatus::Rejected,
9949                    &results,
9950                    Some(&commit.ops),
9951                )?;
9952                self.delete_outbox_persisted(&commit.client_commit_id)?;
9953            }
9954            if !doomed.is_empty() {
9955                self.outbox
9956                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
9957                self.rejections.extend(rejections);
9958                self.prune_commit_outcomes()?;
9959                self.overlay_dirty.set(true);
9960                batch.status = true;
9961                batch.rejections = true;
9962                batch.outcomes = true;
9963            }
9964
9965            let mut purged_rows = 0usize;
9966            for (table, ids) in &row_ids {
9967                if ids.is_empty() {
9968                    continue;
9969                }
9970                batch.table(table);
9971                for row_id in ids {
9972                    self.delete_base_row(table, row_id)?;
9973                    purged_rows += 1;
9974                }
9975            }
9976            self.rebuild_overlay_if_dirty();
9977            self.delete_unreferenced_cached_blobs();
9978            self.conn
9979                .execute(
9980                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
9981                    rusqlite::params![meta_key, canonical_plan],
9982                )
9983                .map_err(|error| error.to_string())?;
9984            Ok((
9985                batch,
9986                LocalDataPurgeResult {
9987                    already_applied: false,
9988                    purged_rows,
9989                    dropped_commits: doomed.len(),
9990                },
9991            ))
9992        })();
9993        let (batch, result) = match applied {
9994            Ok(value) => value,
9995            Err(error) => {
9996                self.rollback_observation("syncular_local_purge");
9997                self.outbox = prior_outbox;
9998                self.rejections.truncate(prior_rejection_count);
9999                self.overlay_dirty.set(prior_overlay_dirty);
10000                return Err(error);
10001            }
10002        };
10003        if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
10004            self.rollback_observation("syncular_local_purge");
10005            self.outbox = prior_outbox;
10006            self.rejections.truncate(prior_rejection_count);
10007            self.overlay_dirty.set(prior_overlay_dirty);
10008            return Err(error);
10009        }
10010        Ok(result)
10011    }
10012
10013    /// Application-authorized recovery of the replicated projection. Unlike a
10014    /// local security purge, this retains the entire outbox, device identity,
10015    /// lease, outcomes, subscription registrations, and protected bookkeeping.
10016    /// The projection reset, subscription rewind, optimistic replay, and
10017    /// idempotency marker share one savepoint for interruption safety.
10018    pub fn rebootstrap_local_data(
10019        &mut self,
10020        input: &LocalDataRebootstrapInput,
10021    ) -> Result<LocalDataRebootstrapResult, String> {
10022        if input.rebootstrap_id.is_empty()
10023            || input.rebootstrap_id.len() > 128
10024            || !is_local_operation_code_like(&input.rebootstrap_id)
10025        {
10026            return Err(
10027                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
10028                    .to_owned(),
10029            );
10030        }
10031        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
10032        if let Some(persisted) = self.get_meta_strict(&meta_key)? {
10033            let (retained_commits, reset_subscriptions) =
10034                decode_local_rebootstrap_receipt(&persisted)?;
10035            return Ok(LocalDataRebootstrapResult {
10036                already_applied: true,
10037                retained_commits,
10038                reset_subscriptions,
10039            });
10040        }
10041        if self.stopped || self.schema_floor.is_some() {
10042            return Err(
10043                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
10044                    .to_owned(),
10045            );
10046        }
10047
10048        let retained_commits = self.outbox.len();
10049        let reset_subscriptions = self.subs.len();
10050        let prior_subs = self.subs.clone();
10051        let prior_upgrading = self.upgrading;
10052        let prior_stopped = self.stopped;
10053        let prior_schema_floor = self.schema_floor.clone();
10054        let prior_overlay_dirty = self.overlay_dirty.get();
10055        let prior_sync_needed = self.sync_needed;
10056        let prior_sync_intents = self.sync_intent_queue.clone();
10057        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
10058
10059        self.begin_observation("syncular_local_rebootstrap")?;
10060        let mut batch = ChangeAccumulator::default();
10061        let applied = (|| -> Result<(), String> {
10062            self.run_schema_reset_observed(&mut batch, false)?;
10063            self.conn
10064                .execute(
10065                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
10066                    rusqlite::params![meta_key, receipt],
10067                )
10068                .map_err(|error| error.to_string())?;
10069            self.sync_needed = true;
10070            self.sync_intent_queue.push_back(SyncIntent::Interactive);
10071            batch.status = true;
10072            Ok(())
10073        })();
10074        if let Err(error) = applied {
10075            self.rollback_observation("syncular_local_rebootstrap");
10076            self.subs = prior_subs;
10077            self.upgrading = prior_upgrading;
10078            self.stopped = prior_stopped;
10079            self.schema_floor = prior_schema_floor;
10080            self.overlay_dirty.set(prior_overlay_dirty);
10081            self.sync_needed = prior_sync_needed;
10082            self.sync_intent_queue = prior_sync_intents;
10083            return Err(error);
10084        }
10085        if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
10086            self.rollback_observation("syncular_local_rebootstrap");
10087            self.subs = prior_subs;
10088            self.upgrading = prior_upgrading;
10089            self.stopped = prior_stopped;
10090            self.schema_floor = prior_schema_floor;
10091            self.overlay_dirty.set(prior_overlay_dirty);
10092            self.sync_needed = prior_sync_needed;
10093            self.sync_intent_queue = prior_sync_intents;
10094            return Err(error);
10095        }
10096
10097        Ok(LocalDataRebootstrapResult {
10098            already_applied: false,
10099            retained_commits,
10100            reset_subscriptions,
10101        })
10102    }
10103
10104    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
10105
10106    /// Delete base rows matching the effective scopes; `Err(())` = no local
10107    /// scope-column mapping for a key (the fail-closed case).
10108    fn purge_scope_rows(
10109        &mut self,
10110        table_name: &str,
10111        effective: &[(String, Vec<String>)],
10112    ) -> Result<(), ()> {
10113        if effective.is_empty() {
10114            return Ok(());
10115        }
10116        let table = self.schema.table(table_name).ok_or(())?.clone();
10117        let mut clauses = Vec::new();
10118        let mut params: Vec<SqlValue> = Vec::new();
10119        for (variable, values) in effective {
10120            let column = table.scope_column(variable).ok_or(())?;
10121            let placeholders: Vec<String> = values
10122                .iter()
10123                .map(|v| {
10124                    params.push(SqlValue::Text(v.clone()));
10125                    "?".to_owned()
10126                })
10127                .collect();
10128            clauses.push(format!(
10129                "{} IN ({})",
10130                quote_ident(column),
10131                placeholders.join(", ")
10132            ));
10133        }
10134        let sql = format!(
10135            "DELETE FROM {} WHERE {}",
10136            base_table(table_name),
10137            clauses.join(" AND ")
10138        );
10139        self.overlay_dirty.set(true);
10140        self.conn
10141            .execute(&sql, rusqlite::params_from_iter(&params))
10142            .map_err(|_| ())?;
10143        self.conn
10144            .execute(
10145                &format!(
10146                    "DELETE FROM {} WHERE {}",
10147                    visible_table(table_name),
10148                    clauses.join(" AND ")
10149                ),
10150                rusqlite::params_from_iter(&params),
10151            )
10152            .map_err(|_| ())?;
10153        Ok(())
10154    }
10155
10156    /// §3.3: drop pending commits whose upserts provably land in the
10157    /// revoked effective scopes — whole-commit, never per-operation.
10158    fn drop_doomed_outbox(
10159        &mut self,
10160        table_name: &str,
10161        effective: &[(String, Vec<String>)],
10162    ) -> Result<bool, String> {
10163        if effective.is_empty() {
10164            return Ok(false);
10165        }
10166        let Some(table) = self.schema.table(table_name).cloned() else {
10167            return Ok(false);
10168        };
10169        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
10170        for (variable, values) in effective {
10171            match table.scope_column(variable) {
10172                Some(column) => mappings.push((column, values)),
10173                None => return Ok(false), // not provable without a mapping
10174            }
10175        }
10176        let doomed: Vec<OutboxCommit> = self
10177            .outbox
10178            .iter()
10179            .filter(|commit| {
10180                commit.ops.iter().any(|op| {
10181                    op.upsert
10182                        && op.table == table_name
10183                        && op.values.as_ref().is_some_and(|values| {
10184                            mappings
10185                                .iter()
10186                                .all(|(column, allowed)| match values.get(*column) {
10187                                    Some(Value::String(s)) => allowed.contains(s),
10188                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
10189                                    _ => false,
10190                                })
10191                        })
10192                })
10193            })
10194            .cloned()
10195            .collect();
10196        if doomed.is_empty() {
10197            return Ok(false);
10198        }
10199        let mut rejections = Vec::new();
10200        for commit in &doomed {
10201            let results = commit
10202                .ops
10203                .iter()
10204                .enumerate()
10205                .map(|(op_index, operation)| {
10206                    let rejection = RejectionRecord {
10207                        client_commit_id: commit.client_commit_id.clone(),
10208                        op_index: op_index as i32,
10209                        code: "sync.scope_revoked".to_owned(),
10210                        message: "the commit was dropped because its effective scope was revoked"
10211                            .to_owned(),
10212                        retryable: false,
10213                        details: None,
10214                        operation: Some(CommitOperation::from(operation)),
10215                    };
10216                    rejections.push(rejection.clone());
10217                    CommitOperationOutcome::Error { rejection }
10218                })
10219                .collect::<Vec<_>>();
10220            self.persist_commit_outcome(
10221                &commit.client_commit_id,
10222                CommitOutcomeStatus::Rejected,
10223                &results,
10224                Some(&commit.ops),
10225            )?;
10226            self.delete_outbox_persisted(&commit.client_commit_id)?;
10227        }
10228        self.prune_commit_outcomes()?;
10229        let doomed_ids = doomed
10230            .iter()
10231            .map(|commit| commit.client_commit_id.as_str())
10232            .collect::<BTreeSet<_>>();
10233        self.outbox
10234            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
10235        self.rejections.extend(rejections);
10236        self.overlay_dirty.set(true);
10237        Ok(true)
10238    }
10239
10240    // -- blobs (§5.9) ----------------------------------------------------------------
10241
10242    /// §5.9.7: hash bytes into the content address, cache them, queue the
10243    /// upload (flushed before the next push, B4). Returns the canonical
10244    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
10245    /// column value.
10246    pub fn upload_blob(
10247        &mut self,
10248        bytes: &[u8],
10249        media_type: Option<String>,
10250        name: Option<String>,
10251    ) -> Result<Value, String> {
10252        let blob_id = blob_id_for(bytes);
10253        let now = self.clock_now_ms();
10254        let tx = self.conn.transaction().map_err(|e| e.to_string())?;
10255        tx.execute(
10256            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
10257             ON CONFLICT(blob_id) DO NOTHING",
10258            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
10259        )
10260        .map_err(|e| e.to_string())?;
10261        tx.execute(
10262            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
10263            rusqlite::params![blob_id, media_type, now],
10264        )
10265        .map_err(|e| e.to_string())?;
10266        tx.commit().map_err(|e| e.to_string())?;
10267        self.enforce_blob_cache_cap();
10268        let mut obj = Map::new();
10269        obj.insert("blobId".to_owned(), Value::from(blob_id));
10270        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
10271        if let Some(mt) = media_type {
10272            obj.insert("mediaType".to_owned(), Value::from(mt));
10273        }
10274        if let Some(n) = name {
10275            obj.insert("name".to_owned(), Value::from(n));
10276        }
10277        Ok(Value::Object(obj))
10278    }
10279
10280    /// §5.9.7: resolve an owned blob body without driver encoding. A
10281    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
10282    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
10283    pub fn fetch_blob_bytes(
10284        &mut self,
10285        transport: &mut dyn Transport,
10286        blob_id_or_ref: &str,
10287    ) -> Result<FetchedBlob, (String, String)> {
10288        let simple = |m: String| ("client.failed".to_owned(), m);
10289        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
10290            blob_id_or_ref.to_owned()
10291        } else {
10292            let value: Value = serde_json::from_str(blob_id_or_ref)
10293                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
10294            value
10295                .get("blobId")
10296                .and_then(Value::as_str)
10297                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
10298                .to_owned()
10299        };
10300        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
10301            return Ok(cached);
10302        }
10303        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
10304        // blob.not_found) verbatim so the harness can assert on it. The
10305        // authorized endpoint serves bytes inline OR (always-issue, presign
10306        // configured) a signed url the client fetches directly — no host auth,
10307        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
10308        #[cfg(feature = "bench-internals")]
10309        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
10310        let bytes = match transport
10311            .blob_download(&blob_id)
10312            .map_err(|e| (e.code, e.message))?
10313        {
10314            BlobDownload::Bytes(bytes) => bytes,
10315            BlobDownload::Url {
10316                url,
10317                url_expires_at_ms,
10318            } => {
10319                // §5.9.5: MUST NOT start a fetch at/past expiry.
10320                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
10321                    return Err((
10322                        "sync.segment_expired".to_owned(),
10323                        format!(
10324                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
10325                        ),
10326                    ));
10327                }
10328                transport
10329                    .fetch_blob_url(&url)
10330                    .map_err(|e| (e.code, e.message))?
10331            }
10332        };
10333        #[cfg(feature = "bench-internals")]
10334        drop(download_phase);
10335        #[cfg(feature = "bench-internals")]
10336        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
10337        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
10338        if blob_id_for(&bytes) != blob_id {
10339            return Err(simple(format!(
10340                "blob content address mismatch for {blob_id}"
10341            )));
10342        }
10343        #[cfg(feature = "bench-internals")]
10344        drop(validate_phase);
10345        #[cfg(feature = "bench-internals")]
10346        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
10347        let now = self.clock_now_ms();
10348        self.conn
10349            .execute(
10350                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
10351                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
10352            )
10353            .map_err(|e| simple(e.to_string()))?;
10354        self.conn
10355            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
10356            .map_err(|e| simple(e.to_string()))?;
10357        self.enforce_blob_cache_cap();
10358        #[cfg(feature = "bench-internals")]
10359        drop(insert_phase);
10360        self.get_cached_blob_bytes(&blob_id)
10361            .map_err(simple)?
10362            .ok_or_else(|| simple("blob cache write failed".to_owned()))
10363    }
10364
10365    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
10366        #[cfg(feature = "bench-internals")]
10367        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
10368        let mut stmt = self
10369            .conn
10370            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
10371            .map_err(|e| e.to_string())?;
10372        let mut rows = stmt
10373            .query(rusqlite::params![blob_id])
10374            .map_err(|e| e.to_string())?;
10375        if let Some(row) = rows.next().map_err(|e| e.to_string())? {
10376            let bytes: Vec<u8> = row.get(0).map_err(|e| e.to_string())?;
10377            let byte_length: i64 = row.get(1).map_err(|e| e.to_string())?;
10378            let media_type: Option<String> = row.get(2).map_err(|e| e.to_string())?;
10379            return Ok(Some(FetchedBlob {
10380                blob_id: blob_id.to_owned(),
10381                byte_length,
10382                bytes,
10383                media_type,
10384            }));
10385        }
10386        Ok(None)
10387    }
10388
10389    /// §5.9.7 B4: upload every queued blob before push.
10390    fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
10391        let pending: Vec<(String, Option<String>)> = {
10392            let mut stmt = self
10393                .conn
10394                .prepare(
10395                    "SELECT blob_id, media_type FROM (
10396                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
10397                      UNION ALL
10398                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
10399                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
10400                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
10401                    ) ORDER BY created_at_ms, blob_id",
10402                )
10403                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
10404            let rows = stmt
10405                .query_map([], |row| {
10406                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
10407                })
10408                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
10409            rows.collect::<Result<_, _>>()
10410                .map_err(|error| match error {
10411                    rusqlite::Error::InvalidColumnType(..)
10412                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
10413                        "sync.local_corrupt",
10414                        "Pending blob upload metadata is invalid",
10415                    ),
10416                    _ => TransportError::new("client.failed", error.to_string()),
10417                })?
10418        };
10419        for (blob_id, media_type) in pending {
10420            let stored: Option<(SqlValue, SqlValue)> = self
10421                .conn
10422                .query_row(
10423                    "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
10424                    rusqlite::params![blob_id],
10425                    |row| Ok((row.get(0)?, row.get(1)?)),
10426                )
10427                .optional()
10428                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
10429            let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
10430                return Err(TransportError::new(
10431                    "sync.local_corrupt",
10432                    "Pending blob upload body is missing or corrupt",
10433                ));
10434            };
10435            if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
10436                return Err(TransportError::new(
10437                    "sync.local_corrupt",
10438                    "Pending blob upload body is missing or corrupt",
10439                ));
10440            }
10441            self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
10442            self.conn
10443                .execute(
10444                    "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
10445                    rusqlite::params![blob_id],
10446                )
10447                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
10448        }
10449        Ok(())
10450    }
10451
10452    /// §5.9.3: upload one blob, preferring the presigned direct-to-storage
10453    /// grant when the transport supports it, else streaming through the direct
10454    /// host-authenticated endpoint (capability, not fallback). A `Url` grant
10455    /// PUTs direct with no host auth; on a grant PUT failure the client streams
10456    /// through the direct endpoint — a *different, host-authenticated
10457    /// capability*, not a fall-through of the grant's authority.
10458    fn upload_one(
10459        &self,
10460        transport: &mut dyn Transport,
10461        blob_id: &str,
10462        bytes: &[u8],
10463        media_type: Option<&str>,
10464    ) -> Result<(), TransportError> {
10465        match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
10466            BlobUploadGrant::Present => return Ok(()), // idempotent §5.9.3
10467            BlobUploadGrant::Url {
10468                url,
10469                url_expires_at_ms,
10470            } => {
10471                let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
10472                if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
10473                    return Ok(());
10474                }
10475                // Failed/expired grant PUT — stream through the direct endpoint.
10476            }
10477            BlobUploadGrant::None => {
10478                // No presign store — stream through the direct endpoint.
10479            }
10480        }
10481        transport.blob_upload(blob_id, bytes, media_type)
10482    }
10483
10484    fn visible_blob_ids_sql(&self) -> String {
10485        let mut selects = Vec::new();
10486        for table in &self.schema.tables {
10487            for column in table
10488                .columns
10489                .iter()
10490                .filter(|column| column.ty == ColumnType::BlobRef)
10491            {
10492                let identifier = quote_ident(&column.name);
10493                selects.push(format!(
10494                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
10495                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
10496                     AND json_type({identifier}, '$.blobId') = 'text'",
10497                    quote_ident(&table.name),
10498                ));
10499            }
10500        }
10501        if selects.is_empty() {
10502            "SELECT NULL AS blob_id WHERE 0".to_owned()
10503        } else {
10504            selects.join(" UNION ")
10505        }
10506    }
10507
10508    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
10509    /// staged uploads pin bodies directly; no derived refcount is stored.
10510    fn enforce_blob_cache_cap(&self) {
10511        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
10512            return;
10513        };
10514        let mut total: i64 = self
10515            .conn
10516            .query_row(
10517                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
10518                [],
10519                |row| row.get(0),
10520            )
10521            .unwrap_or(0);
10522        if total <= max_bytes {
10523            return;
10524        }
10525        let query = format!(
10526            "SELECT blob_id, byte_length FROM _syncular_blobs
10527             WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
10528               AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
10529               AND blob_id NOT IN ({})
10530             ORDER BY created_at_ms ASC, blob_id ASC",
10531            self.visible_blob_ids_sql(),
10532        );
10533        let candidates: Vec<(String, i64)> = {
10534            let Ok(mut stmt) = self.conn.prepare(&query) else {
10535                return;
10536            };
10537            let Ok(rows) = stmt.query_map([], |row| {
10538                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
10539            }) else {
10540                return;
10541            };
10542            rows.filter_map(Result::ok).collect()
10543        };
10544        for (blob_id, byte_length) in candidates {
10545            if total <= max_bytes {
10546                break;
10547            }
10548            let _ = self.conn.execute(
10549                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
10550                rusqlite::params![blob_id],
10551            );
10552            total -= byte_length;
10553        }
10554    }
10555
10556    fn delete_unreferenced_cached_blobs(&mut self) {
10557        #[cfg(feature = "bench-internals")]
10558        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
10559        if !self.schema_has_blobs() {
10560            return;
10561        }
10562        self.rebuild_overlay_if_dirty();
10563        let _ = self.conn.execute(
10564            &format!(
10565                "DELETE FROM _syncular_blobs
10566                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
10567                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
10568                   AND blob_id NOT IN ({})",
10569                self.visible_blob_ids_sql(),
10570            ),
10571            [],
10572        );
10573    }
10574
10575    // -- local row storage ----------------------------------------------------------
10576
10577    /// The cached per-table primary-key upsert SQL for `full_table` (see
10578    /// the `insert_sql` field: built once, reused per row).
10579    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
10580        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
10581            return sql.clone();
10582        }
10583        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
10584        columns.push(quote_ident("_syncular_version"));
10585        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
10586        let primary_key = quote_ident(&table.primary_key);
10587        let updates = columns
10588            .iter()
10589            .filter(|column| **column != primary_key)
10590            .map(|column| format!("{column}=excluded.{column}"))
10591            .collect::<Vec<_>>()
10592            .join(", ");
10593        let sql = format!(
10594            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
10595            columns.join(", "),
10596            placeholders.join(", ")
10597        );
10598        self.insert_sql
10599            .borrow_mut()
10600            .insert(full_table.to_owned(), sql.clone());
10601        sql
10602    }
10603
10604    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
10605        self.overlay_dirty.set(true);
10606        self.write_row(&base_table(table_name), table_name, row, version)
10607    }
10608
10609    fn write_row(
10610        &self,
10611        full_table: &str,
10612        table_name: &str,
10613        row: &Row,
10614        version: i64,
10615    ) -> Result<(), String> {
10616        #[cfg(feature = "bench-internals")]
10617        let _phase = self.benchmark_phases.start(Phase::RowWrite);
10618        let table = self
10619            .schema
10620            .table(table_name)
10621            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
10622        let sql = self.insert_row_sql(full_table, table);
10623        let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
10624        let params = row
10625            .iter()
10626            .map(RowParam::Cell)
10627            .chain(std::iter::once(RowParam::Version(version)));
10628        stmt.execute(rusqlite::params_from_iter(params))
10629            .map_err(|e| e.to_string())?;
10630        Ok(())
10631    }
10632
10633    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
10634        let table = self
10635            .schema
10636            .table(table_name)
10637            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
10638        self.overlay_dirty.set(true);
10639        let sql = format!(
10640            "DELETE FROM {} WHERE {}",
10641            base_table(table_name),
10642            row_id_predicate(table)
10643        );
10644        let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
10645        stmt.execute(rusqlite::params![row_id])
10646            .map_err(|e| e.to_string())?;
10647        Ok(())
10648    }
10649
10650    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
10651    /// the outbox changed since the last rebuild (the no-op sync round).
10652    fn rebuild_overlay_if_dirty(&mut self) {
10653        if self.overlay_dirty.get() {
10654            self.rebuild_overlay();
10655        }
10656    }
10657
10658    /// §7.1: local reads see outbox state applied optimistically — rebuild
10659    /// every visible table as (base server state) + (pending outbox replay
10660    /// on top). Optimistic rows carry version `-1`.
10661    fn rebuild_overlay(&mut self) {
10662        #[cfg(feature = "bench-internals")]
10663        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
10664        #[cfg(test)]
10665        self.overlay_rebuild_count
10666            .set(self.overlay_rebuild_count.get() + 1);
10667        self.exec("SAVEPOINT syncular_overlay");
10668        for table in self.schema.tables.clone() {
10669            for index in &table.fts_indexes {
10670                let _ = self.drop_fts_triggers(index);
10671            }
10672            let visible = visible_table(&table.name);
10673            let base = base_table(&table.name);
10674            self.exec(&format!("DELETE FROM {visible}"));
10675            self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
10676        }
10677        for commit in &self.outbox {
10678            self.apply_outbox_ops(&commit.ops);
10679        }
10680        for table in self.schema.tables.clone() {
10681            for index in &table.fts_indexes {
10682                let _ = self.rebuild_fts_projection(&table, index);
10683                let _ = self.create_fts_triggers(&table, index);
10684            }
10685        }
10686        self.exec("RELEASE syncular_overlay");
10687        self.overlay_dirty.set(false);
10688    }
10689
10690    fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
10691        #[cfg(feature = "bench-internals")]
10692        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
10693        for op in ops {
10694            #[cfg(feature = "bench-internals")]
10695            if let Some(phase) = &mut phase {
10696                phase.unit();
10697            }
10698            let Some(table) = self.schema.table(&op.table) else {
10699                continue;
10700            };
10701            if op.upsert {
10702                let Some(values) = op.values.as_ref() else {
10703                    continue;
10704                };
10705                // §7.1: the overlay applies the operation's PRESENT columns
10706                // over the current local row. A partial operation over an
10707                // absent local row leaves it absent — the server answers it
10708                // with sync.row_deleted or sync.row_missing (§6.2).
10709                let full = table
10710                    .columns
10711                    .iter()
10712                    .all(|column| values.contains_key(&column.name));
10713                let local = self.visible_row(table, &op.row_id);
10714                if local.is_none() && !full {
10715                    continue;
10716                }
10717                let mut row: Row = Vec::with_capacity(table.columns.len());
10718                let mut ok = true;
10719                for (index, column) in table.columns.iter().enumerate() {
10720                    let incoming = match values.get(&column.name) {
10721                        Some(raw) => json_to_column_value(column, Some(raw)),
10722                        None => Ok(local
10723                            .as_ref()
10724                            .and_then(|(values, _)| values.get(index).cloned())
10725                            .flatten()),
10726                    };
10727                    match incoming {
10728                        Ok(value) => row.push(value),
10729                        Err(_) => {
10730                            ok = false;
10731                            break;
10732                        }
10733                    }
10734                }
10735                if ok {
10736                    let version = local.as_ref().map_or(-1, |(_, version)| *version);
10737                    let _ = self.write_row(&visible_table(&table.name), &table.name, &row, version);
10738                }
10739            } else {
10740                let sql = format!(
10741                    "DELETE FROM {} WHERE {}",
10742                    visible_table(&table.name),
10743                    row_id_predicate(table)
10744                );
10745                let _ = self
10746                    .conn
10747                    .prepare_cached(&sql)
10748                    .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]));
10749            }
10750        }
10751    }
10752
10753    /// The visible (optimistic) row's values plus version, or `None` when the
10754    /// row is absent from the overlay.
10755    fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
10756        let sql = format!(
10757            "SELECT * FROM {} WHERE {}",
10758            visible_table(&table.name),
10759            row_id_predicate(table)
10760        );
10761        let mut stmt = self.conn.prepare_cached(&sql).ok()?;
10762        let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
10763        let row = rows.next().ok()??;
10764        let mut values = Vec::with_capacity(table.columns.len());
10765        for (index, column) in table.columns.iter().enumerate() {
10766            let raw = row.get_ref(index).ok()?;
10767            let json = sql_ref_to_json(column, raw);
10768            values.push(json_to_column_value(column, Some(&json)).ok()?);
10769        }
10770        let version: i64 = row.get(table.columns.len()).ok()?;
10771        Some((values, version))
10772    }
10773
10774    fn exec(&self, sql: &str) {
10775        let _ = self.conn.execute_batch(sql);
10776    }
10777
10778    // -- realtime (§8) ---------------------------------------------------------------
10779
10780    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
10781        if self.realtime_connected {
10782            return Ok(());
10783        }
10784        transport
10785            .realtime_connect_for_client(&self.client_id)
10786            .map_err(|e| format!("{}: {}", e.code, e.message))?;
10787        self.realtime_connected = true;
10788        Ok(())
10789    }
10790
10791    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
10792        if !self.realtime_connected {
10793            return;
10794        }
10795        let _ = transport.realtime_close();
10796        self.realtime_connected = false;
10797        self.presence.clear(); // §8.6.1: presence is per-connection
10798    }
10799
10800    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
10801    /// document for `scope_key`. Requires a live socket; the document is
10802    /// ephemeral (lost on disconnect). Authorization is the connection's
10803    /// registration (§8.6.3) — an unheld key is rejected loudly by the
10804    /// server with `presence.forbidden`.
10805    pub fn set_presence(
10806        &mut self,
10807        transport: &mut dyn Transport,
10808        scope_key: &str,
10809        doc: Option<&Value>,
10810    ) -> Result<(), String> {
10811        if !self.realtime_connected {
10812            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
10813        }
10814        let text = encode_presence_publish(scope_key, doc);
10815        transport
10816            .realtime_send(&text)
10817            .map_err(|e| format!("{}: {}", e.code, e.message))
10818    }
10819
10820    /// §8.6: the peers currently present on a scope key (ephemeral).
10821    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
10822        self.presence
10823            .get(scope_key)
10824            .map(|peers| peers.values().cloned().collect())
10825            .unwrap_or_default()
10826    }
10827
10828    /// §8.6 apply an inbound presence fanout to the local map.
10829    fn apply_presence(
10830        &mut self,
10831        scope_key: String,
10832        kind: Option<PresenceKind>,
10833        actor_id: Option<String>,
10834        client_id: Option<String>,
10835        doc: Option<Value>,
10836        error: Option<String>,
10837    ) {
10838        // The publisher-directed error variant is out-of-band; nothing to
10839        // record in the peer map.
10840        if error.is_some() {
10841            return;
10842        }
10843        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
10844            return;
10845        };
10846        let peer_key = format!("{actor_id} {client_id}");
10847        match kind {
10848            PresenceKind::Leave => {
10849                if let Some(peers) = self.presence.get_mut(&scope_key) {
10850                    peers.remove(&peer_key);
10851                    if peers.is_empty() {
10852                        self.presence.remove(&scope_key);
10853                    }
10854                }
10855            }
10856            _ => {
10857                let doc = match doc {
10858                    Some(Value::Object(_)) => doc.unwrap(),
10859                    _ => return,
10860                };
10861                self.presence.entry(scope_key).or_default().insert(
10862                    peer_key,
10863                    PresencePeer {
10864                        actor_id,
10865                        client_id,
10866                        doc,
10867                    },
10868                );
10869            }
10870        }
10871    }
10872
10873    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
10874    pub fn on_realtime_text(&mut self, text: &str) {
10875        match parse_control(text) {
10876            Ok(ControlMessage::Hello { requires_sync, .. }) => {
10877                if requires_sync {
10878                    // §8.1: pull before trusting the socket for continuity.
10879                    self.set_sync_needed(true, true);
10880                }
10881            }
10882            Ok(ControlMessage::Presence {
10883                scope_key,
10884                kind,
10885                actor_id,
10886                client_id,
10887                doc,
10888                error,
10889                ..
10890            }) => {
10891                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
10892            }
10893            Ok(ControlMessage::Wake { .. }) => {
10894                // §8.3: any wake-up means "run a pull soon", never data.
10895                self.set_sync_needed(true, true);
10896            }
10897            _ => {}
10898        }
10899    }
10900
10901    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
10902    /// a pull response per section; an unapplied delta is a wake-up.
10903    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
10904        if self.stopped {
10905            return;
10906        }
10907        #[cfg(feature = "bench-internals")]
10908        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
10909        let message = match decode_message(bytes) {
10910            Ok(m) if m.msg_kind == MsgKind::Response => m,
10911            _ => {
10912                self.set_sync_needed(true, true);
10913                return;
10914            }
10915        };
10916        #[cfg(feature = "bench-internals")]
10917        drop(decode_phase);
10918        let mut frames = message.frames.into_iter();
10919        let mut applied_cursor: Option<i64> = None;
10920        let mut any_covered = false;
10921        let mut dropped = false;
10922        while let Some(frame) = frames.next() {
10923            let Frame::SubStart {
10924                id,
10925                status,
10926                effective_scopes,
10927                ..
10928            } = frame
10929            else {
10930                continue;
10931            };
10932            let mut body = Vec::new();
10933            let mut next_cursor: Option<i64> = None;
10934            for inner in frames.by_ref() {
10935                match inner {
10936                    Frame::SubEnd {
10937                        next_cursor: nc, ..
10938                    } => {
10939                        next_cursor = Some(nc);
10940                        break;
10941                    }
10942                    Frame::Unknown { .. } => {}
10943                    other => body.push(other),
10944                }
10945            }
10946            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
10947                dropped = true;
10948                continue;
10949            };
10950            let sub = &self.subs[sub_index];
10951            // §8.2: only locally active, not mid-bootstrap subscriptions
10952            // apply; skipped sections are repaired by the next pull.
10953            if status != SubStatus::Active
10954                || sub.state != SubState::Active
10955                || sub.bootstrap_state.is_some()
10956                || !sub.synced_once
10957            {
10958                dropped = true;
10959                continue;
10960            }
10961            if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
10962                // Idempotent redelivery of an already-covered window.
10963                any_covered = true;
10964                continue;
10965            }
10966            let previous_effective = self.subs[sub_index].effective.clone();
10967            let previous_cursor = self.subs[sub_index].cursor;
10968            self.subs[sub_index].effective = Some(effective_scopes);
10969            let mut failed = false;
10970            for inner in body {
10971                match inner {
10972                    Frame::Commit {
10973                        tables, changes, ..
10974                    } => {
10975                        if self.apply_commit_frame(&tables, &changes).is_err() {
10976                            failed = true;
10977                            break;
10978                        }
10979                    }
10980                    _ => {
10981                        failed = true;
10982                        break;
10983                    }
10984                }
10985            }
10986            let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
10987                self.apply_sub_end(sub_index, cursor, None)
10988                    .ok()
10989                    .map(|()| cursor)
10990            });
10991            let Some(next_cursor) = committed else {
10992                self.subs[sub_index].effective = previous_effective;
10993                self.subs[sub_index].cursor = previous_cursor;
10994                dropped = true;
10995                continue;
10996            };
10997            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
10998        }
10999        if let Some(cursor) = applied_cursor {
11000            // §8.2 ack point: the highest applied SUB_END.nextCursor.
11001            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
11002            let _ = transport.realtime_send(&ack);
11003        } else if !any_covered || dropped {
11004            // §8.2: a delta not applied at all is treated as a wake-up.
11005            self.set_sync_needed(true, true);
11006        }
11007    }
11008
11009    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
11010    /// cursor across active, non-bootstrapping subscriptions that have
11011    /// synced at least once. No such subscription, no ack.
11012    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
11013        if !self.realtime_connected {
11014            return;
11015        }
11016        let floor = self
11017            .subs
11018            .iter()
11019            .filter(|s| {
11020                s.state == SubState::Active
11021                    && s.bootstrap_state.is_none()
11022                    && s.synced_once
11023                    && s.cursor >= 0
11024            })
11025            .map(|s| s.cursor)
11026            .min();
11027        if let Some(cursor) = floor {
11028            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
11029            let _ = transport.realtime_send(&ack);
11030        }
11031    }
11032}