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, encode_row_json,
48    json_to_column_value, json_to_scope_map, normalize_values_casing, render_row_id_json,
49    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 serde_json::json;
144
145    fn client() -> SyncClient {
146        SyncClient::new(
147            "retry-test".to_owned(),
148            &json!({
149                "version": 1,
150                "tables": [{
151                    "name": "tasks",
152                    "primaryKey": "id",
153                    "columns": [
154                        { "name": "id", "type": "string", "nullable": false },
155                        { "name": "project_id", "type": "string", "nullable": false }
156                    ],
157                    "scopes": [{ "pattern": "project:{project_id}" }]
158                }]
159            }),
160            ClientLimits::default(),
161        )
162        .expect("test client")
163    }
164
165    #[test]
166    fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
167        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
168            {"name":"id","type":"string","nullable":false},
169            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
170        for state in ["absent", "cached", "pinned"] {
171            let path = std::env::temp_dir().join(format!(
172                "syncular-blob-stage-{}.sqlite",
173                uuid::Uuid::new_v4()
174            ));
175            let mut client = SyncClient::open_path(
176                "stage-test".into(),
177                &schema,
178                Default::default(),
179                path.to_str().unwrap(),
180            )
181            .unwrap();
182            let bytes = b"atomic stage";
183            if state != "absent" {
184                client
185                    .upload_blob(bytes, Some("text/plain".into()), None)
186                    .unwrap();
187                if state == "cached" {
188                    client
189                        .conn
190                        .execute("DELETE FROM _syncular_blob_uploads", [])
191                        .unwrap();
192                }
193            }
194            let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
195            client
196                .conn
197                .execute_batch(
198                    "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
199                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
200                 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
201                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
202                )
203                .unwrap();
204            let error = client
205                .upload_blob(bytes, Some("application/octet-stream".into()), None)
206                .unwrap_err();
207            assert!(error.contains("injected stage failure"), "{state}: {error}");
208            assert!(client.conn.is_autocommit());
209            let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
210            drop(client);
211            let mut reopened = SyncClient::open_path(
212                "stage-test".into(),
213                &schema,
214                Default::default(),
215                path.to_str().unwrap(),
216            )
217            .unwrap();
218            let durable = reopened
219                .query("SELECT * FROM _syncular_blobs", &[])
220                .unwrap();
221            reopened
222                .conn
223                .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
224                .unwrap();
225            let retried = reopened.upload_blob(bytes, None, None).unwrap();
226            assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
227            assert_eq!(
228                reopened
229                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
230                    .unwrap(),
231                vec![Map::from_iter([(
232                    "blob_id".into(),
233                    Value::from(blob_id_for(bytes)),
234                )])]
235            );
236            drop(reopened);
237            std::fs::remove_file(&path).unwrap();
238            assert_eq!(durable, bodies, "{state}: reopened body state");
239            assert_eq!(immediate, bodies, "{state}: immediate body state");
240        }
241    }
242
243    #[test]
244    fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
245        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
246            {"name":"id","type":"string","nullable":false},
247            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
248        for fault in [
249            "missing",
250            "body-type",
251            "length",
252            "hash",
253            "pin-metadata",
254            "read",
255            "pin-delete",
256        ] {
257            let path = std::env::temp_dir().join(format!(
258                "syncular-blob-upload-{}.sqlite",
259                uuid::Uuid::new_v4()
260            ));
261            let mut client = SyncClient::open_path(
262                "upload-test".into(),
263                &schema,
264                Default::default(),
265                path.to_str().unwrap(),
266            )
267            .unwrap();
268            let bytes = b"durable upload";
269            let ref_value = client
270                .upload_blob(bytes, Some("text/plain".into()), None)
271                .unwrap();
272            let commit = client
273                .mutate(vec![Mutation::Upsert {
274                    table: "attachments".into(),
275                    values: Map::from_iter([
276                        ("id".into(), Value::from("pending")),
277                        (
278                            "file".into(),
279                            Value::from(format!(
280                                "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
281                                blob_id_for(bytes),
282                                bytes.len()
283                            )),
284                        ),
285                    ]),
286                    base_version: None,
287                }])
288                .unwrap();
289            client
290                .conn
291                .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
292                .unwrap();
293            client.conn.execute_batch(match fault {
294                "missing" => "DELETE FROM _syncular_blobs",
295                "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
296                "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
297                "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
298                "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
299                "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
300                _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
301            }).unwrap();
302            let state = if fault == "read" {
303                Vec::new()
304            } else {
305                client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
306            };
307            let mut transport = CountingRealtimeTransport::default();
308            let error = client.flush_blob_uploads(&mut transport).unwrap_err();
309            assert_eq!(
310                error.code,
311                if matches!(fault, "read" | "pin-delete") {
312                    "client.failed"
313                } else {
314                    "sync.local_corrupt"
315                },
316                "{fault}"
317            );
318            assert_eq!(
319                transport.blob_uploads.len(),
320                usize::from(fault == "pin-delete"),
321                "{fault}: corrupt bytes must never be uploaded"
322            );
323            assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
324            if fault != "read" {
325                assert_eq!(
326                    client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
327                    state
328                );
329            } else {
330                client
331                    .conn
332                    .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
333                    .unwrap();
334            }
335            drop(client);
336            let mut reopened = SyncClient::open_path(
337                "upload-test".into(),
338                &schema,
339                Default::default(),
340                path.to_str().unwrap(),
341            )
342            .unwrap();
343            assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
344            if fault != "read" {
345                assert_eq!(
346                    reopened
347                        .query("SELECT * FROM _syncular_blobs", &[])
348                        .unwrap(),
349                    state
350                );
351            }
352            if fault == "pin-delete" {
353                reopened
354                    .conn
355                    .execute_batch("DROP TRIGGER fail_pin_delete")
356                    .unwrap();
357            }
358            if fault == "pin-metadata" {
359                reopened
360                    .conn
361                    .execute(
362                        "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
363                        [],
364                    )
365                    .unwrap();
366            }
367            reopened
368                .conn
369                .execute_batch(
370                    "DELETE FROM _syncular_blobs;
371                     INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
372                )
373                .unwrap();
374            transport.blob_uploads.clear();
375            reopened.flush_blob_uploads(&mut transport).unwrap();
376            assert_eq!(
377                transport.blob_uploads,
378                [(blob_id_for(bytes), bytes.to_vec())]
379            );
380            assert_eq!(
381                reopened
382                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
383                    .unwrap(),
384                Vec::<Map<String, Value>>::new()
385            );
386            assert_eq!(reopened.pending_commit_ids(), [commit]);
387            assert_eq!(
388                reopened
389                    .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
390                    .unwrap()
391                    .unwrap()
392                    .bytes,
393                bytes
394            );
395            drop(reopened);
396            std::fs::remove_file(path).unwrap();
397        }
398    }
399
400    #[test]
401    fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
402        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
403            {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
404            "scopes":[{"pattern":"project:{project_id}"}]}]});
405        for realtime in [false, true] {
406            for fault in [
407                "none",
408                "row",
409                "revision",
410                "commit",
411                "cursor",
412                "error",
413                "missing-end",
414            ] {
415                if realtime && fault == "missing-end" {
416                    continue;
417                }
418                let path = std::env::temp_dir()
419                    .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
420                let mut client = SyncClient::open_path(
421                    "frame-test".into(),
422                    &schema,
423                    Default::default(),
424                    path.to_str().unwrap(),
425                )
426                .unwrap();
427                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
428                client
429                    .subscribe(
430                        "tasks".into(),
431                        "tasks".into(),
432                        vec![("project_id".into(), vec!["p1".into()])],
433                        None,
434                    )
435                    .unwrap();
436                client.subs[0].cursor = 0;
437                client.subs[0].synced_once = true;
438                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
439                client.persist_sub(&client.subs[0]).unwrap();
440                client.realtime_connected = true;
441                client.drain_change_batches();
442                let mut transport = CountingRealtimeTransport::default();
443                let (_, meta) = client.build_request(false);
444                let mut response = Message {
445                    wire_version: WIRE_VERSION,
446                    msg_kind: MsgKind::Response,
447                    frames: vec![
448                        Frame::RespHeader {
449                            required_schema_version: None,
450                            latest_schema_version: None,
451                            log_epoch: Some("epoch-1".into()),
452                            reset_required: Some(false),
453                        },
454                        Frame::SubStart {
455                            id: "tasks".into(),
456                            status: SubStatus::Active,
457                            reason_code: "".into(),
458                            effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
459                            bootstrap: false,
460                        },
461                    ],
462                };
463                for (index, id) in ["first", "second"].iter().enumerate() {
464                    let values = Map::from_iter([
465                        ("id".into(), Value::from(*id)),
466                        ("project_id".into(), Value::from("p1")),
467                    ]);
468                    response.frames.push(Frame::Commit {
469                        commit_seq: index as i64 + 1,
470                        actor_id: "writer".into(),
471                        created_at_ms: 1,
472                        tables: vec!["tasks".into()],
473                        changes: vec![ssp2::model::Change {
474                            table_index: 0,
475                            row_id: (*id).into(),
476                            op: Op::Upsert,
477                            row_version: Some(index as i64 + 1),
478                            scopes: vec![("project_id".into(), "p1".into())],
479                            row: Some(
480                                encode_row_json(
481                                    client.schema.table("tasks").unwrap(),
482                                    id,
483                                    &values,
484                                    &client.encryption,
485                                )
486                                .unwrap(),
487                            ),
488                        }],
489                    });
490                }
491                response.frames.push(Frame::SubEnd {
492                    next_cursor: 2,
493                    bootstrap_state: None,
494                });
495                let valid = response.clone();
496                match fault {
497                    "row" => {
498                        let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
499                        let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
500                    },
501                    "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(),
502                    "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(),
503                    "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
504                    "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});},
505                    "missing-end" => {response.frames.truncate(3);},
506                    _ => {}
507                }
508                if realtime {
509                    client.on_realtime_binary(&mut transport, &encode_message(&response));
510                } else {
511                    assert_eq!(
512                        matches!(
513                            client.process_response(&mut transport, response, &meta),
514                            SyncOutcome::Ok(_)
515                        ),
516                        fault == "none",
517                        "{fault}"
518                    );
519                }
520                assert!(
521                    client.conn.is_autocommit(),
522                    "no open transaction after {realtime}/{fault}"
523                );
524                let count = if matches!(fault, "none" | "cursor") {
525                    2
526                } else {
527                    1
528                };
529                let expected_revision = count + u64::from(realtime && fault != "none");
530                assert_eq!(
531                    client.local_revision(),
532                    expected_revision,
533                    "{realtime}/{fault}"
534                );
535                let batches = client.drain_change_batches();
536                assert_eq!(
537                    batches.len(),
538                    expected_revision as usize,
539                    "{realtime}/{fault}"
540                );
541                let row_batches: Vec<_> = batches
542                    .iter()
543                    .filter(|batch| !batch.tables.is_empty())
544                    .collect();
545                assert_eq!(row_batches.len(), count as usize);
546                assert_eq!(
547                    row_batches
548                        .iter()
549                        .map(|b| b.revision.clone())
550                        .collect::<Vec<_>>(),
551                    (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
552                );
553                for table in ["tasks", "_syncular_base_tasks"] {
554                    assert_eq!(
555                        client
556                            .conn
557                            .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
558                                .get::<_, u64>(0))
559                            .unwrap(),
560                        count,
561                        "{realtime}/{fault}/{table}"
562                    );
563                }
564                assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
565                assert_eq!(
566                    transport
567                        .messages
568                        .iter()
569                        .filter(|m| m.contains("\"ack\""))
570                        .count(),
571                    usize::from(fault == "none")
572                );
573                if matches!(fault, "revision" | "commit" | "cursor") {
574                    client
575                        .conn
576                        .execute_batch("DROP TRIGGER fail_frame")
577                        .unwrap();
578                }
579                drop(client);
580                let mut reopened = SyncClient::open_path(
581                    "frame-test".into(),
582                    &schema,
583                    Default::default(),
584                    path.to_str().unwrap(),
585                )
586                .unwrap();
587                assert_eq!(reopened.local_revision(), expected_revision);
588                assert_eq!(
589                    reopened
590                        .query("SELECT id FROM tasks ORDER BY id", &[])
591                        .unwrap()
592                        .len(),
593                    count as usize
594                );
595                assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
596                let (_, meta) = reopened.build_request(false);
597                assert!(matches!(
598                    reopened.process_response(&mut transport, valid, &meta),
599                    SyncOutcome::Ok(_)
600                ));
601                assert_eq!(
602                    reopened
603                        .query("SELECT id FROM tasks ORDER BY id", &[])
604                        .unwrap()
605                        .len(),
606                    2
607                );
608                assert_eq!(reopened.subs[0].cursor, 2);
609                drop(reopened);
610                std::fs::remove_file(path).unwrap();
611            }
612        }
613    }
614
615    #[test]
616    fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
617        for status in [SubStatus::Reset, SubStatus::Revoked] {
618            for fault in ["subscription", "revision"] {
619                let mut client = client();
620                client
621                    .subscribe(
622                        "tasks".into(),
623                        "tasks".into(),
624                        vec![("project_id".into(), vec!["p1".into()])],
625                        None,
626                    )
627                    .unwrap();
628                client.subs[0].cursor = 41;
629                client.subs[0].synced_once = true;
630                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
631                client.persist_sub(&client.subs[0]).unwrap();
632                client
633                    .mutate(vec![Mutation::Upsert {
634                        table: "tasks".into(),
635                        values: Map::from_iter([
636                            ("id".into(), Value::from("pending")),
637                            ("project_id".into(), Value::from("p1")),
638                        ]),
639                        base_version: None,
640                    }])
641                    .unwrap();
642                let ids = client.pending_commit_ids();
643                let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
644                let revision = client.local_revision();
645                client.drain_change_batches();
646                let (_, meta) = client.build_request(false);
647                client.conn.execute_batch(if fault == "subscription" {
648                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
649                } else {
650                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
651                }).unwrap();
652                let mut transport = CountingRealtimeTransport::default();
653                let mut report = SyncReport::default();
654                // Reset changes window completeness and therefore needs a revision.
655                if status == SubStatus::Reset {
656                    client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
657                }
658                let result = client.process_section(
659                    &mut transport,
660                    "tasks",
661                    status,
662                    "sync.scope_revoked",
663                    vec![],
664                    vec![],
665                    Some((42, None)),
666                    &meta,
667                    &mut report,
668                );
669                assert!(result.is_err(), "{status:?}/{fault}");
670                assert!(client.conn.is_autocommit());
671                assert_eq!(client.subs[0].cursor, 41);
672                assert_eq!(client.subs[0].state, SubState::Active);
673                assert_eq!(client.pending_commit_ids(), ids);
674                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
675                assert!(client.rejections.is_empty());
676                assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
677                assert_eq!(client.local_revision(), revision);
678                assert!(client.drain_change_batches().is_empty());
679                assert!(report.revoked.is_empty() && report.resets.is_empty());
680            }
681        }
682    }
683
684    #[test]
685    fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
686        let mut client = client();
687        client
688            .subscribe(
689                "tasks".into(),
690                "tasks".into(),
691                vec![("project_id".into(), vec!["p1".into()])],
692                None,
693            )
694            .unwrap();
695        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
696        client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
697            CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
698        let row = |id: &str| ssp2::segment::SegmentRow {
699            server_version: 1,
700            values: vec![
701                Some(ColumnValue::String(id.into())),
702                Some(ColumnValue::String("p1".into())),
703            ],
704        };
705        let segment = RowsSegment {
706            table: "tasks".into(),
707            schema_version: 1,
708            columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
709            blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
710        };
711        assert!(client.apply_segment(0, &segment, true).is_err());
712        assert!(client.conn.is_autocommit());
713        assert_eq!(
714            client
715                .query("SELECT id FROM tasks ORDER BY id", &[])
716                .unwrap(),
717            vec![Map::from_iter([("id".into(), Value::from("first"))])]
718        );
719        assert_eq!(client.local_revision(), 1);
720        assert_eq!(client.drain_change_batches().len(), 1);
721        assert_eq!(client.subs[0].cursor, -1);
722        client
723            .conn
724            .execute_batch("DROP TRIGGER fail_block")
725            .unwrap();
726        assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
727        assert_eq!(client.local_revision(), 3);
728        assert_eq!(client.drain_change_batches().len(), 2);
729    }
730
731    #[test]
732    fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
733        assert_eq!(
734            client().diagnostics_storage().blob_cache_bytes_approx,
735            Some(0)
736        );
737        let client = SyncClient::new(
738            "diagnostic-storage".to_owned(),
739            &json!({
740                "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
741                    "columns": [{"name": "id", "type": "string", "nullable": false},
742                        {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
743            }),
744            ClientLimits {
745                blob_cache_max_bytes: Some(5),
746                ..Default::default()
747            },
748        )
749        .unwrap();
750        let empty = client.diagnostics_storage();
751        assert_eq!(empty.status, "healthy");
752        assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
753        assert_eq!(empty.retained_outcome_entries, Some(0));
754        assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
755        assert_eq!(empty.blob_cache_bytes_approx, Some(0));
756        client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
757            INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
758                VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
759            INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
760                VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
761        let written = client.diagnostics_storage();
762        assert_eq!(written.status, "pressure");
763        assert_eq!(written.pending_outbox_bytes_approx, Some(2));
764        assert_eq!(written.retained_outcome_entries, Some(2));
765        assert_eq!(written.retained_outcome_bytes_approx, Some(6));
766        assert_eq!(written.blob_cache_bytes_approx, Some(8));
767        assert_eq!(
768            written.pressure_reason_code.as_deref(),
769            Some("client.blob_cache_over_limit")
770        );
771        client
772            .conn
773            .execute_batch(
774                "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
775            DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
776            )
777            .unwrap();
778        let staged = client.diagnostics_storage();
779        assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
780        assert_eq!(staged.retained_outcome_entries, Some(0));
781        assert_eq!(staged.blob_cache_bytes_approx, Some(0));
782        assert_eq!(staged.status, "healthy");
783        client.conn.execute_batch("ROLLBACK").unwrap();
784        assert_eq!(client.diagnostics_storage(), written);
785        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
786        let unreadable = client.diagnostics_storage();
787        assert_eq!(unreadable.status, "unreadable");
788        assert!(unreadable.database_bytes_approx.is_none());
789        assert!(unreadable.pending_outbox_bytes_approx.is_none());
790        assert!(unreadable.retained_outcome_entries.is_none());
791        assert!(unreadable.retained_outcome_bytes_approx.is_none());
792        assert!(unreadable.blob_cache_bytes_approx.is_none());
793        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
794        let restored = client.diagnostics_storage();
795        assert_eq!(restored.status, written.status);
796        assert_eq!(
797            restored.retained_outcome_bytes_approx,
798            written.retained_outcome_bytes_approx
799        );
800        client.conn.set_prepared_statement_cache_capacity(0);
801        assert_eq!(client.diagnostics_storage(), restored);
802    }
803
804    #[test]
805    fn background_retry_deadlines_back_off_and_reset() {
806        let mut client = client();
807        client.schedule_background_retry();
808        assert!(matches!(
809            client.drain_sync_intents().as_slice(),
810            [SyncIntent::Background { delay_ms: 250 }]
811        ));
812        client.schedule_background_retry();
813        assert!(matches!(
814            client.drain_sync_intents().as_slice(),
815            [SyncIntent::Background { delay_ms: 500 }]
816        ));
817        client.reset_background_retry();
818        client.schedule_background_retry();
819        assert!(matches!(
820            client.drain_sync_intents().as_slice(),
821            [SyncIntent::Background { delay_ms: 250 }]
822        ));
823    }
824
825    #[test]
826    fn log_epoch_reset_requests_an_immediate_follow_up_round() {
827        let mut client = client();
828        client
829            .subscribe(
830                "epoch-tasks".to_owned(),
831                "tasks".to_owned(),
832                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
833                None,
834            )
835            .expect("subscribe");
836        client.drain_sync_intents();
837
838        client
839            .run_log_epoch_reset("epoch-2")
840            .expect("reset partition log epoch");
841
842        assert!(matches!(
843            client.drain_sync_intents().as_slice(),
844            [SyncIntent::Interactive]
845        ));
846    }
847
848    #[derive(Default)]
849    struct CountingRealtimeTransport {
850        connects: usize,
851        closes: usize,
852        messages: Vec<String>,
853        blob_uploads: Vec<(String, Vec<u8>)>,
854        blob_download: Option<Vec<u8>>,
855    }
856
857    impl Transport for CountingRealtimeTransport {
858        fn blob_upload(
859            &mut self,
860            blob_id: &str,
861            bytes: &[u8],
862            _media_type: Option<&str>,
863        ) -> Result<(), TransportError> {
864            self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
865            Ok(())
866        }
867
868        fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
869            self.blob_download
870                .clone()
871                .map(BlobDownload::Bytes)
872                .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
873        }
874
875        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
876            Err(TransportError::new("sync.transport_failed", "offline"))
877        }
878
879        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
880            Err(TransportError::new("sync.transport_failed", "offline"))
881        }
882
883        fn download_segment(
884            &mut self,
885            _request: &SegmentRequest,
886            _on_progress: &mut dyn FnMut(u64),
887        ) -> Result<Vec<u8>, TransportError> {
888            Err(TransportError::new("sync.transport_failed", "offline"))
889        }
890
891        fn realtime_connect(&mut self) -> Result<(), TransportError> {
892            self.connects += 1;
893            Ok(())
894        }
895
896        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
897            self.messages.push(text.to_owned());
898            Ok(())
899        }
900
901        fn realtime_close(&mut self) -> Result<(), TransportError> {
902            self.closes += 1;
903            Ok(())
904        }
905    }
906
907    #[test]
908    fn blob_results_own_downloaded_bytes() {
909        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
910            {"name":"id","type":"string","nullable":false},
911            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
912        let bytes = b"owned typed result".to_vec();
913        let blob_id = blob_id_for(&bytes);
914        let mut transport = CountingRealtimeTransport {
915            blob_download: Some(bytes.clone()),
916            ..Default::default()
917        };
918        let result = {
919            let mut client =
920                SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
921                    .unwrap();
922            let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
923            assert_eq!(result.blob_id, blob_id);
924            assert_eq!(result.byte_length, bytes.len() as i64);
925            assert_eq!(result.bytes, bytes);
926            assert_eq!(result.media_type, None);
927            assert_eq!(
928                client
929                    .conn
930                    .query_row(
931                        "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
932                        rusqlite::params![blob_id],
933                        |row| row.get::<_, i64>(0),
934                    )
935                    .unwrap(),
936                0
937            );
938            result
939        };
940        assert_eq!(result.bytes, bytes);
941
942        let mut client =
943            SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
944        assert_eq!(
945            client
946                .fetch_blob_bytes(&mut transport, "not json")
947                .unwrap_err(),
948            (
949                "client.failed".to_owned(),
950                "blob ref is not JSON".to_owned()
951            )
952        );
953    }
954
955    #[test]
956    fn visible_blob_references_survive_cache_trimming() {
957        let schema = json!({"version":1,"tables":[
958            {"name":"attachments","primaryKey":"id","columns":[
959                {"name":"id","type":"string","nullable":false},
960                {"name":"body","type":"blob_ref","nullable":true},
961                {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
962            {"name":"avatars","primaryKey":"id","columns":[
963                {"name":"id","type":"string","nullable":false},
964                {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
965        ]});
966        let bytes = b"referenced above cap".to_vec();
967        let blob_id = blob_id_for(&bytes);
968        let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
969        let mut client = SyncClient::new(
970            "visible-blob-refs".to_owned(),
971            &schema,
972            ClientLimits {
973                blob_cache_max_bytes: Some(1),
974                ..Default::default()
975            },
976        )
977        .unwrap();
978        client.conn.execute(
979            "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
980            rusqlite::params![reference, reference, reference, reference],
981        ).unwrap();
982        client
983            .conn
984            .execute(
985                "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
986                rusqlite::params![reference],
987            )
988            .unwrap();
989        client.conn.execute(
990            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
991            [],
992        ).unwrap();
993        let mut transport = CountingRealtimeTransport {
994            blob_download: Some(bytes.clone()),
995            ..Default::default()
996        };
997
998        assert_eq!(
999            client
1000                .fetch_blob_bytes(&mut transport, &blob_id)
1001                .unwrap()
1002                .bytes,
1003            bytes
1004        );
1005        let rows = client
1006            .conn
1007            .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1008            .unwrap()
1009            .query_map([], |row| row.get::<_, String>(0))
1010            .unwrap()
1011            .collect::<Result<Vec<_>, _>>()
1012            .unwrap();
1013        assert_eq!(rows, vec![blob_id]);
1014    }
1015
1016    #[test]
1017    fn realtime_connection_ownership_is_idempotent() {
1018        let mut client = client();
1019        let mut transport = CountingRealtimeTransport {
1020            connects: 0,
1021            closes: 0,
1022            ..Default::default()
1023        };
1024        client
1025            .connect_realtime(&mut transport)
1026            .expect("first connect");
1027        client
1028            .connect_realtime(&mut transport)
1029            .expect("idempotent connect");
1030        assert_eq!(transport.connects, 1);
1031        client.disconnect_realtime(&mut transport);
1032        client.disconnect_realtime(&mut transport);
1033        assert_eq!(transport.closes, 1);
1034        client
1035            .connect_realtime(&mut transport)
1036            .expect("deliberate reconnect");
1037        assert_eq!(transport.connects, 2);
1038    }
1039
1040    #[test]
1041    fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1042        let mut client = client();
1043        client
1044            .subscribe(
1045                "repair-tasks".to_owned(),
1046                "tasks".to_owned(),
1047                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1048                None,
1049            )
1050            .expect("subscribe");
1051        {
1052            let sub = client
1053                .subs
1054                .iter_mut()
1055                .find(|sub| sub.id == "repair-tasks")
1056                .expect("subscription");
1057            sub.cursor = 42;
1058            sub.synced_once = true;
1059            let persisted = sub.clone();
1060            client.persist_sub(&persisted).unwrap();
1061        }
1062        for table in ["_syncular_base_tasks", "tasks"] {
1063            client
1064                .conn
1065                .execute(
1066                    &format!(
1067                        "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1068                    ),
1069                    rusqlite::params!["server-row", "p1"],
1070                )
1071                .expect("seed server row");
1072        }
1073        let pending = client
1074            .mutate(vec![Mutation::Upsert {
1075                table: "tasks".to_owned(),
1076                values: Map::from_iter([
1077                    ("id".to_owned(), Value::from("offline-row")),
1078                    ("project_id".to_owned(), Value::from("p1")),
1079                ]),
1080                base_version: None,
1081            }])
1082            .expect("queue offline work");
1083        client.drain_change_batches();
1084        client.drain_sync_intents();
1085
1086        assert_eq!(
1087            client
1088                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1089                    rebootstrap_id: "support-case-001".to_owned(),
1090                })
1091                .expect("rebootstrap"),
1092            LocalDataRebootstrapResult {
1093                already_applied: false,
1094                retained_commits: 1,
1095                reset_subscriptions: 1,
1096            }
1097        );
1098        let visible_ids = client
1099            .conn
1100            .prepare("SELECT id FROM tasks ORDER BY id")
1101            .expect("prepare visible ids")
1102            .query_map([], |row| row.get::<_, String>(0))
1103            .expect("query visible ids")
1104            .collect::<Result<Vec<_>, _>>()
1105            .expect("collect visible ids");
1106        assert_eq!(visible_ids, vec!["offline-row"]);
1107        assert_eq!(client.pending_commit_ids(), vec![pending]);
1108        assert_eq!(
1109            client
1110                .subscription_state("repair-tasks")
1111                .expect("subscription")
1112                .cursor,
1113            -1
1114        );
1115        assert!(client.upgrading());
1116        assert!(client.sync_needed());
1117        assert!(matches!(
1118            client.drain_sync_intents().as_slice(),
1119            [SyncIntent::Interactive]
1120        ));
1121        assert_eq!(client.drain_change_batches().len(), 1);
1122
1123        assert_eq!(
1124            client
1125                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1126                    rebootstrap_id: "support-case-001".to_owned(),
1127                })
1128                .expect("idempotent retry"),
1129            LocalDataRebootstrapResult {
1130                already_applied: true,
1131                retained_commits: 1,
1132                reset_subscriptions: 1,
1133            }
1134        );
1135        assert!(client.drain_change_batches().is_empty());
1136    }
1137
1138    #[test]
1139    fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1140        let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1141        assert_eq!(
1142            decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1143            (3, 4)
1144        );
1145        assert_eq!(
1146            decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1147            (0, 0)
1148        );
1149        for malformed in [
1150            "",
1151            "{}",
1152            r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1153            r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1154            r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1155        ] {
1156            assert_eq!(
1157                decode_local_rebootstrap_receipt(malformed)
1158                    .expect_err("malformed receipt must fail"),
1159                "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1160            );
1161        }
1162    }
1163
1164    #[test]
1165    fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1166        let path = std::env::temp_dir().join(format!(
1167            "syncular-rebootstrap-receipt-{}.db",
1168            uuid::Uuid::new_v4()
1169        ));
1170        let schema = json!({
1171            "version": 1,
1172            "tables": [{
1173                "name": "tasks",
1174                "primaryKey": "id",
1175                "columns": [
1176                    { "name": "id", "type": "string", "nullable": false },
1177                    { "name": "project_id", "type": "string", "nullable": false }
1178                ],
1179                "scopes": [{ "pattern": "project:{project_id}" }]
1180            }]
1181        });
1182        let path_string = path.to_str().expect("UTF-8 temp path");
1183
1184        {
1185            let mut first = SyncClient::open_path(
1186                "repair-restart-client".to_owned(),
1187                &schema,
1188                ClientLimits::default(),
1189                path_string,
1190            )
1191            .expect("first open");
1192            first
1193                .subscribe(
1194                    "repair-tasks".to_owned(),
1195                    "tasks".to_owned(),
1196                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1197                    None,
1198                )
1199                .expect("subscribe");
1200            first
1201                .mutate(vec![Mutation::Upsert {
1202                    table: "tasks".to_owned(),
1203                    values: Map::from_iter([
1204                        ("id".to_owned(), Value::from("offline-row")),
1205                        ("project_id".to_owned(), Value::from("p1")),
1206                    ]),
1207                    base_version: None,
1208                }])
1209                .expect("queue offline work");
1210            assert_eq!(
1211                first
1212                    .rebootstrap_local_data(&LocalDataRebootstrapInput {
1213                        rebootstrap_id: "restart-receipt".to_owned(),
1214                    })
1215                    .expect("first rebootstrap"),
1216                LocalDataRebootstrapResult {
1217                    already_applied: false,
1218                    retained_commits: 1,
1219                    reset_subscriptions: 1,
1220                }
1221            );
1222        }
1223
1224        let mut reopened = SyncClient::open_path(
1225            "repair-restart-client".to_owned(),
1226            &schema,
1227            ClientLimits::default(),
1228            path_string,
1229        )
1230        .expect("reopen");
1231        reopened.drain_change_batches();
1232        reopened.drain_sync_intents();
1233        assert_eq!(
1234            reopened
1235                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1236                    rebootstrap_id: "restart-receipt".to_owned(),
1237                })
1238                .expect("receipt replay"),
1239            LocalDataRebootstrapResult {
1240                already_applied: true,
1241                retained_commits: 1,
1242                reset_subscriptions: 1,
1243            }
1244        );
1245        assert!(reopened.drain_change_batches().is_empty());
1246        assert!(reopened.drain_sync_intents().is_empty());
1247        drop(reopened);
1248        std::fs::remove_file(path).expect("remove temp database");
1249    }
1250
1251    #[test]
1252    fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1253        let mut malformed = client();
1254        malformed
1255            .subscribe(
1256                "repair-tasks".to_owned(),
1257                "tasks".to_owned(),
1258                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1259                None,
1260            )
1261            .expect("subscribe");
1262        malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1263        malformed.drain_change_batches();
1264        malformed.drain_sync_intents();
1265        let malformed_error = malformed
1266            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1267                rebootstrap_id: "malformed".to_owned(),
1268            })
1269            .expect_err("malformed receipt must fail closed");
1270        assert_eq!(
1271            malformed_error,
1272            "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1273        );
1274        assert!(!malformed.upgrading());
1275        assert_eq!(
1276            malformed
1277                .subscription_state("repair-tasks")
1278                .expect("unchanged subscription")
1279                .cursor,
1280            -1
1281        );
1282        assert!(malformed.drain_change_batches().is_empty());
1283        assert!(malformed.drain_sync_intents().is_empty());
1284
1285        let mut unreadable = client();
1286        unreadable
1287            .conn
1288            .execute(
1289                "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1290                rusqlite::params!["server-row", "p1"],
1291            )
1292            .expect("seed visible row");
1293        unreadable
1294            .conn
1295            .execute("DROP TABLE _syncular_meta", [])
1296            .expect("break marker storage");
1297        let unreadable_error = unreadable
1298            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1299                rebootstrap_id: "unreadable".to_owned(),
1300            })
1301            .expect_err("unreadable marker storage must fail closed");
1302        assert_eq!(
1303            unreadable_error,
1304            "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1305        );
1306        let visible_rows = unreadable
1307            .conn
1308            .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1309            .expect("visible projection remains");
1310        assert_eq!(visible_rows, 1);
1311        assert!(!unreadable.upgrading());
1312        assert!(unreadable.drain_change_batches().is_empty());
1313        assert!(unreadable.drain_sync_intents().is_empty());
1314    }
1315
1316    #[test]
1317    fn local_rebootstrap_cannot_bypass_schema_floor() {
1318        let mut client = client();
1319        client.set_schema_floor(Some(SchemaFloor {
1320            required_schema_version: Some(2),
1321            latest_schema_version: Some(2),
1322        }));
1323        let error = client
1324            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1325                rebootstrap_id: "blocked-floor".to_owned(),
1326            })
1327            .expect_err("schema floor must block repair");
1328        assert!(error.contains("cannot bypass an active schema-floor stop"));
1329    }
1330
1331    #[test]
1332    fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
1333        let mut client = client();
1334        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1335        const COMMIT_COUNT: usize = 32;
1336
1337        for index in 0..COMMIT_COUNT {
1338            client
1339                .mutate(vec![Mutation::Upsert {
1340                    table: "tasks".to_owned(),
1341                    values: Map::from_iter([
1342                        ("id".to_owned(), Value::from(format!("task-{index}"))),
1343                        ("project_id".to_owned(), Value::from("project-1")),
1344                    ]),
1345                    base_version: None,
1346                }])
1347                .expect("queue commit");
1348        }
1349
1350        let (_, request_meta) = client.build_request(false);
1351        assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
1352        let mut frames = vec![Frame::RespHeader {
1353            required_schema_version: None,
1354            latest_schema_version: None,
1355            log_epoch: Some("epoch-1".to_owned()),
1356            reset_required: Some(false),
1357        }];
1358        frames.extend(request_meta.pushed_ids.iter().enumerate().map(
1359            |(index, client_commit_id)| Frame::PushResult {
1360                client_commit_id: client_commit_id.clone(),
1361                status: PushStatus::Applied,
1362                commit_seq: Some(index as i64 + 1),
1363                results: vec![OpResult::Applied { op_index: 0 }],
1364            },
1365        ));
1366        let response = Message {
1367            wire_version: WIRE_VERSION,
1368            msg_kind: MsgKind::Response,
1369            frames,
1370        };
1371        let mut transport =
1372            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
1373
1374        client.overlay_rebuild_count.set(0);
1375        client.outcome_prune_count.set(0);
1376        let outcome = client.process_response(&mut transport, response, &request_meta);
1377        assert!(matches!(outcome, SyncOutcome::Ok(_)));
1378        assert!(client.pending_commit_ids().is_empty());
1379        assert_eq!(
1380            client.overlay_rebuild_count.get(),
1381            1,
1382            "one response must reconcile its acknowledged commits with one overlay rebuild"
1383        );
1384        assert_eq!(
1385            client.outcome_prune_count.get(),
1386            1,
1387            "one response must enforce outcome retention once"
1388        );
1389    }
1390
1391    #[test]
1392    fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
1393        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
1394            "columns": [
1395                {"name":"id", "type":"string", "nullable":false},
1396                {"name":"project_id", "type":"string", "nullable":false},
1397                {"name":"title", "type":"string", "nullable":false}
1398            ], "scopes": [{"pattern":"project:{project_id}"}],
1399            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1400            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
1401        }] });
1402        let mut incremental =
1403            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1404        let mut reference =
1405            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1406        let tables = vec!["tasks".to_owned()];
1407        for index in 0..32 {
1408            let id = format!("task-{}", index % 8);
1409            let values = Map::from_iter([
1410                ("id".to_owned(), Value::from(id.clone())),
1411                (
1412                    "project_id".to_owned(),
1413                    Value::from(format!("p{}", index % 2)),
1414                ),
1415                ("title".to_owned(), Value::from(format!("needle{index}"))),
1416            ]);
1417            let payload = encode_row_json(
1418                incremental.schema.table("tasks").unwrap(),
1419                &id,
1420                &values,
1421                &incremental.encryption,
1422            )
1423            .unwrap();
1424            let change = ssp2::model::Change {
1425                table_index: 0,
1426                row_id: id,
1427                op: if index % 5 == 0 {
1428                    Op::Delete
1429                } else {
1430                    Op::Upsert
1431                },
1432                row_version: Some(index + 1),
1433                scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
1434                row: Some(payload),
1435            };
1436            reference.overlay_dirty.set(true);
1437            for instance in [&mut incremental, &mut reference] {
1438                instance.begin_observation("mirror_test").unwrap();
1439                let mut batch = ChangeAccumulator::default();
1440                instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
1441                instance
1442                    .apply_commit_changes(&tables, std::slice::from_ref(&change))
1443                    .unwrap();
1444                instance.rebuild_overlay_if_dirty();
1445                instance.finish_observation("mirror_test", batch).unwrap();
1446            }
1447            for sql in [
1448                "SELECT * FROM tasks ORDER BY id",
1449                "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
1450            ] {
1451                assert_eq!(
1452                    incremental.query(sql, &[]).unwrap(),
1453                    reference.query(sql, &[]).unwrap()
1454                );
1455            }
1456            assert_eq!(incremental.local_revision(), reference.local_revision());
1457            assert_eq!(
1458                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1459                serde_json::to_value(reference.drain_change_batches()).unwrap()
1460            );
1461        }
1462        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
1463        assert_eq!(reference.overlay_rebuild_count.get(), 32);
1464    }
1465
1466    #[test]
1467    fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
1468        let cases = [
1469            (
1470                "string",
1471                vec![
1472                    json!("1"),
1473                    json!("01"),
1474                    json!(""),
1475                    json!("nul\u{0}id"),
1476                    json!("é"),
1477                ],
1478            ),
1479            (
1480                "integer",
1481                vec![
1482                    json!(i64::MIN),
1483                    json!(-1),
1484                    json!(0),
1485                    json!(1),
1486                    json!(i64::MAX),
1487                ],
1488            ),
1489            ("boolean", vec![json!(false), json!(true)]),
1490            ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
1491            (
1492                "float",
1493                vec![
1494                    json!(1.0),
1495                    json!(1.0000000000000002),
1496                    json!(-1.0),
1497                    json!(1e20),
1498                ],
1499            ),
1500        ];
1501        for (kind, ids) in cases {
1502            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1503                "columns":[{"name":"id","type":kind,"nullable":false},
1504                    {"name":"project_id","type":"string","nullable":false}],
1505                "scopes":[{"pattern":"project:{project_id}"}]}]});
1506            let mut client =
1507                SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
1508            for (index, id) in ids.iter().enumerate() {
1509                let table = client.schema.table("tasks").unwrap();
1510                client
1511                    .write_base_row(
1512                        "tasks",
1513                        &vec![
1514                            json_to_column_value(&table.columns[0], Some(id)).unwrap(),
1515                            Some(ColumnValue::String(format!("p{index}"))),
1516                        ],
1517                        1,
1518                    )
1519                    .unwrap();
1520            }
1521            client.rebuild_overlay_if_dirty();
1522            for base in [true, false] {
1523                let full = if base {
1524                    base_table("tasks")
1525                } else {
1526                    visible_table("tasks")
1527                };
1528                let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
1529                for row_id in [
1530                    "1",
1531                    "01",
1532                    "1.0",
1533                    "1.0000000000000002",
1534                    "+1",
1535                    "1e0",
1536                    " 1",
1537                    "-1",
1538                    "0",
1539                    "9223372036854775807",
1540                    "-9223372036854775808",
1541                    "true",
1542                    "false",
1543                    "",
1544                    "nul\u{0}id",
1545                    "é",
1546                    "null",
1547                    "{\"a\":1}",
1548                ] {
1549                    let old_sql =
1550                        format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
1551                    let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
1552                    let old = client
1553                        .conn
1554                        .prepare(&old_sql)
1555                        .unwrap()
1556                        .query_map([row_id], |row| row.get::<_, String>(0))
1557                        .unwrap()
1558                        .collect::<Result<Vec<_>, _>>()
1559                        .unwrap();
1560                    let new = client
1561                        .conn
1562                        .prepare_cached(&new_sql)
1563                        .unwrap()
1564                        .query_map([row_id], |row| row.get::<_, String>(0))
1565                        .unwrap()
1566                        .collect::<Result<Vec<_>, _>>()
1567                        .unwrap();
1568                    assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
1569                    // The floating-point text collision is deliberate: an
1570                    // indexed numeric equality must not silently lose a match.
1571                    if kind == "float" && row_id == "1.0" {
1572                        assert_eq!(old.len(), 2);
1573                    }
1574                    let mut batch = ChangeAccumulator::default();
1575                    assert_eq!(
1576                        client.record_row_scopes(&mut batch, "tasks", row_id, base),
1577                        !old.is_empty()
1578                    );
1579                    let expected = old.first().map(|scope| {
1580                        BTreeMap::from([(
1581                            "tasks".to_owned(),
1582                            Some(BTreeSet::from([format!("project:{scope}")])),
1583                        )])
1584                    });
1585                    assert_eq!(batch.tables, expected.unwrap_or_default());
1586                    client
1587                        .conn
1588                        .execute_batch("SAVEPOINT lookup_delete")
1589                        .unwrap();
1590                    if base {
1591                        client.delete_base_row("tasks", row_id).unwrap();
1592                    } else {
1593                        client.apply_outbox_ops(&[OutboxOp {
1594                            upsert: false,
1595                            table: "tasks".into(),
1596                            row_id: row_id.into(),
1597                            base_version: None,
1598                            values: None,
1599                            changed_fields: 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            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
1910            assert_eq!(
1911                client
1912                    .conn
1913                    .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
1914                        .get::<_, String>(0))
1915                    .unwrap(),
1916                "remote"
1917            );
1918        }
1919    }
1920
1921    #[test]
1922    fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
1923        let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
1924            "columns": [
1925                {"name":"id", "type":"string", "nullable":false},
1926                {"name":"project_id", "type":"string", "nullable":false},
1927                {"name":"title", "type":"string", "nullable":false}],
1928            "scopes": [{"pattern":"project:{project_id}"}],
1929            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1930            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
1931        let mut incremental =
1932            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
1933        let mut reference =
1934            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
1935        for instance in [&mut incremental, &mut reference] {
1936            for index in 0..16 {
1937                instance
1938                    .write_base_row(
1939                        "tasks",
1940                        &vec![
1941                            Some(ColumnValue::String(format!("task{index}"))),
1942                            Some(ColumnValue::String("p0".to_owned())),
1943                            Some(ColumnValue::String(format!("base{index}"))),
1944                        ],
1945                        1,
1946                    )
1947                    .unwrap();
1948            }
1949            instance.rebuild_overlay_if_dirty();
1950            instance.overlay_rebuild_count.set(0);
1951        }
1952        for index in 0..100 {
1953            let mutations = (0..1 + index % 4)
1954                .map(|offset| {
1955                    let id = format!("task{}", (index + offset) % 24);
1956                    if (index + offset) % 7 == 0 {
1957                        Mutation::Delete {
1958                            table: "tasks".to_owned(),
1959                            row_id: id,
1960                            base_version: None,
1961                        }
1962                    } else {
1963                        Mutation::Upsert {
1964                            table: "tasks".to_owned(),
1965                            values: Map::from_iter([
1966                                ("id".to_owned(), Value::from(id)),
1967                                (
1968                                    "project_id".to_owned(),
1969                                    Value::from(format!("p{}", index % 2)),
1970                                ),
1971                                (
1972                                    "title".to_owned(),
1973                                    Value::from(format!("needle{}", (index + offset) % 5)),
1974                                ),
1975                            ]),
1976                            base_version: None,
1977                        }
1978                    }
1979                })
1980                .collect::<Vec<_>>();
1981            reference.overlay_dirty.set(true);
1982            incremental.mutate(mutations.clone()).unwrap();
1983            reference.mutate(mutations).unwrap();
1984            for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
1985                "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
1986                assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
1987            }
1988            assert_eq!(incremental.local_revision(), reference.local_revision());
1989            assert_eq!(
1990                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1991                serde_json::to_value(reference.drain_change_batches()).unwrap()
1992            );
1993        }
1994        assert_eq!(incremental.outbox.len(), 100);
1995        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
1996        assert_eq!(reference.overlay_rebuild_count.get(), 100);
1997    }
1998
1999    #[test]
2000    fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
2001        for fail_at in ["outbox", "revision"] {
2002            let mut client = client();
2003            let values = Map::from_iter([
2004                ("id".to_owned(), Value::from("t1")),
2005                ("project_id".to_owned(), Value::from("p1")),
2006            ]);
2007            let mutation = Mutation::Upsert {
2008                table: "tasks".to_owned(),
2009                values,
2010                base_version: None,
2011            };
2012            let trigger = if fail_at == "outbox" {
2013                "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
2014            } else {
2015                format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
2016            };
2017            client.conn.execute_batch(&trigger).unwrap();
2018            let revision = client.local_revision();
2019            assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
2020            assert_eq!(client.local_revision(), revision);
2021            assert!(client.outbox.is_empty());
2022            assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2023            assert_eq!(
2024                client
2025                    .conn
2026                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
2027                        .get::<_, i64>(0))
2028                    .unwrap(),
2029                0
2030            );
2031            assert!(client.drain_change_batches().is_empty());
2032            assert!(!client.overlay_dirty.get());
2033            client
2034                .conn
2035                .execute_batch("DROP TRIGGER fail_append")
2036                .unwrap();
2037            client.mutate(vec![mutation]).unwrap();
2038            assert_eq!(client.outbox.len(), 1);
2039            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
2040        }
2041    }
2042
2043    #[test]
2044    fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
2045        let mut client = client();
2046        let tables = vec!["tasks".to_owned(), "missing".to_owned()];
2047        let values = Map::from_iter([
2048            ("id".to_owned(), Value::from("t1")),
2049            ("project_id".to_owned(), Value::from("p1")),
2050        ]);
2051        let payload = encode_row_json(
2052            client.schema.table("tasks").unwrap(),
2053            "t1",
2054            &values,
2055            &client.encryption,
2056        )
2057        .unwrap();
2058        let first = ssp2::model::Change {
2059            table_index: 0,
2060            row_id: "t1".to_owned(),
2061            op: Op::Upsert,
2062            row_version: Some(1),
2063            scopes: vec![("project_id".to_owned(), "p1".to_owned())],
2064            row: Some(payload),
2065        };
2066        let mut invalid = first.clone();
2067        invalid.table_index = 1;
2068        let revision = client.local_revision();
2069        client.begin_observation("failed_mirror").unwrap();
2070        assert!(client
2071            .apply_commit_changes(&tables, &[first, invalid])
2072            .is_err());
2073        client.rollback_observation("failed_mirror");
2074        client.rebuild_overlay_if_dirty();
2075        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2076        assert_eq!(
2077            client
2078                .conn
2079                .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
2080                    .get::<_, i64>(
2081                    0
2082                ))
2083                .unwrap(),
2084            0
2085        );
2086        assert_eq!(client.local_revision(), revision);
2087        assert!(client.drain_change_batches().is_empty());
2088    }
2089
2090    #[test]
2091    fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
2092        for failure in ["journal", "revision", "commit", "later-frame"] {
2093            let mut client = client();
2094            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2095            for id in ["first", "second"] {
2096                client
2097                    .mutate(vec![Mutation::Upsert {
2098                        table: "tasks".to_owned(),
2099                        values: Map::from_iter([
2100                            ("id".to_owned(), Value::from(id)),
2101                            ("project_id".to_owned(), Value::from("p1")),
2102                        ]),
2103                        base_version: None,
2104                    }])
2105                    .unwrap();
2106            }
2107            let (_, meta) = client.build_request(false);
2108            // A malicious or stale response must not drain an unsent local commit.
2109            client
2110                .mutate(vec![Mutation::Upsert {
2111                    table: "tasks".to_owned(),
2112                    values: Map::from_iter([
2113                        ("id".to_owned(), Value::from("later")),
2114                        ("project_id".to_owned(), Value::from("p1")),
2115                    ]),
2116                    base_version: None,
2117                }])
2118                .unwrap();
2119            let ids = client.pending_commit_ids();
2120            let rows = client
2121                .query("SELECT * FROM tasks ORDER BY id", &[])
2122                .unwrap();
2123            let revision = client.local_revision();
2124            client.drain_change_batches();
2125            if failure != "later-frame" {
2126                client.conn.execute_batch(match failure {
2127                    "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",
2128                    "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",
2129                    _ => "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",
2130                }).unwrap();
2131            }
2132            let mut response = Message {
2133                wire_version: WIRE_VERSION,
2134                msg_kind: MsgKind::Response,
2135                frames: vec![Frame::RespHeader {
2136                    required_schema_version: None,
2137                    latest_schema_version: None,
2138                    log_epoch: Some("epoch-1".to_owned()),
2139                    reset_required: Some(false),
2140                }],
2141            };
2142            for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
2143                response.frames.push(Frame::PushResult {
2144                    client_commit_id: id.to_owned(),
2145                    status: PushStatus::Applied,
2146                    commit_seq: Some(1),
2147                    results: vec![OpResult::Applied { op_index: 0 }],
2148                });
2149            }
2150            if failure == "later-frame" {
2151                response.frames.push(Frame::Error {
2152                    code: "sync.invalid_request".to_owned(),
2153                    message: "later error".to_owned(),
2154                    category: "protocol".to_owned(),
2155                    retryable: false,
2156                    recommended_action: "retry".to_owned(),
2157                    details: None,
2158                });
2159            }
2160            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2161            let outcome = client.process_response(&mut transport, response.clone(), &meta);
2162            assert!(
2163                matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
2164            );
2165            if failure == "later-frame" {
2166                assert_eq!(client.pending_commit_ids(), ids[2..]);
2167                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2168                assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
2169                let batches = client.drain_change_batches();
2170                assert_eq!(batches.len(), 1);
2171                assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
2172                assert_eq!(client.local_revision(), revision + 1);
2173                response.frames.pop();
2174            } else {
2175                assert_eq!(client.pending_commit_ids(), ids);
2176                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2177                assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
2178                assert_eq!(client.local_revision(), revision);
2179                assert!(client.drain_change_batches().is_empty());
2180                assert_eq!(
2181                    client
2182                        .query("SELECT * FROM tasks ORDER BY id", &[])
2183                        .unwrap(),
2184                    rows
2185                );
2186                client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
2187            }
2188            for frame in &mut response.frames {
2189                if let Frame::PushResult { status, .. } = frame {
2190                    *status = PushStatus::Cached;
2191                }
2192            }
2193            let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
2194            else {
2195                panic!("retry failed");
2196            };
2197            assert_eq!(
2198                report.applied,
2199                if failure == "later-frame" {
2200                    vec![]
2201                } else {
2202                    ids[..2].to_vec()
2203                }
2204            );
2205            assert_eq!(client.pending_commit_ids(), ids[2..]);
2206            let batches = client.drain_change_batches();
2207            assert_eq!(batches.len(), usize::from(failure != "later-frame"));
2208            if let Some(batch) = batches.first() {
2209                assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
2210            }
2211            assert_eq!(client.local_revision(), revision + 1);
2212        }
2213    }
2214
2215    #[test]
2216    fn failed_push_result_preserves_memory_disk_and_retry_identity() {
2217        for failure in ["revision", "journal", "commit"] {
2218            for status in ["applied", "cached", "rejected", "conflict"] {
2219                let mut client = client();
2220                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2221                for id in ["first", "later"] {
2222                    client
2223                        .mutate(vec![Mutation::Upsert {
2224                            table: "tasks".to_owned(),
2225                            values: Map::from_iter([
2226                                ("id".to_owned(), Value::from(id)),
2227                                ("project_id".to_owned(), Value::from("p1")),
2228                            ]),
2229                            base_version: None,
2230                        }])
2231                        .unwrap();
2232                }
2233                let (_, meta) = client.build_request(false);
2234                let ids = client.pending_commit_ids();
2235                let rows = client
2236                    .query("SELECT * FROM tasks ORDER BY id", &[])
2237                    .unwrap();
2238                let revision = client.local_revision();
2239                client.drain_change_batches();
2240                let (push_status, results) = match status {
2241                    "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
2242                    "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
2243                    "conflict" => (
2244                        PushStatus::Rejected,
2245                        vec![OpResult::Conflict {
2246                            op_index: 0,
2247                            code: "sync.version_conflict".to_owned(),
2248                            message: "conflict".to_owned(),
2249                            server_version: 1,
2250                            server_row: encode_row_json(
2251                                client.schema.table("tasks").unwrap(),
2252                                "first",
2253                                &Map::from_iter([
2254                                    ("id".to_owned(), Value::from("first")),
2255                                    ("project_id".to_owned(), Value::from("p1")),
2256                                ]),
2257                                &client.encryption,
2258                            )
2259                            .unwrap(),
2260                        }],
2261                    ),
2262                    _ => (
2263                        PushStatus::Rejected,
2264                        vec![OpResult::Error {
2265                            op_index: 0,
2266                            code: "sync.validation_failed".to_owned(),
2267                            message: "rejected".to_owned(),
2268                            retryable: false,
2269                        }],
2270                    ),
2271                };
2272                let response = Message {
2273                    wire_version: WIRE_VERSION,
2274                    msg_kind: MsgKind::Response,
2275                    frames: vec![
2276                        Frame::RespHeader {
2277                            required_schema_version: None,
2278                            latest_schema_version: None,
2279                            log_epoch: Some("epoch-1".to_owned()),
2280                            reset_required: Some(false),
2281                        },
2282                        Frame::PushResult {
2283                            client_commit_id: ids[0].clone(),
2284                            status: push_status,
2285                            commit_seq: Some(1),
2286                            results,
2287                        },
2288                    ],
2289                };
2290                client.conn.execute_batch(match failure {
2291                    "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
2292                    "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
2293                    _ => "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",
2294                }).unwrap();
2295                let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2296                let outcome = client.process_response(&mut transport, response.clone(), &meta);
2297                assert_eq!(
2298                    client.pending_commit_ids(),
2299                    ids,
2300                    "{failure}/{status}: pending identity"
2301                );
2302                assert_eq!(
2303                    client.local_revision(),
2304                    revision,
2305                    "{failure}/{status}: revision"
2306                );
2307                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2308                assert!(client.conflicts().is_empty());
2309                assert!(client.rejections().is_empty());
2310                assert!(client.drain_change_batches().is_empty());
2311                assert_eq!(
2312                    client
2313                        .query("SELECT * FROM tasks ORDER BY id", &[])
2314                        .unwrap(),
2315                    rows
2316                );
2317                assert!(
2318                    matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
2319                    "{failure}/{status}: failed round"
2320                );
2321                client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
2322                assert!(matches!(
2323                    client.process_response(&mut transport, response, &meta),
2324                    SyncOutcome::Ok(_)
2325                ));
2326                assert_eq!(client.pending_commit_ids(), ids[1..]);
2327                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2328                assert_eq!(client.drain_change_batches().len(), 1);
2329            }
2330        }
2331    }
2332
2333    #[test]
2334    fn mixed_push_results_reconcile_and_prune_once_per_response() {
2335        let mut client = client();
2336        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2337        for index in 0..4 {
2338            client
2339                .mutate(vec![Mutation::Upsert {
2340                    table: "tasks".to_owned(),
2341                    values: Map::from_iter([
2342                        ("id".to_owned(), Value::from(format!("task-{index}"))),
2343                        ("project_id".to_owned(), Value::from("project-1")),
2344                    ]),
2345                    base_version: None,
2346                }])
2347                .expect("queue commit");
2348        }
2349
2350        let (_, request_meta) = client.build_request(false);
2351        let ids = &request_meta.pushed_ids;
2352        assert_eq!(ids.len(), 4);
2353        let response = Message {
2354            wire_version: WIRE_VERSION,
2355            msg_kind: MsgKind::Response,
2356            frames: vec![
2357                Frame::RespHeader {
2358                    required_schema_version: None,
2359                    latest_schema_version: None,
2360                    log_epoch: Some("epoch-1".to_owned()),
2361                    reset_required: Some(false),
2362                },
2363                Frame::PushResult {
2364                    client_commit_id: ids[0].clone(),
2365                    status: PushStatus::Applied,
2366                    commit_seq: Some(1),
2367                    results: vec![OpResult::Applied { op_index: 0 }],
2368                },
2369                Frame::PushResult {
2370                    client_commit_id: ids[1].clone(),
2371                    status: PushStatus::Cached,
2372                    commit_seq: Some(2),
2373                    results: vec![OpResult::Applied { op_index: 0 }],
2374                },
2375                Frame::PushResult {
2376                    client_commit_id: ids[2].clone(),
2377                    status: PushStatus::Rejected,
2378                    commit_seq: None,
2379                    results: vec![OpResult::Error {
2380                        op_index: 0,
2381                        code: "sync.validation_failed".to_owned(),
2382                        message: "rejected".to_owned(),
2383                        retryable: false,
2384                    }],
2385                },
2386                Frame::PushResult {
2387                    client_commit_id: ids[3].clone(),
2388                    status: PushStatus::Rejected,
2389                    commit_seq: None,
2390                    results: vec![OpResult::Error {
2391                        op_index: 0,
2392                        code: "sync.idempotency_cache_miss".to_owned(),
2393                        message: "retry".to_owned(),
2394                        retryable: true,
2395                    }],
2396                },
2397            ],
2398        };
2399        let mut transport =
2400            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
2401
2402        client.overlay_rebuild_count.set(0);
2403        client.outcome_prune_count.set(0);
2404        let outcome = client.process_response(&mut transport, response, &request_meta);
2405        let SyncOutcome::Ok(report) = outcome else {
2406            panic!("mixed push-result response failed");
2407        };
2408
2409        assert_eq!(report.applied, ids[..2]);
2410        assert_eq!(report.rejected, ids[2..3]);
2411        assert_eq!(report.retryable, ids[3..4]);
2412        assert_eq!(client.pending_commit_ids(), ids[3..4]);
2413        assert_eq!(
2414            client
2415                .query("SELECT id FROM tasks ORDER BY id", &[])
2416                .expect("query visible overlay"),
2417            vec![Map::from_iter([("id".to_owned(), Value::from("task-3"))])]
2418        );
2419        assert_eq!(client.overlay_rebuild_count.get(), 1);
2420        assert_eq!(client.outcome_prune_count.get(), 1);
2421    }
2422
2423    #[test]
2424    fn secondary_unique_collision_preserves_existing_synced_row() {
2425        let client = SyncClient::new(
2426            "unique-upsert-test".to_owned(),
2427            &json!({
2428                "version": 1,
2429                "tables": [{
2430                    "name": "tasks",
2431                    "primaryKey": "id",
2432                    "columns": [
2433                        { "name": "id", "type": "string", "nullable": false },
2434                        { "name": "project_id", "type": "string", "nullable": false },
2435                        { "name": "title", "type": "string", "nullable": false }
2436                    ],
2437                    "scopes": [{ "pattern": "project:{project_id}" }],
2438                    "indexes": [{
2439                        "name": "tasks_by_project_title",
2440                        "columns": ["project_id", "title"],
2441                        "unique": true
2442                    }]
2443                }]
2444            }),
2445            ClientLimits::default(),
2446        )
2447        .expect("test client");
2448        let table = client.schema.table("tasks").expect("tasks table");
2449        let sql = client.insert_row_sql(&base_table("tasks"), table);
2450
2451        client
2452            .conn
2453            .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
2454            .expect("insert first row");
2455        client
2456            .conn
2457            .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
2458            .expect("update same primary key");
2459        client
2460            .conn
2461            .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
2462            .expect("insert second row");
2463        assert!(client
2464            .conn
2465            .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
2466            .is_err());
2467
2468        let rows = client
2469            .conn
2470            .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
2471            .expect("prepare rows")
2472            .query_map([], |row| {
2473                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2474            })
2475            .expect("query rows")
2476            .collect::<Result<Vec<_>, _>>()
2477            .expect("collect rows");
2478        assert_eq!(
2479            rows,
2480            vec![
2481                ("t1".to_owned(), "updated".to_owned()),
2482                ("t2".to_owned(), "original".to_owned())
2483            ]
2484        );
2485    }
2486
2487    #[test]
2488    fn reopening_active_subscriptions_emits_a_catch_up_intent() {
2489        let path = std::env::temp_dir().join(format!(
2490            "syncular-startup-intent-{}.db",
2491            uuid::Uuid::new_v4()
2492        ));
2493        let schema = json!({
2494            "version": 1,
2495            "tables": [{
2496                "name": "tasks",
2497                "primaryKey": "id",
2498                "columns": [
2499                    { "name": "id", "type": "string", "nullable": false },
2500                    { "name": "project_id", "type": "string", "nullable": false }
2501                ],
2502                "scopes": [{ "pattern": "project:{project_id}" }]
2503            }]
2504        });
2505
2506        {
2507            let mut first = SyncClient::open_path_with_identity(
2508                None,
2509                &schema,
2510                ClientLimits::default(),
2511                path.to_str().expect("UTF-8 temp path"),
2512            )
2513            .expect("first open");
2514            first
2515                .set_window(
2516                    &WindowBase {
2517                        table: "tasks".to_owned(),
2518                        variable: "project_id".to_owned(),
2519                        fixed_scopes: Vec::new(),
2520                        params: None,
2521                    },
2522                    &["persisted".to_owned()],
2523                )
2524                .expect("persist window");
2525        }
2526
2527        let mut reopened = SyncClient::open_path_with_identity(
2528            None,
2529            &schema,
2530            ClientLimits::default(),
2531            path.to_str().expect("UTF-8 temp path"),
2532        )
2533        .expect("reopen");
2534        assert!(reopened.sync_needed());
2535        assert!(matches!(
2536            reopened.drain_sync_intents().as_slice(),
2537            [SyncIntent::Interactive]
2538        ));
2539        drop(reopened);
2540        std::fs::remove_file(path).expect("remove temp database");
2541    }
2542
2543    #[test]
2544    fn reopening_preserves_immutable_subscription_identity_and_progress() {
2545        let path = std::env::temp_dir().join(format!(
2546            "syncular-subscription-identity-{}.db",
2547            uuid::Uuid::new_v4()
2548        ));
2549        let schema = json!({
2550            "version": 1,
2551            "tables": [
2552                {
2553                    "name": "tasks",
2554                    "primaryKey": "id",
2555                    "columns": [
2556                        { "name": "id", "type": "string", "nullable": false },
2557                        { "name": "project_id", "type": "string", "nullable": false }
2558                    ],
2559                    "scopes": [{ "pattern": "project:{project_id}" }]
2560                },
2561                {
2562                    "name": "docs",
2563                    "primaryKey": "id",
2564                    "columns": [
2565                        { "name": "id", "type": "string", "nullable": false },
2566                        { "name": "org_id", "type": "string", "nullable": false },
2567                        { "name": "project_id", "type": "string", "nullable": false }
2568                    ],
2569                    "scopes": [
2570                        { "pattern": "org:{org_id}" },
2571                        { "pattern": "project:{projectId}", "column": "project_id" }
2572                    ]
2573                }
2574            ]
2575        });
2576
2577        {
2578            let mut first = SyncClient::open_path_with_identity(
2579                None,
2580                &schema,
2581                ClientLimits::default(),
2582                path.to_str().expect("UTF-8 temp path"),
2583            )
2584            .expect("first open");
2585            first
2586                .subscribe(
2587                    "stable-subscription".to_owned(),
2588                    "tasks".to_owned(),
2589                    vec![(
2590                        "project_id".to_owned(),
2591                        vec!["p2".to_owned(), "p1".to_owned()],
2592                    )],
2593                    Some(r#"{"view":"v1"}"#.to_owned()),
2594                )
2595                .expect("persist subscription");
2596            let persisted = {
2597                let subscription = first
2598                    .subs
2599                    .iter_mut()
2600                    .find(|subscription| subscription.id == "stable-subscription")
2601                    .expect("subscription");
2602                subscription.cursor = 41;
2603                subscription.bootstrap_state = Some("resume-token".to_owned());
2604                subscription.effective = Some(vec![(
2605                    "project_id".to_owned(),
2606                    vec!["p1".to_owned(), "p2".to_owned()],
2607                )]);
2608                subscription.synced_once = true;
2609                subscription.clone()
2610            };
2611            first.persist_sub(&persisted).unwrap();
2612        }
2613
2614        let mut reopened = SyncClient::open_path_with_identity(
2615            None,
2616            &schema,
2617            ClientLimits::default(),
2618            path.to_str().expect("UTF-8 temp path"),
2619        )
2620        .expect("reopen");
2621        let progress = reopened
2622            .subscription_state("stable-subscription")
2623            .expect("persisted state");
2624        assert_eq!(progress.cursor, 41);
2625        assert!(progress.has_resume_token);
2626
2627        reopened
2628            .subscribe(
2629                "stable-subscription".to_owned(),
2630                "tasks".to_owned(),
2631                vec![(
2632                    "project_id".to_owned(),
2633                    vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
2634                )],
2635                Some(r#"{"view":"v1"}"#.to_owned()),
2636            )
2637            .expect("canonical intent is idempotent");
2638        assert_eq!(
2639            reopened
2640                .subscription_state("stable-subscription")
2641                .expect("unchanged state")
2642                .cursor,
2643            progress.cursor
2644        );
2645
2646        for (table, scopes, params) in [
2647            (
2648                "tasks",
2649                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2650                Some(r#"{"view":"v1"}"#.to_owned()),
2651            ),
2652            (
2653                "tasks",
2654                vec![(
2655                    "project_id".to_owned(),
2656                    vec!["p2".to_owned(), "p1".to_owned()],
2657                )],
2658                Some(r#"{"view":"v2"}"#.to_owned()),
2659            ),
2660            (
2661                "docs",
2662                vec![
2663                    ("org_id".to_owned(), vec!["o1".to_owned()]),
2664                    ("projectId".to_owned(), vec!["p1".to_owned()]),
2665                ],
2666                Some(r#"{"view":"v1"}"#.to_owned()),
2667            ),
2668        ] {
2669            let error = reopened
2670                .subscribe(
2671                    "stable-subscription".to_owned(),
2672                    table.to_owned(),
2673                    scopes,
2674                    params,
2675                )
2676                .expect_err("identity rebind must fail");
2677            assert!(error.starts_with("client.subscription_intent_mismatch:"));
2678            assert_eq!(
2679                reopened
2680                    .subscription_state("stable-subscription")
2681                    .expect("unchanged state")
2682                    .cursor,
2683                progress.cursor
2684            );
2685        }
2686
2687        drop(reopened);
2688        std::fs::remove_file(path).expect("remove temp database");
2689    }
2690
2691    #[test]
2692    fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
2693        let path = std::env::temp_dir().join(format!(
2694            "syncular-satisfied-schema-floor-{}.db",
2695            uuid::Uuid::new_v4()
2696        ));
2697        let schema = json!({
2698            "version": 23,
2699            "tables": [{
2700                "name": "tasks",
2701                "primaryKey": "id",
2702                "columns": [
2703                    { "name": "id", "type": "string", "nullable": false },
2704                    { "name": "project_id", "type": "string", "nullable": false }
2705                ],
2706                "scopes": [{ "pattern": "project:{project_id}" }]
2707            }]
2708        });
2709
2710        {
2711            let mut first = SyncClient::open_path_with_identity(
2712                None,
2713                &schema,
2714                ClientLimits::default(),
2715                path.to_str().expect("UTF-8 temp path"),
2716            )
2717            .expect("first open");
2718            first
2719                .subscribe(
2720                    "tasks".to_owned(),
2721                    "tasks".to_owned(),
2722                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2723                    None,
2724                )
2725                .expect("persist subscription");
2726            first.set_schema_floor(Some(SchemaFloor {
2727                required_schema_version: Some(22),
2728                latest_schema_version: Some(22),
2729            }));
2730        }
2731
2732        let mut reopened = SyncClient::open_path_with_identity(
2733            None,
2734            &schema,
2735            ClientLimits::default(),
2736            path.to_str().expect("UTF-8 temp path"),
2737        )
2738        .expect("reopen");
2739        assert!(reopened.schema_floor().is_none());
2740        assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
2741        assert!(reopened.sync_needed());
2742        assert!(matches!(
2743            reopened.drain_sync_intents().as_slice(),
2744            [SyncIntent::Interactive]
2745        ));
2746        drop(reopened);
2747        std::fs::remove_file(path).expect("remove temp database");
2748    }
2749
2750    #[test]
2751    fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
2752        let path = std::env::temp_dir().join(format!(
2753            "syncular-unsatisfied-schema-floor-{}.db",
2754            uuid::Uuid::new_v4()
2755        ));
2756        let schema = json!({
2757            "version": 1,
2758            "tables": [{
2759                "name": "tasks",
2760                "primaryKey": "id",
2761                "columns": [
2762                    { "name": "id", "type": "string", "nullable": false },
2763                    { "name": "project_id", "type": "string", "nullable": false }
2764                ],
2765                "scopes": [{ "pattern": "project:{project_id}" }]
2766            }]
2767        });
2768
2769        {
2770            let mut first = SyncClient::open_path_with_identity(
2771                None,
2772                &schema,
2773                ClientLimits::default(),
2774                path.to_str().expect("UTF-8 temp path"),
2775            )
2776            .expect("first open");
2777            first
2778                .subscribe(
2779                    "tasks".to_owned(),
2780                    "tasks".to_owned(),
2781                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2782                    None,
2783                )
2784                .expect("persist subscription");
2785            first.set_schema_floor(Some(SchemaFloor {
2786                required_schema_version: Some(2),
2787                latest_schema_version: Some(2),
2788            }));
2789        }
2790
2791        let reopened = SyncClient::open_path_with_identity(
2792            None,
2793            &schema,
2794            ClientLimits::default(),
2795            path.to_str().expect("UTF-8 temp path"),
2796        )
2797        .expect("reopen");
2798        assert_eq!(
2799            reopened.schema_floor(),
2800            Some(&SchemaFloor {
2801                required_schema_version: Some(2),
2802                latest_schema_version: Some(2),
2803            })
2804        );
2805        assert!(!reopened.sync_needed());
2806        drop(reopened);
2807        std::fs::remove_file(path).expect("remove temp database");
2808    }
2809
2810    #[test]
2811    fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
2812        let path = std::env::temp_dir().join(format!(
2813            "syncular-indexed-column-bump-{}.db",
2814            uuid::Uuid::new_v4()
2815        ));
2816        let old_schema = json!({
2817            "version": 1,
2818            "tables": [
2819                {
2820                    "name": "tasks",
2821                    "primaryKey": "id",
2822                    "columns": [
2823                        { "name": "id", "type": "string", "nullable": false },
2824                        { "name": "project_id", "type": "string", "nullable": false }
2825                    ],
2826                    "scopes": [{ "pattern": "project:{project_id}" }]
2827                },
2828                {
2829                    "name": "legacy",
2830                    "primaryKey": "id",
2831                    "columns": [
2832                        { "name": "id", "type": "string", "nullable": false },
2833                        { "name": "project_id", "type": "string", "nullable": false }
2834                    ],
2835                    "scopes": [{ "pattern": "project:{project_id}" }]
2836                }
2837            ]
2838        });
2839        {
2840            let mut first = SyncClient::open_path_with_identity(
2841                None,
2842                &old_schema,
2843                ClientLimits::default(),
2844                path.to_str().expect("UTF-8 temp path"),
2845            )
2846            .expect("open old schema");
2847            first
2848                .subscribe(
2849                    "legacy-sub".to_owned(),
2850                    "legacy".to_owned(),
2851                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2852                    None,
2853                )
2854                .expect("persist legacy subscription");
2855        }
2856
2857        let new_schema = json!({
2858            "version": 2,
2859            "tables": [{
2860                "name": "tasks",
2861                "primaryKey": "id",
2862                "columns": [
2863                    { "name": "id", "type": "string", "nullable": false },
2864                    { "name": "project_id", "type": "string", "nullable": false },
2865                    { "name": "facility_membership_id", "type": "string", "nullable": true }
2866                ],
2867                "scopes": [{ "pattern": "project:{project_id}" }],
2868                "indexes": [{
2869                    "name": "tasks_by_membership",
2870                    "columns": ["project_id", "facility_membership_id"],
2871                    "unique": false
2872                }]
2873            }]
2874        });
2875        let upgraded = SyncClient::open_path_with_identity(
2876            None,
2877            &new_schema,
2878            ClientLimits::default(),
2879            path.to_str().expect("UTF-8 temp path"),
2880        )
2881        .expect("open upgraded schema");
2882        let columns = upgraded
2883            .conn
2884            .prepare("PRAGMA table_info(tasks)")
2885            .expect("prepare columns")
2886            .query_map([], |row| row.get::<_, String>(1))
2887            .expect("query columns")
2888            .collect::<Result<Vec<_>, _>>()
2889            .expect("collect columns");
2890        assert!(columns.contains(&"facility_membership_id".to_owned()));
2891        let index_count: i64 = upgraded
2892            .conn
2893            .query_row(
2894                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
2895                [],
2896                |row| row.get(0),
2897            )
2898            .expect("query index");
2899        assert_eq!(index_count, 1);
2900        assert!(upgraded.subscription_state("legacy-sub").is_none());
2901        drop(upgraded);
2902        std::fs::remove_file(path).expect("remove temp database");
2903    }
2904
2905    #[test]
2906    fn migrates_pre_envelope_outcome_journal_additively() {
2907        let path = std::env::temp_dir().join(format!(
2908            "syncular-outcome-migration-{}.db",
2909            uuid::Uuid::new_v4()
2910        ));
2911        let conn = Connection::open(&path).expect("open legacy database");
2912        conn.execute_batch(
2913            "CREATE TABLE _syncular_commit_outcomes (
2914               seq INTEGER PRIMARY KEY AUTOINCREMENT,
2915               client_commit_id TEXT NOT NULL UNIQUE,
2916               status TEXT NOT NULL,
2917               recorded_at_ms INTEGER NOT NULL,
2918               results_json TEXT NOT NULL,
2919               resolution TEXT NOT NULL DEFAULT 'active',
2920               resolved_at_ms INTEGER,
2921               replacement_client_commit_id TEXT);",
2922        )
2923        .expect("create legacy outcome journal");
2924        drop(conn);
2925        let schema = json!({
2926            "version": 1,
2927            "tables": [{
2928                "name": "tasks",
2929                "primaryKey": "id",
2930                "columns": [
2931                    { "name": "id", "type": "string", "nullable": false },
2932                    { "name": "project_id", "type": "string", "nullable": false }
2933                ],
2934                "scopes": [{ "pattern": "project:{project_id}" }]
2935            }]
2936        });
2937        let client = SyncClient::open_path(
2938            "migration-native".to_owned(),
2939            &schema,
2940            ClientLimits::default(),
2941            path.to_str().expect("UTF-8 temp path"),
2942        )
2943        .expect("migrate database");
2944        let has_operations = client
2945            .conn
2946            .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
2947            .expect("prepare table info")
2948            .query_map([], |row| row.get::<_, String>(1))
2949            .expect("query table info")
2950            .filter_map(Result::ok)
2951            .any(|column| column == "operations_json");
2952        assert!(has_operations);
2953        drop(client);
2954        std::fs::remove_file(path).expect("remove temp database");
2955    }
2956
2957    #[test]
2958    fn durable_conflict_outcome_and_resolution_survive_reopen() {
2959        let path = std::env::temp_dir().join(format!(
2960            "syncular-durable-outcome-{}.db",
2961            uuid::Uuid::new_v4()
2962        ));
2963        let schema = json!({
2964            "version": 1,
2965            "tables": [{
2966                "name": "tasks",
2967                "primaryKey": "id",
2968                "columns": [
2969                    { "name": "id", "type": "string", "nullable": false },
2970                    { "name": "project_id", "type": "string", "nullable": false }
2971                ],
2972                "scopes": [{ "pattern": "project:{project_id}" }]
2973            }]
2974        });
2975        let conflict = ConflictRecord {
2976            client_commit_id: "losing-commit".to_owned(),
2977            op_index: 0,
2978            table: "tasks".to_owned(),
2979            row_id: "t1".to_owned(),
2980            code: "sync.version_conflict".to_owned(),
2981            message: "stale base version".to_owned(),
2982            server_version: 2,
2983            server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
2984            operation: Some(CommitOperation {
2985                table: "tasks".to_owned(),
2986                row_id: "t1".to_owned(),
2987                op: "upsert".to_owned(),
2988                base_version: Some(1),
2989                values: None,
2990                changed_fields: None,
2991            }),
2992        };
2993        let failed_operations = vec![
2994            OutboxOp {
2995                upsert: true,
2996                table: "tasks".to_owned(),
2997                row_id: "t1".to_owned(),
2998                base_version: Some(1),
2999                values: None,
3000                changed_fields: None,
3001            },
3002            OutboxOp {
3003                upsert: true,
3004                table: "tasks".to_owned(),
3005                row_id: "status-event-1".to_owned(),
3006                base_version: Some(0),
3007                values: None,
3008                changed_fields: None,
3009            },
3010        ];
3011
3012        {
3013            let mut first = SyncClient::open_path(
3014                "durable-native".to_owned(),
3015                &schema,
3016                ClientLimits::default(),
3017                path.to_str().expect("UTF-8 temp path"),
3018            )
3019            .expect("first open");
3020            first
3021                .begin_observation("test_outcome")
3022                .expect("begin outcome");
3023            first
3024                .persist_commit_outcome(
3025                    "losing-commit",
3026                    CommitOutcomeStatus::Conflict,
3027                    &[CommitOperationOutcome::Conflict {
3028                        conflict: conflict.clone(),
3029                    }],
3030                    Some(&failed_operations),
3031                )
3032                .expect("persist outcome");
3033            first.conflicts.push(conflict);
3034            first
3035                .finish_observation(
3036                    "test_outcome",
3037                    ChangeAccumulator {
3038                        conflicts: true,
3039                        outcomes: true,
3040                        ..ChangeAccumulator::default()
3041                    },
3042                )
3043                .expect("commit outcome");
3044        }
3045
3046        {
3047            let mut reopened = SyncClient::open_path(
3048                "durable-native".to_owned(),
3049                &schema,
3050                ClientLimits::default(),
3051                path.to_str().expect("UTF-8 temp path"),
3052            )
3053            .expect("reopen");
3054            assert_eq!(reopened.conflicts().len(), 1);
3055            let outcome = reopened
3056                .commit_outcome("losing-commit")
3057                .expect("read outcome")
3058                .expect("outcome");
3059            assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
3060            let operations = outcome.operations.expect("aggregate envelope");
3061            assert_eq!(operations.len(), 2);
3062            assert_eq!(operations[1].row_id, "status-event-1");
3063            let resolved = reopened
3064                .resolve_commit_outcome(ResolveCommitOutcomeInput {
3065                    client_commit_id: "losing-commit".to_owned(),
3066                    resolution: CommitOutcomeResolution::ResolvedKeepServer,
3067                    replacement_client_commit_id: None,
3068                })
3069                .expect("resolve");
3070            assert_eq!(
3071                resolved.resolution,
3072                CommitOutcomeResolution::ResolvedKeepServer
3073            );
3074            assert!(reopened.conflicts().is_empty());
3075        }
3076
3077        let reopened = SyncClient::open_path(
3078            "durable-native".to_owned(),
3079            &schema,
3080            ClientLimits::default(),
3081            path.to_str().expect("UTF-8 temp path"),
3082        )
3083        .expect("second reopen");
3084        assert!(reopened.conflicts().is_empty());
3085        assert_eq!(
3086            reopened
3087                .commit_outcome("losing-commit")
3088                .expect("read outcome")
3089                .expect("outcome")
3090                .resolution,
3091            CommitOutcomeResolution::ResolvedKeepServer
3092        );
3093        drop(reopened);
3094        std::fs::remove_file(path).expect("remove temp database");
3095    }
3096
3097    #[test]
3098    fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
3099        let path =
3100            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
3101        let schema = json!({
3102            "version": 1,
3103            "tables": [{
3104                "name": "tasks",
3105                "primaryKey": "id",
3106                "columns": [
3107                    { "name": "id", "type": "string", "nullable": false },
3108                    { "name": "project_id", "type": "string", "nullable": false }
3109                ],
3110                "scopes": [{ "pattern": "project:{project_id}" }]
3111            }]
3112        });
3113        let mut client = SyncClient::open_path_with_identity(
3114            Some("sidecar-client".to_owned()),
3115            &schema,
3116            ClientLimits::default(),
3117            path.to_str().expect("UTF-8 temp path"),
3118        )
3119        .expect("open owner");
3120        let base = WindowBase {
3121            table: "tasks".to_owned(),
3122            variable: "project_id".to_owned(),
3123            fixed_scopes: Vec::new(),
3124            params: None,
3125        };
3126        client
3127            .set_window(&base, &["one".to_owned()])
3128            .expect("set window");
3129        client
3130            .mutate(vec![Mutation::Upsert {
3131                table: "tasks".to_owned(),
3132                values: Map::from_iter([
3133                    ("id".to_owned(), Value::from("t1")),
3134                    ("project_id".to_owned(), Value::from("one")),
3135                ]),
3136                base_version: None,
3137            }])
3138            .expect("local mutate");
3139
3140        let coverage = [WindowCoverage {
3141            base,
3142            units: vec!["one".to_owned(), "missing".to_owned()],
3143        }];
3144        let owner = client
3145            .query_snapshot(
3146                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3147                &[],
3148                &coverage,
3149            )
3150            .expect("owner snapshot");
3151        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
3152        let sidecar = reader
3153            .query_snapshot(
3154                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3155                &[],
3156                &coverage,
3157            )
3158            .expect("sidecar snapshot");
3159
3160        assert_eq!(sidecar.revision, owner.revision);
3161        assert_eq!(sidecar.rows, owner.rows);
3162        assert_eq!(
3163            serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
3164            serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
3165        );
3166        assert_eq!(sidecar.revision, "2");
3167        assert_eq!(sidecar.rows[0]["id"], "t1");
3168        assert_eq!(sidecar.rows[0]["server_version"], -1);
3169        assert!(!sidecar.coverage.complete);
3170        assert_eq!(sidecar.coverage.pending.len(), 1);
3171        assert_eq!(sidecar.coverage.missing.len(), 1);
3172
3173        drop(reader);
3174        drop(client);
3175        std::fs::remove_file(path).expect("remove temp database");
3176    }
3177
3178    #[test]
3179    fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
3180        let mut client = client();
3181        let image = Connection::open_in_memory().unwrap();
3182        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
3183            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3184            INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
3185            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3186            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
3187        let table = client.schema.table("tasks").unwrap().clone();
3188        let scopes = vec![("project_id".into(), vec!["p1".into()])];
3189        assert!(client
3190            .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
3191            .is_err());
3192        assert!(client.conn.is_autocommit());
3193        assert_eq!(
3194            client
3195                .query("SELECT count(*) AS n FROM tasks", &[])
3196                .unwrap()[0]["n"],
3197            1024
3198        );
3199        assert_eq!(client.drain_change_batches().len(), 1);
3200        image
3201            .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
3202            .unwrap();
3203        assert!(matches!(
3204            client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
3205            Ok(2050)
3206        ));
3207        assert_eq!(
3208            client
3209                .query("SELECT count(*) AS n FROM tasks", &[])
3210                .unwrap()[0]["n"],
3211            2050
3212        );
3213        assert_eq!(client.drain_change_batches().len(), 3);
3214    }
3215
3216    #[test]
3217    fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
3218        let mut client = client();
3219        let base = WindowBase {
3220            table: "tasks".into(),
3221            variable: "project_id".into(),
3222            fixed_scopes: Vec::new(),
3223            params: None,
3224        };
3225        client
3226            .set_window(&base, &["p1".into(), "p2".into()])
3227            .unwrap();
3228        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3229            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3230            INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
3231            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3232            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3233        client.drain_change_batches();
3234        assert!(client.set_window(&base, &["p2".into()]).is_err());
3235        assert!(client.conn.is_autocommit());
3236        assert_eq!(
3237            client
3238                .query("SELECT count(*) AS n FROM tasks", &[])
3239                .unwrap()[0]["n"],
3240            1027
3241        );
3242        assert_eq!(client.window_state(&base).units, vec!["p2"]);
3243        assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
3244        assert_eq!(client.drain_change_batches().len(), 1);
3245        client
3246            .conn
3247            .execute_batch("DROP TRIGGER interrupt_evict")
3248            .unwrap();
3249        client.drain_pending_evictions().unwrap();
3250        assert!(client.load_pending_evictions().unwrap().is_empty());
3251        assert_eq!(
3252            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3253            "held"
3254        );
3255        assert_eq!(client.drain_change_batches().len(), 2);
3256    }
3257
3258    #[test]
3259    fn eviction_marker_failures_roll_back_window_changes() {
3260        let mut client = client();
3261        let base = WindowBase {
3262            table: "tasks".into(),
3263            variable: "project_id".into(),
3264            fixed_scopes: Vec::new(),
3265            params: None,
3266        };
3267        client.set_window(&base, &["p1".into()]).unwrap();
3268        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
3269            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
3270            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
3271            CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3272        let revision = client.local_revision();
3273        assert!(client.set_window(&base, &[]).is_err());
3274        assert!(client.conn.is_autocommit());
3275        assert_eq!(client.local_revision(), revision);
3276        assert_eq!(
3277            client
3278                .query("SELECT count(*) AS n FROM tasks", &[])
3279                .unwrap()[0]["n"],
3280            2050
3281        );
3282        assert_eq!(client.window_state(&base).units, vec!["p1"]);
3283        client.conn.execute_batch("DROP TRIGGER fail_marker;
3284            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
3285        assert!(client.set_window(&base, &[]).is_err());
3286        client.conn.execute_batch("DROP TRIGGER interrupt_evict;
3287            CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
3288        assert!(client.drain_pending_evictions().is_err());
3289        assert!(client.conn.is_autocommit());
3290        assert_eq!(
3291            client
3292                .query("SELECT count(*) AS n FROM tasks", &[])
3293                .unwrap()[0]["n"],
3294            2
3295        );
3296        assert!(client.set_window(&base, &["p1".into()]).is_err());
3297        assert!(client.conn.is_autocommit());
3298        assert!(client.window_state(&base).units.is_empty());
3299        assert!(client.subs.is_empty());
3300        assert_eq!(
3301            client
3302                .query(
3303                    "SELECT count(*) AS n FROM _syncular_window_pending_evict",
3304                    &[]
3305                )
3306                .unwrap()[0]["n"],
3307            1
3308        );
3309        client
3310            .conn
3311            .execute_batch("DROP TRIGGER fail_marker")
3312            .unwrap();
3313        client.set_window(&base, &["p1".into()]).unwrap();
3314        assert_eq!(client.window_state(&base).units, vec!["p1"]);
3315    }
3316
3317    #[test]
3318    fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
3319        let mut client = client();
3320        client
3321            .conn
3322            .execute(
3323                "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
3324                [],
3325            )
3326            .unwrap();
3327        assert_eq!(
3328            client.drain_pending_evictions().unwrap_err(),
3329            "sync.local_corrupt: invalid pending eviction scopes"
3330        );
3331        assert!(client.conn.is_autocommit());
3332        assert!(client.enqueue_startup_sync_if_needed().is_err());
3333    }
3334
3335    #[test]
3336    fn image_chunks_reject_null_primary_keys() {
3337        let mut client = client();
3338        let image = Connection::open_in_memory().unwrap();
3339        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3340            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
3341            INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
3342            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3343            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
3344        let table = client.schema.table("tasks").unwrap().clone();
3345        assert!(client
3346            .apply_sqlite_image(
3347                &image,
3348                &table,
3349                true,
3350                &[("project_id".into(), vec!["p1".into()])],
3351                1025,
3352                7,
3353                "digest"
3354            )
3355            .is_err());
3356        assert!(client.conn.is_autocommit());
3357        assert_eq!(
3358            client
3359                .query("SELECT count(*) AS n FROM tasks", &[])
3360                .unwrap()[0]["n"],
3361            0
3362        );
3363    }
3364
3365    #[test]
3366    fn import_chunks_with_unique_constraints_match_full_pending_replay() {
3367        for images in [false, true] {
3368            let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
3369                "name":name,"primaryKey":"id","columns":[
3370                    {"name":"id","type":"string","nullable":false},
3371                    {"name":"project_id","type":"string","nullable":false},
3372                    {"name":"title","type":"string","nullable":false}],
3373                "scopes":[{"pattern":"project:{project_id}"}],
3374                "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
3375                "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
3376            })))});
3377            let mut actual =
3378                SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
3379            let mut reference =
3380                SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
3381            let scopes = vec![("project_id".into(), vec!["p1".into()])];
3382            for client in [&mut actual, &mut reference] {
3383                client
3384                    .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
3385                    .unwrap();
3386                client.subs[0].effective = Some(scopes.clone());
3387                for (table, id, title) in [
3388                    ("tasks", "a", "original"),
3389                    ("tasks", "b", "occupied"),
3390                    ("tasks", "c", "deleted"),
3391                    ("notes", "n", "untouched"),
3392                ] {
3393                    client
3394                        .write_base_row(
3395                            table,
3396                            &vec![
3397                                Some(ColumnValue::String(id.into())),
3398                                Some(ColumnValue::String("p1".into())),
3399                                Some(ColumnValue::String(title.into())),
3400                            ],
3401                            1,
3402                        )
3403                        .unwrap();
3404                }
3405                client.rebuild_overlay_if_dirty();
3406                client
3407                    .mutate(vec![
3408                        Mutation::Upsert {
3409                            table: "tasks".into(),
3410                            values: Map::from_iter([
3411                                ("id".into(), Value::from("a")),
3412                                ("project_id".into(), Value::from("p1")),
3413                                ("title".into(), Value::from("occupied")),
3414                            ]),
3415                            base_version: None,
3416                        },
3417                        Mutation::Delete {
3418                            table: "tasks".into(),
3419                            row_id: "c".into(),
3420                            base_version: None,
3421                        },
3422                        Mutation::Upsert {
3423                            table: "tasks".into(),
3424                            values: Map::from_iter([
3425                                ("id".into(), Value::from("local")),
3426                                ("project_id".into(), Value::from("p1")),
3427                                ("title".into(), Value::from("local value")),
3428                            ]),
3429                            base_version: None,
3430                        },
3431                        Mutation::Upsert {
3432                            table: "notes".into(),
3433                            values: Map::from_iter([
3434                                ("id".into(), Value::from("n")),
3435                                ("project_id".into(), Value::from("p1")),
3436                                ("title".into(), Value::from("pending note")),
3437                            ]),
3438                            base_version: None,
3439                        },
3440                    ])
3441                    .unwrap();
3442            }
3443            let pending = actual.pending_commit_ids();
3444            actual.overlay_rebuild_count.set(0);
3445            for (id, title) in [
3446                ("b", "available"),
3447                ("b", "occupied"),
3448                ("a", "server original"),
3449                ("d", "local value"),
3450                ("d", "released"),
3451                ("c", "redelivered"),
3452            ] {
3453                let table = actual.schema.table("tasks").unwrap().clone();
3454                let values = vec![
3455                    Some(ColumnValue::String(id.into())),
3456                    Some(ColumnValue::String("p1".into())),
3457                    Some(ColumnValue::String(title.into())),
3458                ];
3459                if images {
3460                    let image = Connection::open_in_memory().unwrap();
3461                    image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3462                        CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3463                        INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
3464                    image
3465                        .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
3466                        .unwrap();
3467                    assert!(matches!(
3468                        actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
3469                        Ok(1)
3470                    ));
3471                } else {
3472                    let segment = RowsSegment {
3473                        table: "tasks".into(),
3474                        schema_version: 1,
3475                        columns: table.wire_columns.clone(),
3476                        blocks: vec![vec![ssp2::segment::SegmentRow {
3477                            server_version: 2,
3478                            values: values.clone(),
3479                        }]],
3480                    };
3481                    assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
3482                }
3483                reference.write_base_row("tasks", &values, 2).unwrap();
3484                reference.rebuild_overlay();
3485                for table in ["tasks", "notes"] {
3486                    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")] {
3487                        assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
3488                    }
3489                }
3490                assert_eq!(actual.pending_commit_ids(), pending);
3491            }
3492            assert_eq!(
3493                actual.overlay_rebuild_count.get(),
3494                0,
3495                "each chunk must leave unrelated tables alone"
3496            );
3497            let before = actual
3498                .query("SELECT * FROM tasks ORDER BY id", &[])
3499                .unwrap();
3500            let fts_before = actual
3501                .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
3502                .unwrap();
3503            let revision = actual.local_revision();
3504            actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
3505            let table = actual.schema.table("tasks").unwrap().clone();
3506            let segment = RowsSegment {
3507                table: "tasks".into(),
3508                schema_version: 1,
3509                columns: table.wire_columns.clone(),
3510                blocks: vec![vec![ssp2::segment::SegmentRow {
3511                    server_version: 3,
3512                    values: vec![
3513                        Some(ColumnValue::String("b".into())),
3514                        Some(ColumnValue::String("p1".into())),
3515                        Some(ColumnValue::String("fresh".into())),
3516                    ],
3517                }]],
3518            };
3519            assert!(actual.apply_segment(0, &segment, false).is_err());
3520            assert!(actual.conn.is_autocommit());
3521            assert_eq!(
3522                actual
3523                    .query("SELECT * FROM tasks ORDER BY id", &[])
3524                    .unwrap(),
3525                before
3526            );
3527            assert_eq!(
3528                actual
3529                    .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
3530                    .unwrap(),
3531                fts_before
3532            );
3533            assert_eq!(actual.local_revision(), revision);
3534            assert_eq!(actual.pending_commit_ids(), pending);
3535        }
3536    }
3537
3538    #[test]
3539    fn import_reconciliation_preserves_typed_pending_primary_keys() {
3540        for (kind, a, b) in [
3541            ("string", json!("a"), json!("b")),
3542            ("integer", json!(1), json!(2)),
3543            ("float", json!(1.5), json!(2.5)),
3544            ("boolean", json!(true), json!(false)),
3545            ("json", json!("1"), json!("2")),
3546        ] {
3547            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}]}]});
3548            for images in [false, true] {
3549                let mut client =
3550                    SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
3551                let table = client.schema.table("tasks").unwrap().clone();
3552                client
3553                    .subscribe("tasks".into(), "tasks".into(), vec![], None)
3554                    .unwrap();
3555                for (id, title) in [(&a, "original"), (&b, "occupied")] {
3556                    client
3557                        .write_base_row(
3558                            "tasks",
3559                            &vec![
3560                                json_to_column_value(&table.columns[0], Some(id)).unwrap(),
3561                                Some(ColumnValue::String(title.into())),
3562                            ],
3563                            1,
3564                        )
3565                        .unwrap();
3566                }
3567                client.rebuild_overlay_if_dirty();
3568                client
3569                    .mutate(vec![Mutation::Upsert {
3570                        table: "tasks".into(),
3571                        values: Map::from_iter([
3572                            ("id".into(), a.clone()),
3573                            ("title".into(), json!("occupied")),
3574                        ]),
3575                        base_version: None,
3576                    }])
3577                    .unwrap();
3578                client.overlay_rebuild_count.set(0);
3579                for (title, expected) in [("available", "occupied"), ("occupied", "original")] {
3580                    let values = vec![
3581                        json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
3582                        Some(ColumnValue::String(title.into())),
3583                    ];
3584                    if images {
3585                        let image = Connection::open_in_memory().unwrap();
3586                        image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3587                            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
3588                            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
3589                        image
3590                            .execute(
3591                                "INSERT INTO tasks VALUES (?1,?2,2)",
3592                                rusqlite::params![RowParam::Cell(&values[0]), title],
3593                            )
3594                            .unwrap();
3595                        assert!(
3596                            matches!(
3597                                client.apply_sqlite_image(
3598                                    &image,
3599                                    &table,
3600                                    false,
3601                                    &[],
3602                                    1,
3603                                    7,
3604                                    "digest"
3605                                ),
3606                                Ok(1)
3607                            ),
3608                            "{kind}, images={images}"
3609                        );
3610                    } else {
3611                        let segment = RowsSegment {
3612                            table: "tasks".into(),
3613                            schema_version: 1,
3614                            columns: table.wire_columns.clone(),
3615                            blocks: vec![vec![ssp2::segment::SegmentRow {
3616                                server_version: 2,
3617                                values,
3618                            }]],
3619                        };
3620                        assert!(
3621                            matches!(client.apply_segment(0, &segment, false), Ok(1)),
3622                            "{kind}"
3623                        );
3624                    }
3625                    let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
3626                    let actual: String = client
3627                        .conn
3628                        .query_row(
3629                            "SELECT title FROM tasks WHERE id=?1",
3630                            [RowParam::Cell(&key)],
3631                            |row| row.get(0),
3632                        )
3633                        .unwrap();
3634                    assert_eq!(actual, expected, "{kind}, images={images}");
3635                }
3636                assert_eq!(client.overlay_rebuild_count.get(), 0);
3637            }
3638        }
3639    }
3640
3641    fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
3642        let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
3643            "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
3644            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
3645            "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
3646        })))});
3647        let mut client =
3648            SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
3649        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();
3650        client.rebuild_overlay();
3651        client
3652            .mutate(vec![Mutation::Upsert {
3653                table: "tasks".into(),
3654                values: Map::from_iter([
3655                    ("id".into(), json!("offline")),
3656                    ("project_id".into(), json!("p1")),
3657                    ("title".into(), json!("pending edit")),
3658                ]),
3659                base_version: None,
3660            }])
3661            .unwrap();
3662        let image = Connection::open_in_memory().unwrap();
3663        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
3664            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
3665        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();
3666        image
3667            .execute(
3668                "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
3669                [rows],
3670            )
3671            .unwrap();
3672        client.overlay_rebuild_count.set(0);
3673        let table = client.schema.table("tasks").unwrap().clone();
3674        (client, image, table)
3675    }
3676
3677    #[test]
3678    fn large_unique_import_copies_no_unrelated_tables() {
3679        let (mut client, image, table) = unique_import_fixture(10240);
3680        let pending = client.pending_commit_ids();
3681        client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
3682            CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
3683        assert!(matches!(
3684            client.apply_sqlite_image(
3685                &image,
3686                &table,
3687                true,
3688                &[("project_id".into(), vec!["p1".into()])],
3689                10240,
3690                7,
3691                "digest"
3692            ),
3693            Ok(10240)
3694        ));
3695        assert_eq!(client.overlay_rebuild_count.get(), 0);
3696        assert_eq!(client.pending_commit_ids(), pending);
3697        assert_eq!(
3698            client
3699                .query("SELECT count(*) AS n FROM tasks", &[])
3700                .unwrap()[0]["n"],
3701            10241
3702        );
3703        assert_eq!(
3704            client
3705                .query("SELECT count(*) AS n FROM tasks_fts", &[])
3706                .unwrap()[0]["n"],
3707            10241
3708        );
3709        assert_eq!(
3710            client
3711                .query("SELECT count(*) AS n FROM notes_fts", &[])
3712                .unwrap()[0]["n"],
3713            8192
3714        );
3715    }
3716
3717    #[test]
3718    #[ignore = "manual scaling benchmark; see bench/README.md"]
3719    fn benchmark_unique_image_import() {
3720        let mut samples = Vec::new();
3721        for rows in [10240, 51200, 102400] {
3722            for trial in 0..4 {
3723                let (mut client, image, table) = unique_import_fixture(rows);
3724                let start = std::time::Instant::now();
3725                assert!(
3726                    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)
3727                );
3728                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
3729                assert_eq!(
3730                    client
3731                        .query("SELECT count(*) AS n FROM tasks", &[])
3732                        .unwrap()[0]["n"],
3733                    rows + 1
3734                );
3735                assert_eq!(
3736                    client
3737                        .query("SELECT count(*) AS n FROM tasks_fts", &[])
3738                        .unwrap()[0]["n"],
3739                    rows + 1
3740                );
3741                assert_eq!(
3742                    client
3743                        .query("SELECT count(*) AS n FROM notes_fts", &[])
3744                        .unwrap()[0]["n"],
3745                    8192
3746                );
3747                assert_eq!(client.pending_commit_ids().len(), 1);
3748                if trial > 0 {
3749                    samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
3750                }
3751            }
3752        }
3753        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});
3754        let output = serde_json::to_string_pretty(&artifact).unwrap();
3755        if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
3756            use std::io::Write;
3757            let mut file = std::fs::OpenOptions::new()
3758                .write(true)
3759                .create_new(true)
3760                .open(path)
3761                .unwrap();
3762            file.write_all(output.as_bytes()).unwrap();
3763        } else {
3764            println!("{output}");
3765        }
3766    }
3767
3768    #[test]
3769    fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
3770        let schema = json!({
3771            "version": 1,
3772            "tables": [{
3773                "name": "catalogue_codes",
3774                "primaryKey": "id",
3775                "columns": [
3776                    { "name": "id", "type": "string", "nullable": false },
3777                    { "name": "release_id", "type": "string", "nullable": false },
3778                    { "name": "code", "type": "string", "nullable": false },
3779                    { "name": "title", "type": "string", "nullable": false }
3780                ],
3781                "scopes": [{ "pattern": "release:{release_id}" }],
3782                "ftsIndexes": [{
3783                    "name": "catalogue_codes_fts",
3784                    "columns": ["code", "title"],
3785                    "tokenize": "unicode61 remove_diacritics 2"
3786                }]
3787            }]
3788        });
3789        let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
3790            .expect("FTS5 client");
3791        let insert_trigger: String = client
3792            .conn
3793            .query_row(
3794                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
3795                [],
3796                |row| row.get(0),
3797            )
3798            .expect("insert trigger");
3799        let replace_guard: String = client
3800            .conn
3801            .query_row(
3802                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
3803                [],
3804                |row| row.get(0),
3805            )
3806            .expect("replace guard");
3807        assert!(!insert_trigger.contains("DELETE FROM"));
3808        assert!(replace_guard.contains("BEFORE INSERT"));
3809        assert!(replace_guard.contains("WHEN EXISTS"));
3810        let search = |client: &SyncClient, query: &str| {
3811            client
3812                .query(
3813                    "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",
3814                    &[Value::from(query)],
3815                )
3816                .expect("FTS query")
3817        };
3818
3819        client
3820            .mutate(vec![Mutation::Upsert {
3821                table: "catalogue_codes".to_owned(),
3822                values: Map::from_iter([
3823                    ("id".to_owned(), Value::from("c1")),
3824                    ("release_id".to_owned(), Value::from("r1")),
3825                    ("code".to_owned(), Value::from("A01")),
3826                    ("title".to_owned(), Value::from("Cholera")),
3827                ]),
3828                base_version: None,
3829            }])
3830            .expect("insert code");
3831        assert_eq!(search(&client, "cholera").len(), 1);
3832        let prior = client
3833            .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
3834            .unwrap();
3835        client
3836            .conn
3837            .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
3838            .unwrap();
3839        client.create_synced_tables().unwrap();
3840        assert_eq!(
3841            client
3842                .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
3843                .unwrap(),
3844            prior
3845        );
3846        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();
3847        let rendered = serde_json::to_string(&plan).unwrap();
3848        assert!(rendered.contains("COVERING INDEX"));
3849        assert!(rendered.contains("INDEX 0:="));
3850        client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
3851        assert_eq!(search(&client, "cholera").len(), 1);
3852
3853        client
3854            .mutate(vec![Mutation::Upsert {
3855                table: "catalogue_codes".to_owned(),
3856                values: Map::from_iter([
3857                    ("id".to_owned(), Value::from("c1")),
3858                    ("release_id".to_owned(), Value::from("r1")),
3859                    ("code".to_owned(), Value::from("A01")),
3860                    ("title".to_owned(), Value::from("Enteric infection")),
3861                ]),
3862                base_version: None,
3863            }])
3864            .expect("update code");
3865        assert!(search(&client, "cholera").is_empty());
3866        assert_eq!(search(&client, "enteric").len(), 1);
3867
3868        client
3869            .mutate(vec![Mutation::Delete {
3870                table: "catalogue_codes".to_owned(),
3871                row_id: "c1".to_owned(),
3872                base_version: None,
3873            }])
3874            .expect("delete code");
3875        assert!(search(&client, "enteric").is_empty());
3876    }
3877
3878    #[test]
3879    fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
3880        let schema = json!({
3881            "version": 1,
3882            "tables": [{
3883                "name": "patient_notes",
3884                "primaryKey": "id",
3885                "columns": [
3886                    { "name": "id", "type": "string", "nullable": false },
3887                    { "name": "practice_id", "type": "string", "nullable": false },
3888                    { "name": "encryption_key_id", "type": "string", "nullable": false },
3889                    { "name": "title", "type": "string", "nullable": false }
3890                ],
3891                "scopes": [{ "pattern": "practice:{practice_id}" }],
3892                "ftsIndexes": [{
3893                    "name": "patient_notes_fts",
3894                    "columns": ["title"],
3895                    "tokenize": "unicode61 remove_diacritics 2"
3896                }]
3897            }]
3898        });
3899        let mut client = SyncClient::new(
3900            "local-purge-test".to_owned(),
3901            &schema,
3902            ClientLimits::default(),
3903        )
3904        .expect("local purge client");
3905        client
3906            .conn
3907            .execute(
3908                "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
3909                 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
3910                 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
3911                [],
3912            )
3913            .expect("seed base rows");
3914        client.overlay_dirty.set(true);
3915        client.rebuild_overlay_if_dirty();
3916
3917        let note = |id: &str, key_id: &str, title: &str| {
3918            Map::from_iter([
3919                ("id".to_owned(), Value::from(id)),
3920                ("practice_id".to_owned(), Value::from("practice-1")),
3921                ("encryption_key_id".to_owned(), Value::from(key_id)),
3922                ("title".to_owned(), Value::from(title)),
3923            ])
3924        };
3925        let doomed = client
3926            .mutate(vec![
3927                Mutation::Upsert {
3928                    table: "patient_notes".to_owned(),
3929                    values: note("target", "key-revoked", "Target changed"),
3930                    base_version: None,
3931                },
3932                Mutation::Upsert {
3933                    table: "patient_notes".to_owned(),
3934                    values: note("unrelated", "key-held", "Unrelated changed"),
3935                    base_version: None,
3936                },
3937            ])
3938            .expect("doomed commit");
3939        let kept = client
3940            .mutate(vec![Mutation::Upsert {
3941                table: "patient_notes".to_owned(),
3942                values: note("kept", "key-held", "Kept optimistic"),
3943                base_version: None,
3944            }])
3945            .expect("kept commit");
3946        client.drain_change_batches();
3947
3948        let input = LocalDataPurgeInput {
3949            purge_id: "purge-001".to_owned(),
3950            targets: vec![LocalDataPurgeTarget {
3951                table: "patient_notes".to_owned(),
3952                selectors: BTreeMap::from([(
3953                    "encryption_key_id".to_owned(),
3954                    vec!["key-revoked".to_owned()],
3955                )]),
3956            }],
3957        };
3958        assert_eq!(
3959            client.purge_local_data(&input).expect("apply purge"),
3960            LocalDataPurgeResult {
3961                already_applied: false,
3962                purged_rows: 1,
3963                dropped_commits: 1,
3964            }
3965        );
3966        let rows = client
3967            .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
3968            .expect("visible rows");
3969        assert_eq!(rows.len(), 2);
3970        assert_eq!(rows[0]["id"], "kept");
3971        assert_eq!(rows[1]["id"], "unrelated");
3972        assert_eq!(rows[1]["title"], "Unrelated original");
3973        let fts = client
3974            .query(
3975                "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'",
3976                &[],
3977            )
3978            .expect("fts query");
3979        assert!(fts.is_empty());
3980        assert_eq!(client.outbox.len(), 1);
3981        assert_eq!(client.outbox[0].client_commit_id, kept);
3982        let outcome = client
3983            .commit_outcome(&doomed)
3984            .expect("read doomed outcome")
3985            .expect("doomed outcome");
3986        assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
3987        match &outcome.results[0] {
3988            CommitOperationOutcome::Error { rejection } => {
3989                assert_eq!(rejection.code, "client.local_data_purged");
3990                assert_eq!(rejection.client_commit_id, doomed);
3991            }
3992            other => panic!("expected local purge rejection, got {other:?}"),
3993        }
3994        assert_eq!(client.drain_change_batches().len(), 1);
3995        assert_eq!(
3996            client.purge_local_data(&input).expect("retry purge"),
3997            LocalDataPurgeResult {
3998                already_applied: true,
3999                purged_rows: 0,
4000                dropped_commits: 0,
4001            }
4002        );
4003        let conflicting = LocalDataPurgeInput {
4004            purge_id: input.purge_id.clone(),
4005            targets: vec![LocalDataPurgeTarget {
4006                table: "patient_notes".to_owned(),
4007                selectors: BTreeMap::from([(
4008                    "encryption_key_id".to_owned(),
4009                    vec!["key-held".to_owned()],
4010                )]),
4011            }],
4012        };
4013        assert!(client
4014            .purge_local_data(&conflicting)
4015            .expect_err("id collision must fail")
4016            .contains("already used with a different plan"));
4017    }
4018}
4019
4020impl SubState {
4021    fn name(self) -> &'static str {
4022        match self {
4023            SubState::Active => "active",
4024            SubState::Revoked => "revoked",
4025            SubState::Failed => "failed",
4026        }
4027    }
4028
4029    fn parse(value: &str) -> Self {
4030        match value {
4031            "revoked" => Self::Revoked,
4032            "failed" => Self::Failed,
4033            _ => Self::Active,
4034        }
4035    }
4036}
4037
4038#[derive(Debug, Clone)]
4039struct Subscription {
4040    id: String,
4041    table: String,
4042    requested: Vec<(String, Vec<String>)>,
4043    params: Option<String>,
4044    cursor: i64,
4045    /// §4.7 resume token, round-tripped opaquely.
4046    bootstrap_state: Option<String>,
4047    state: SubState,
4048    reason_code: Option<String>,
4049    /// Last effective scopes echoed while active (§3.3: persisted for the
4050    /// purge contract; each active echo replaces it).
4051    effective: Option<Vec<(String, Vec<String>)>>,
4052    synced_once: bool,
4053}
4054
4055#[derive(Debug, Clone)]
4056struct OutboxOp {
4057    upsert: bool,
4058    table: String,
4059    row_id: String,
4060    base_version: Option<i64>,
4061    /// Schema-agnostic local form (§0): driver JSON values, encoded with
4062    /// the current codec at send time.
4063    values: Option<Map<String, Value>>,
4064    /// Local-only patch intent; never encoded into SSP2 PUSH_COMMIT.
4065    changed_fields: Option<Vec<String>>,
4066}
4067
4068impl From<&OutboxOp> for CommitOperation {
4069    fn from(operation: &OutboxOp) -> Self {
4070        Self {
4071            table: operation.table.clone(),
4072            row_id: operation.row_id.clone(),
4073            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
4074            base_version: operation.base_version,
4075            values: operation.values.clone(),
4076            changed_fields: operation.changed_fields.clone(),
4077        }
4078    }
4079}
4080
4081#[derive(Debug, Clone)]
4082struct OutboxCommit {
4083    client_commit_id: String,
4084    ops: Vec<OutboxOp>,
4085}
4086
4087#[derive(Debug, Clone)]
4088struct CompiledLocalDataPurgeTarget {
4089    table: String,
4090    selectors: Vec<(String, Vec<String>)>,
4091}
4092
4093struct StoredCommitOutcomeRow {
4094    sequence: i64,
4095    client_commit_id: String,
4096    status: String,
4097    recorded_at_ms: i64,
4098    results_json: String,
4099    operations_json: Option<String>,
4100    resolution: String,
4101    resolved_at_ms: Option<i64>,
4102    replacement_client_commit_id: Option<String>,
4103}
4104
4105/// Section outcome distinguishing the §5.6 subscription-local fail-closed
4106/// path from a round-aborting failure (§1.4 rule 5).
4107enum SectionError {
4108    FailClosed,
4109    Abort(String, String),
4110}
4111
4112struct RequestMeta {
4113    pushed_ids: Vec<String>,
4114    /// Subscription id → the request carried `cursor < 0` and no resume
4115    /// token (§5.6 first-page detection: a *fresh* bootstrap).
4116    fresh: Vec<(String, bool)>,
4117    accept: u8,
4118    /// §6.1 splitBatch: outbox commits held back from THIS request because
4119    /// the running operation count reached the push cap — the next round
4120    /// pushes them (`sync_needed` stays set while any remain).
4121    deferred_commits: usize,
4122}
4123
4124#[derive(Default)]
4125struct ChangeAccumulator {
4126    /// None means table-wide; Some is the exact scoped domain.
4127    tables: BTreeMap<String, Option<BTreeSet<String>>>,
4128    windows: BTreeMap<(String, String), BTreeSet<String>>,
4129    status: bool,
4130    conflicts: bool,
4131    rejections: bool,
4132    outcomes: bool,
4133}
4134
4135impl ChangeAccumulator {
4136    fn table(&mut self, table: &str) {
4137        self.tables.insert(table.to_owned(), None);
4138    }
4139
4140    fn scope(&mut self, table: &str, key: String) {
4141        match self.tables.get_mut(table) {
4142            Some(None) => {}
4143            Some(Some(keys)) => {
4144                keys.insert(key);
4145            }
4146            None => {
4147                self.tables
4148                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
4149            }
4150        }
4151    }
4152
4153    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
4154        self.windows
4155            .entry((base_key.to_owned(), table.to_owned()))
4156            .or_default()
4157            .insert(unit.to_owned());
4158    }
4159
4160    fn touched(&self) -> bool {
4161        !self.tables.is_empty()
4162            || !self.windows.is_empty()
4163            || self.status
4164            || self.conflicts
4165            || self.rejections
4166            || self.outcomes
4167    }
4168}
4169
4170/// §6.1: the server caps total operations per request (reference default
4171/// 500) and rejects the whole batch with `sync.too_many_operations`; the
4172/// client "splits and retries". Splitting happens at build time: commits are
4173/// included IN ORDER until the operation budget is spent, the rest wait for
4174/// the next round.
4175const PUSH_OPS_PER_REQUEST: usize = 500;
4176const MAX_LOCAL_PURGE_TARGETS: usize = 64;
4177const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
4178const MAX_LOCAL_PURGE_VALUES: usize = 128;
4179const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
4180pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
4181
4182pub struct SyncClient {
4183    #[cfg(feature = "bench-internals")]
4184    benchmark_phases: Recorder,
4185    conn: Connection,
4186    schema: ClientSchema,
4187    client_id: String,
4188    limits: ClientLimits,
4189    subs: Vec<Subscription>,
4190    outbox: Vec<OutboxCommit>,
4191    conflicts: Vec<ConflictRecord>,
4192    rejections: Vec<RejectionRecord>,
4193    schema_floor: Option<SchemaFloor>,
4194    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
4195    lease_state: Option<LeaseState>,
4196    /// §1.6: the schema-floor response stops syncing until an upgrade.
4197    stopped: bool,
4198    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
4199    upgrading: bool,
4200    /// §8.4 coalesced sync-needed signal.
4201    sync_needed: bool,
4202    realtime_connected: bool,
4203    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
4204    presence: HashMap<String, HashMap<String, PresencePeer>>,
4205    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
4206    /// host may pin it (conformance runs on a virtual clock).
4207    now_ms: Option<i64>,
4208    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
4209    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
4210    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
4211    encryption: crate::values::EncryptionConfig,
4212    /// Fail-closed host bootstrap gate. While set, command hosts permit only
4213    /// status/lifecycle inspection and an exact authorized local purge.
4214    security_preflight: bool,
4215    /// Per-table primary-key upsert SQL, built once per (full table name) —
4216    /// the row write path runs per row during bootstrap (§5.6), so the SQL
4217    /// string (and, via `prepare_cached`, its compiled statement) is reused
4218    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
4219    /// schema reset (the column lists may have changed).
4220    insert_sql: RefCell<HashMap<String, String>>,
4221    /// §7.1 rebuild gate: true whenever the base tables or the outbox have
4222    /// diverged from the visible overlay since the last rebuild. Lets a
4223    /// no-op sync round skip the full base→visible copy.
4224    overlay_dirty: Cell<bool>,
4225    /// Test-only structural performance signal: response processing must not
4226    /// turn a batch of acknowledgements into one full overlay rebuild each.
4227    #[cfg(test)]
4228    overlay_rebuild_count: Cell<usize>,
4229    /// Test-only structural performance signal: outcome retention is enforced
4230    /// once per response rather than once per acknowledgement.
4231    #[cfg(test)]
4232    outcome_prune_count: Cell<usize>,
4233    /// Exact observer-transaction output drained by command/FFI hosts.
4234    progress: ProgressObserver,
4235    change_queue: VecDeque<ClientChangeBatch>,
4236    sync_intent_queue: VecDeque<SyncIntent>,
4237    /// Explicit exponential retry policy for transient transport failures.
4238    retry_delay_ms: u64,
4239    last_round: Option<DiagnosticLastRound>,
4240    last_change: Option<DiagnosticLastChange>,
4241}
4242
4243fn quote_ident(name: &str) -> String {
4244    format!("\"{}\"", name.replace('"', "\"\""))
4245}
4246
4247/// Seek by the existing key while retaining the exact legacy text match.
4248fn row_id_predicate(table: &TableSchema) -> String {
4249    let key = quote_ident(&table.primary_key);
4250    let text_match = format!("CAST({key} AS TEXT) = ?1");
4251    match table.columns[table.pk_index].ty {
4252        ColumnType::String | ColumnType::Json => {
4253            format!("{key} = ?1 AND {text_match}")
4254        }
4255        // Native columns have no declared SQLite affinity. Match the stored
4256        // integer bind type explicitly. Unary + removes expression affinity so
4257        // SQLite can seek the typeless key; the text check preserves exact IDs.
4258        ColumnType::Integer | ColumnType::Boolean => {
4259            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
4260        }
4261        // REAL-to-text conversion can round distinct values to the same text.
4262        // Narrowing those matches by numeric equality would change behavior.
4263        _ => text_match,
4264    }
4265}
4266
4267fn is_local_operation_code_like(value: &str) -> bool {
4268    let bytes = value.as_bytes();
4269    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
4270        && bytes
4271            .iter()
4272            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
4273}
4274
4275fn base_table(name: &str) -> String {
4276    quote_ident(&format!("_syncular_base_{name}"))
4277}
4278
4279/// §4.8: a stable, server-opaque key for a window base — table + variable +
4280/// canonical fixed scopes. Two `set_window` calls with the same base
4281/// address the same registry rows.
4282/// §4.8 deferred eviction record: (sub id, table, effective scope map).
4283type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
4284
4285fn window_base_key(base: &WindowBase) -> String {
4286    format!(
4287        "{}\0{}\0{}",
4288        base.table,
4289        base.variable,
4290        canonical_scope_json(&base.fixed_scopes)
4291    )
4292}
4293
4294/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
4295fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
4296    let mut scopes = base.fixed_scopes.clone();
4297    scopes.retain(|(k, _)| k != &base.variable);
4298    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
4299    scopes
4300}
4301
4302/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
4303/// echoed not interpreted by the server, so the exact hash is client
4304/// convention; SHA-256 matches the SPEC's worked example.
4305fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
4306    let canonical = canonical_scope_json(&unit_scopes(base, unit));
4307    let digest = Sha256::digest(canonical.as_bytes());
4308    let hex = bytes_to_hex(&digest);
4309    format!("w:{}:{}", base.table, &hex[..16])
4310}
4311
4312fn visible_table(name: &str) -> String {
4313    quote_ident(name)
4314}
4315
4316const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
4317
4318/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
4319/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
4320fn is_synced_table_name(name: &str) -> bool {
4321    if name.starts_with("sqlite_") {
4322        return false;
4323    }
4324    if name.starts_with("_syncular_base_") {
4325        return true; // the base half of a synced table pair
4326    }
4327    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
4328    !name.starts_with("_syncular_")
4329}
4330
4331/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
4332fn blob_id_for(bytes: &[u8]) -> String {
4333    let digest = Sha256::digest(bytes);
4334    format!("sha256:{}", bytes_to_hex(&digest))
4335}
4336
4337/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
4338/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
4339/// per row on the §5.6 bootstrap path), scalars bind owned.
4340enum RowParam<'a> {
4341    Cell(&'a Option<ColumnValue>),
4342    Version(i64),
4343}
4344
4345impl rusqlite::ToSql for RowParam<'_> {
4346    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
4347        Ok(match self {
4348            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
4349            RowParam::Cell(cell) => match cell {
4350                None => ToSqlOutput::Owned(SqlValue::Null),
4351                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
4352                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
4353                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
4354                Some(ColumnValue::Boolean(b)) => {
4355                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
4356                }
4357                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
4358                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
4359                }
4360                // §5.10: crdt bytes store as BLOB, like bytes.
4361                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
4362                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
4363                }
4364            },
4365        })
4366    }
4367}
4368
4369// Retain a typed primary key across the current import chunk without copying row payloads.
4370fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
4371    match param.to_sql().map_err(|error| error.to_string())? {
4372        ToSqlOutput::Owned(value) => Ok(value),
4373        ToSqlOutput::Borrowed(value) => Ok(value.into()),
4374        _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
4375    }
4376}
4377
4378/// §5.3 image cell → bind parameter, strict per the declared column type
4379/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
4380/// nullable). Mismatches are image-producer violations. Returns the cell
4381/// borrowed when the stored representation already matches what the row
4382/// codec would write, or the normalized scalar (boolean → 0/1, float from
4383/// INTEGER → REAL) otherwise — the local write is byte-identical to the
4384/// old convert-then-insert path without allocating per cell.
4385fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
4386    use ssp2::segment::ColumnType;
4387    let mismatch = || {
4388        Err(format!(
4389            "image column {:?} holds a value of the wrong type",
4390            column.name
4391        ))
4392    };
4393    match value {
4394        ValueRef::Null => {
4395            if !column.nullable {
4396                return Err(format!(
4397                    "image column {:?} is NULL but not nullable",
4398                    column.name
4399                ));
4400            }
4401            Ok(ToSqlOutput::Owned(SqlValue::Null))
4402        }
4403        ValueRef::Integer(i) => match column.ty {
4404            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
4405            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
4406            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
4407            _ => mismatch(),
4408        },
4409        ValueRef::Real(_) => match column.ty {
4410            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
4411            _ => mismatch(),
4412        },
4413        ValueRef::Text(t) => {
4414            std::str::from_utf8(t)
4415                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
4416            match column.ty {
4417                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
4418                    Ok(ToSqlOutput::Borrowed(value))
4419                }
4420                _ => mismatch(),
4421            }
4422        }
4423        ValueRef::Blob(_) => match column.ty {
4424            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
4425            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
4426            _ => mismatch(),
4427        },
4428    }
4429}
4430
4431fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
4432    use rusqlite::types::ValueRef;
4433    match value {
4434        ValueRef::Null => Value::Null,
4435        ValueRef::Integer(i) => match column.ty {
4436            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
4437            ssp2::segment::ColumnType::Float => {
4438                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
4439            }
4440            _ => Value::from(i),
4441        },
4442        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
4443        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
4444        ValueRef::Blob(b) => {
4445            let mut map = Map::new();
4446            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
4447            Value::Object(map)
4448        }
4449    }
4450}
4451
4452/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
4453/// Objects are accepted in lossless `{"$bytes": hex}` and
4454/// `{"$bigint": decimal}` envelope forms.
4455fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
4456    Ok(match value {
4457        Value::Null => SqlValue::Null,
4458        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
4459        Value::Number(n) => {
4460            if let Some(i) = n.as_i64() {
4461                SqlValue::Integer(i)
4462            } else if let Some(f) = n.as_f64() {
4463                SqlValue::Real(f)
4464            } else {
4465                return Err(format!("query param number {n} is out of range"));
4466            }
4467        }
4468        Value::String(s) => SqlValue::Text(s.clone()),
4469        Value::Object(_) => {
4470            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
4471                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
4472            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
4473                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
4474                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
4475                })?)
4476            } else {
4477                return Err(
4478                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
4479                        .to_owned(),
4480                );
4481            }
4482        }
4483        Value::Array(_) => return Err("query array params are not supported".to_owned()),
4484    })
4485}
4486
4487/// Map a rusqlite value with no schema column to consult (arbitrary query
4488/// output): integers/reals/text pass through by stored affinity, blobs ride
4489/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
4490/// schema column type to recover booleans/floats/json.
4491fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
4492    use rusqlite::types::ValueRef;
4493    match value {
4494        ValueRef::Null => Value::Null,
4495        ValueRef::Integer(i) => {
4496            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
4497            // ordinary UI-sized integers ergonomic and envelope only values
4498            // beyond JavaScript's safe range.
4499            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
4500            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
4501                Value::from(i)
4502            } else {
4503                let mut map = Map::new();
4504                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
4505                Value::Object(map)
4506            }
4507        }
4508        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
4509        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
4510        ValueRef::Blob(b) => {
4511            let mut map = Map::new();
4512            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
4513            Value::Object(map)
4514        }
4515    }
4516}
4517
4518fn query_connection(
4519    conn: &Connection,
4520    sql: &str,
4521    params: &[QueryValue],
4522) -> Result<Vec<QueryRow>, String> {
4523    crate::query_guard::assert_read_only_query(sql)?;
4524    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
4525    let bound: Vec<SqlValue> = params
4526        .iter()
4527        .map(json_param_to_sql)
4528        .collect::<Result<_, _>>()?;
4529    let mut stmt = conn.prepare(&lowered_sql).map_err(|e| e.to_string())?;
4530    let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
4531    let bound_refs: Vec<&dyn rusqlite::ToSql> =
4532        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
4533    let mut sql_rows = stmt
4534        .query(bound_refs.as_slice())
4535        .map_err(|e| e.to_string())?;
4536    let mut out = Vec::new();
4537    while let Some(row) = sql_rows.next().map_err(|e| e.to_string())? {
4538        let mut record = Map::new();
4539        for (i, name) in column_names.iter().enumerate() {
4540            let value = row.get_ref(i).map_err(|e| e.to_string())?;
4541            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
4542        }
4543        out.push(record);
4544    }
4545    Ok(out)
4546}
4547
4548fn persisted_window_state(conn: &Connection, base: &WindowBase) -> Result<WindowState, String> {
4549    let mut stmt = conn
4550        .prepare(
4551            "SELECT windows.unit, subscriptions.state_json
4552               FROM _syncular_windows AS windows
4553               JOIN _syncular_subscriptions AS subscriptions
4554                 ON subscriptions.id = windows.sub_id
4555              WHERE windows.base = ?1
4556              ORDER BY windows.unit ASC",
4557        )
4558        .map_err(|error| error.to_string())?;
4559    let rows = stmt
4560        .query_map(rusqlite::params![window_base_key(base)], |row| {
4561            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
4562        })
4563        .map_err(|error| error.to_string())?;
4564    let mut units = Vec::new();
4565    let mut pending = Vec::new();
4566    for row in rows {
4567        let (unit, raw) = row.map_err(|error| error.to_string())?;
4568        let state: Value = serde_json::from_str(&raw)
4569            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
4570        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
4571            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
4572            || state
4573                .get("bootstrapState")
4574                .is_some_and(|value| !value.is_null());
4575        if is_pending {
4576            pending.push(unit.clone());
4577        }
4578        units.push(unit);
4579    }
4580    Ok(WindowState { units, pending })
4581}
4582
4583fn snapshot_connection(
4584    conn: &Connection,
4585    sql: &str,
4586    params: &[Value],
4587    coverage: &[WindowCoverage],
4588) -> Result<QuerySnapshot, String> {
4589    conn.execute_batch("SAVEPOINT syncular_snapshot_read")
4590        .map_err(|error| error.to_string())?;
4591    let result = (|| {
4592        let revision = conn
4593            .query_row(
4594                "SELECT value FROM _syncular_meta WHERE key = ?1",
4595                rusqlite::params![LOCAL_REVISION_KEY],
4596                |row| row.get::<_, String>(0),
4597            )
4598            .ok()
4599            .and_then(|value| value.parse::<u64>().ok())
4600            .unwrap_or(0);
4601        let rows = query_connection(conn, sql, params)?;
4602        let mut pending = Vec::new();
4603        let mut missing = Vec::new();
4604        for requested in coverage {
4605            let base_key = window_base_key(&requested.base);
4606            let state = persisted_window_state(conn, &requested.base)?;
4607            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
4608                let reference = WindowUnitRef {
4609                    base_key: base_key.clone(),
4610                    unit: unit.clone(),
4611                };
4612                if !state.units.iter().any(|held| held == &unit) {
4613                    missing.push(reference);
4614                } else if state.pending.iter().any(|held| held == &unit) {
4615                    pending.push(reference);
4616                }
4617            }
4618        }
4619        Ok(QuerySnapshot {
4620            revision: revision.to_string(),
4621            rows,
4622            coverage: CoverageSnapshot {
4623                complete: pending.is_empty() && missing.is_empty(),
4624                pending,
4625                missing,
4626            },
4627        })
4628    })();
4629    match result {
4630        Ok(snapshot) => {
4631            conn.execute_batch("RELEASE syncular_snapshot_read")
4632                .map_err(|error| error.to_string())?;
4633            Ok(snapshot)
4634        }
4635        Err(error) => {
4636            let _ = conn.execute_batch(
4637                "ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read",
4638            );
4639            Err(error)
4640        }
4641    }
4642}
4643
4644/// A long-lived read-only SQLite sidecar for latency-critical native views.
4645/// Network rounds stay serialized on the mutable core owner, while atomic
4646/// query snapshots use this independent connection and therefore never queue
4647/// behind HTTP/WebSocket latency.
4648pub struct FileQuerySnapshotReader {
4649    path: String,
4650    conn: Option<Connection>,
4651}
4652
4653impl FileQuerySnapshotReader {
4654    #[must_use]
4655    pub fn new(path: impl Into<String>) -> Self {
4656        Self {
4657            path: path.into(),
4658            conn: None,
4659        }
4660    }
4661
4662    fn connection(&mut self) -> Result<&Connection, String> {
4663        if self.conn.is_none() {
4664            let conn = Connection::open_with_flags(
4665                &self.path,
4666                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
4667            )
4668            .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
4669            conn.busy_timeout(std::time::Duration::from_millis(250))
4670                .map_err(|error| error.to_string())?;
4671            self.conn = Some(conn);
4672        }
4673        self.conn
4674            .as_ref()
4675            .ok_or_else(|| "read sidecar connection missing".to_owned())
4676    }
4677
4678    pub fn query_snapshot(
4679        &mut self,
4680        sql: &str,
4681        params: &[Value],
4682        coverage: &[WindowCoverage],
4683    ) -> Result<QuerySnapshot, String> {
4684        snapshot_connection(self.connection()?, sql, params, coverage)
4685    }
4686}
4687
4688impl SyncClient {
4689    pub fn new_with_identity(
4690        client_id: Option<String>,
4691        schema_json: &Value,
4692        limits: ClientLimits,
4693    ) -> Result<Self, String> {
4694        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
4695        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
4696        Self::with_connection(resolved, schema_json, limits, conn)
4697    }
4698
4699    pub fn new(
4700        client_id: String,
4701        schema_json: &Value,
4702        limits: ClientLimits,
4703    ) -> Result<Self, String> {
4704        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
4705        Self::with_connection(client_id, schema_json, limits, conn)
4706    }
4707
4708    /// Build a client backed by an on-disk SQLite database at `path` — the
4709    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
4710    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
4711    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
4712    /// of the command router's dependency set (the router only holds a path).
4713    pub fn open_path(
4714        client_id: String,
4715        schema_json: &Value,
4716        limits: ClientLimits,
4717        path: &str,
4718    ) -> Result<Self, String> {
4719        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
4720        Self::with_connection(client_id, schema_json, limits, conn)
4721    }
4722
4723    pub fn open_path_with_identity(
4724        client_id: Option<String>,
4725        schema_json: &Value,
4726        limits: ClientLimits,
4727        path: &str,
4728    ) -> Result<Self, String> {
4729        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
4730        // File-backed native clients use an independent read connection for
4731        // latency-critical snapshots. WAL is SQLite's intended reader/writer
4732        // concurrency mode: a view read never holds a rollback-journal lock
4733        // that delays the mutable client's next commit, and a short busy
4734        // timeout absorbs the tiny checkpoint/schema-lock windows.
4735        conn.busy_timeout(std::time::Duration::from_millis(250))
4736            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
4737        conn.pragma_update(None, "journal_mode", "WAL")
4738            .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
4739        let persisted = conn
4740            .query_row(
4741                "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
4742                [],
4743                |row| row.get::<_, String>(0),
4744            )
4745            .ok();
4746        let resolved = persisted
4747            .clone()
4748            .or(client_id.clone())
4749            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
4750        if let (Some(existing), Some(requested)) = (persisted, client_id) {
4751            if existing != requested {
4752                return Err(format!(
4753                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
4754                ));
4755            }
4756        }
4757        Self::with_connection(resolved, schema_json, limits, conn)
4758    }
4759
4760    #[must_use]
4761    pub fn client_id(&self) -> &str {
4762        &self.client_id
4763    }
4764
4765    /// Build a client over a caller-supplied rusqlite connection — the seam a
4766    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
4767    /// with an on-disk database (`Connection::open(path)`) rather than the
4768    /// default `:memory:`. The connection MUST be fresh (no pre-existing
4769    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
4770    /// the same file across process restarts reuses the persisted rows.
4771    pub fn with_connection(
4772        client_id: String,
4773        schema_json: &Value,
4774        limits: ClientLimits,
4775        conn: Connection,
4776    ) -> Result<Self, String> {
4777        if limits.outcome_retention_max_entries == Some(0) {
4778            return Err(
4779                "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
4780            );
4781        }
4782        let schema = parse_schema_json(schema_json)?;
4783        let mut client = SyncClient {
4784            #[cfg(feature = "bench-internals")]
4785            benchmark_phases: Recorder::default(),
4786            conn,
4787            schema,
4788            client_id,
4789            limits,
4790            subs: Vec::new(),
4791            outbox: Vec::new(),
4792            conflicts: Vec::new(),
4793            rejections: Vec::new(),
4794            schema_floor: None,
4795            lease_state: None,
4796            stopped: false,
4797            upgrading: false,
4798            sync_needed: false,
4799            realtime_connected: false,
4800            presence: HashMap::new(),
4801            now_ms: None,
4802            encryption: crate::values::EncryptionConfig::default(),
4803            security_preflight: false,
4804            insert_sql: RefCell::new(HashMap::new()),
4805            overlay_dirty: Cell::new(false),
4806            #[cfg(test)]
4807            overlay_rebuild_count: Cell::new(0),
4808            #[cfg(test)]
4809            outcome_prune_count: Cell::new(0),
4810            progress: ProgressObserver::default(),
4811            change_queue: VecDeque::new(),
4812            sync_intent_queue: VecDeque::new(),
4813            retry_delay_ms: 250,
4814            last_round: None,
4815            last_change: None,
4816        };
4817        // The row write path leans on the prepared-statement cache (two
4818        // insert statements per synced table, plus the bookkeeping
4819        // statements); size it so a multi-table schema never thrashes.
4820        client
4821            .conn
4822            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
4823        // Protected bookkeeping must exist before inspecting the persisted
4824        // schema marker. New app indexes may reference columns that only
4825        // exist after a version-bump reset.
4826        client.create_bookkeeping_tables()?;
4827        match client.get_meta(CLIENT_ID_KEY) {
4828            Some(existing) if existing != client.client_id => {
4829                return Err(format!(
4830                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
4831                    client.client_id
4832                ));
4833            }
4834            None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
4835            _ => {}
4836        }
4837        client.restore_persisted_state()?;
4838        let marker = client
4839            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
4840            .and_then(|value| value.parse::<i32>().ok());
4841        match marker {
4842            None => {
4843                client.create_synced_tables()?;
4844                client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
4845            }
4846            Some(version) if version == client.schema.version => {
4847                client.create_synced_tables()?;
4848            }
4849            Some(_) => client.run_schema_reset()?,
4850        }
4851        client.clear_satisfied_persisted_schema_floor();
4852        client.prune_unknown_subscriptions()?;
4853        if marker == Some(client.schema.version) && !client.outbox.is_empty() {
4854            // Reconstruct the visible optimistic overlay from the durable base
4855            // plus outbox instead of trusting a process-interrupted mirror.
4856            client.overlay_dirty.set(true);
4857            client.rebuild_overlay();
4858        }
4859        // Every persisted active subscription needs one catch-up pull on open:
4860        // realtime only covers changes after connection, while an idempotent
4861        // setWindow correctly creates no fresh command effect. Pending outbox
4862        // work has the same restart requirement. The core owns this intent so
4863        // native hosts never poll or require an application-issued sync().
4864        client.enqueue_startup_sync_if_needed()?;
4865        Ok(client)
4866    }
4867
4868    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
4869    /// against this instead of system time (conformance virtual clock).
4870    pub fn set_now_ms(&mut self, now_ms: i64) {
4871        self.now_ms = Some(now_ms);
4872    }
4873
4874    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
4875    /// The command router parses these from the `create` command's
4876    /// `encryption` config (keys as `{$bytes: hex}`).
4877    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
4878        self.encryption = encryption;
4879    }
4880
4881    #[must_use]
4882    pub fn security_lifecycle(&self) -> &'static str {
4883        if self.security_preflight {
4884            "preflight"
4885        } else {
4886            "active"
4887        }
4888    }
4889
4890    #[must_use]
4891    pub fn security_preflight(&self) -> bool {
4892        self.security_preflight
4893    }
4894
4895    /// Quarantine this replica: enter the fail-closed gate, release all
4896    /// core-owned key material, and record the gate in the database so
4897    /// reopening re-enters preflight until `activate_security` clears it.
4898    ///
4899    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
4900    /// which must NOT leave that durable mark.
4901    pub fn begin_security_preflight(&mut self) {
4902        self.seal_security_on_teardown();
4903        // Persist the quarantine so it survives handle teardown and restart:
4904        // reopening this replica re-enters preflight until activation clears it.
4905        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
4906    }
4907
4908    /// Release core-owned key material as a host tears the client down,
4909    /// WITHOUT recording a quarantine.
4910    ///
4911    /// Shutting an activated client down is not a quarantine event. Persisting
4912    /// the gate here marks every cleanly closed replica as pending, and the
4913    /// next plain `create` is then refused permanently: the reopen path
4914    /// restores the flag from the marker and the create guard rejects it. The
4915    /// in-memory flag still closes the gate for anything still holding this
4916    /// instance, which is all a teardown barrier needs.
4917    pub fn seal_security_on_teardown(&mut self) {
4918        self.security_preflight = true;
4919        self.encryption = crate::values::EncryptionConfig::default();
4920        self.sync_intent_queue.clear();
4921    }
4922
4923    /// Install the post-authentication keyring and release the host loop.
4924    pub fn activate_security(
4925        &mut self,
4926        encryption: crate::values::EncryptionConfig,
4927    ) -> Result<(), String> {
4928        if !self.security_preflight {
4929            return Err(
4930                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
4931            );
4932        }
4933        self.enqueue_startup_sync_if_needed()?;
4934        self.encryption = encryption;
4935        self.security_preflight = false;
4936        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
4937        Ok(())
4938    }
4939
4940    fn clock_now_ms(&self) -> i64 {
4941        self.now_ms.unwrap_or_else(|| {
4942            std::time::SystemTime::now()
4943                .duration_since(std::time::UNIX_EPOCH)
4944                .map(|d| d.as_millis() as i64)
4945                .unwrap_or(0)
4946        })
4947    }
4948
4949    fn create_bookkeeping_tables(&self) -> Result<(), String> {
4950        // Durable client bookkeeping (outbox + subscription + meta).
4951        self.conn
4952            .execute_batch(
4953                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
4954                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
4955                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
4956                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
4957                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
4958                   client_commit_id TEXT NOT NULL UNIQUE,
4959                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
4960                   recorded_at_ms INTEGER NOT NULL,
4961                   results_json TEXT NOT NULL,
4962                   operations_json TEXT,
4963                   resolution TEXT NOT NULL DEFAULT 'active'
4964                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
4965                   resolved_at_ms INTEGER,
4966                   replacement_client_commit_id TEXT);
4967                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
4968                   ON _syncular_commit_outcomes(resolution, seq);
4969                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
4970                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
4971                 CREATE TABLE IF NOT EXISTS _syncular_meta (
4972                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
4973                 CREATE TABLE IF NOT EXISTS _syncular_windows (
4974                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
4975                   PRIMARY KEY (base, unit));
4976                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
4977                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
4978                   effective_scopes TEXT NOT NULL);
4979                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
4980                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
4981                   PRIMARY KEY(commit_id, blob_id));
4982                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
4983                   ON _syncular_blob_commit_refs(blob_id);",
4984            )
4985            .map_err(|e| e.to_string())?;
4986        // Migrate an outcome journal created before failed aggregate
4987        // envelopes were retained. Historical rows intentionally stay NULL.
4988        let _ = self
4989            .conn
4990            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
4991        if self.get_meta(LOCAL_REVISION_KEY).is_none() {
4992            self.set_meta(LOCAL_REVISION_KEY, "0");
4993        }
4994        // §5.9.7 blob cache. Mutable upload state stays off the body row so
4995        // completing a large upload does not rewrite its BLOB record.
4996        if self.schema_has_blobs() {
4997            let exists = self
4998                .conn
4999                .query_row(
5000                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
5001                    [],
5002                    |row| row.get::<_, bool>(0),
5003                )
5004                .map_err(|e| e.to_string())?;
5005            if exists {
5006                let mut statement = self
5007                    .conn
5008                    .prepare("PRAGMA table_info(_syncular_blobs)")
5009                    .map_err(|e| e.to_string())?;
5010                let mut columns = statement
5011                    .query_map([], |row| row.get::<_, String>(1))
5012                    .map_err(|e| e.to_string())?
5013                    .collect::<Result<Vec<_>, _>>()
5014                    .map_err(|e| e.to_string())?;
5015                columns.sort();
5016                let mut expected = vec![
5017                    "blob_id",
5018                    "byte_length",
5019                    "bytes",
5020                    "created_at_ms",
5021                    "media_type",
5022                ];
5023                expected.sort_unstable();
5024                if columns != expected {
5025                    return Err(
5026                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
5027                    );
5028                }
5029            } else {
5030                self.conn
5031                    .execute_batch(
5032                        "CREATE TABLE _syncular_blobs(
5033                           blob_id TEXT PRIMARY KEY,
5034                           bytes BLOB NOT NULL,
5035                           byte_length INTEGER NOT NULL,
5036                           media_type TEXT,
5037                           created_at_ms INTEGER NOT NULL);",
5038                    )
5039                    .map_err(|e| e.to_string())?;
5040            }
5041            self.conn
5042                .execute_batch(
5043                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
5044                       blob_id TEXT PRIMARY KEY,
5045                       media_type TEXT,
5046                       created_at_ms INTEGER NOT NULL);",
5047                )
5048                .map_err(|e| e.to_string())?;
5049        }
5050        Ok(())
5051    }
5052
5053    /// True iff any synced table declares a `blob_ref` column (§5.9).
5054    fn schema_has_blobs(&self) -> bool {
5055        self.schema
5056            .tables
5057            .iter()
5058            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
5059    }
5060
5061    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
5062
5063    fn get_meta(&self, key: &str) -> Option<String> {
5064        self.conn
5065            .query_row(
5066                "SELECT value FROM _syncular_meta WHERE key = ?1",
5067                rusqlite::params![key],
5068                |row| row.get::<_, String>(0),
5069            )
5070            .ok()
5071    }
5072
5073    fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
5074        self.conn
5075            .query_row(
5076                "SELECT value FROM _syncular_meta WHERE key = ?1",
5077                rusqlite::params![key],
5078                |row| row.get::<_, String>(0),
5079            )
5080            .optional()
5081            .map_err(|_| {
5082                "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
5083            })
5084    }
5085
5086    fn set_meta(&self, key: &str, value: &str) {
5087        let _ = self.conn.execute(
5088            "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
5089            rusqlite::params![key, value],
5090        );
5091    }
5092
5093    fn delete_meta(&self, key: &str) {
5094        let _ = self.conn.execute(
5095            "DELETE FROM _syncular_meta WHERE key = ?1",
5096            rusqlite::params![key],
5097        );
5098    }
5099
5100    fn restore_persisted_state(&mut self) -> Result<(), String> {
5101        self.subs = {
5102            let mut stmt = self
5103                .conn
5104                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
5105                .map_err(|error| error.to_string())?;
5106            let rows = stmt
5107                .query_map([], |row| {
5108                    Ok((
5109                        row.get::<_, String>(0)?,
5110                        row.get::<_, String>(1)?,
5111                        row.get::<_, String>(2)?,
5112                    ))
5113                })
5114                .map_err(|error| error.to_string())?;
5115            let mut subscriptions = Vec::new();
5116            for row in rows {
5117                let (id, table, raw) = row.map_err(|error| error.to_string())?;
5118                let state: Value = serde_json::from_str(&raw)
5119                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
5120                let requested = json_to_scope_map(
5121                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
5122                )?;
5123                let effective = state
5124                    .get("effectiveScopes")
5125                    .filter(|value| !value.is_null())
5126                    .map(json_to_scope_map)
5127                    .transpose()?;
5128                subscriptions.push(Subscription {
5129                    id,
5130                    table,
5131                    requested,
5132                    params: state
5133                        .get("params")
5134                        .and_then(Value::as_str)
5135                        .map(str::to_owned),
5136                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
5137                    bootstrap_state: state
5138                        .get("bootstrapState")
5139                        .and_then(Value::as_str)
5140                        .map(str::to_owned),
5141                    state: SubState::parse(
5142                        state
5143                            .get("status")
5144                            .and_then(Value::as_str)
5145                            .unwrap_or("active"),
5146                    ),
5147                    reason_code: state
5148                        .get("reasonCode")
5149                        .and_then(Value::as_str)
5150                        .map(str::to_owned),
5151                    effective,
5152                    synced_once: state
5153                        .get("syncedOnce")
5154                        .and_then(Value::as_bool)
5155                        .unwrap_or(false),
5156                });
5157            }
5158            subscriptions
5159        };
5160
5161        self.outbox = {
5162            let mut stmt = self
5163                .conn
5164                .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
5165                .map_err(|error| error.to_string())?;
5166            let rows = stmt
5167                .query_map([], |row| {
5168                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5169                })
5170                .map_err(|error| error.to_string())?;
5171            let mut commits = Vec::new();
5172            for row in rows {
5173                let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
5174                let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
5175                    format!("invalid persisted outbox {client_commit_id:?}: {error}")
5176                })?;
5177                let mut ops = Vec::with_capacity(entries.len());
5178                for entry in entries {
5179                    let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
5180                    ops.push(OutboxOp {
5181                        upsert: op == "upsert",
5182                        table: entry
5183                            .get("table")
5184                            .and_then(Value::as_str)
5185                            .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
5186                            .to_owned(),
5187                        row_id: entry
5188                            .get("rowId")
5189                            .and_then(Value::as_str)
5190                            .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
5191                            .to_owned(),
5192                        base_version: entry.get("baseVersion").and_then(Value::as_i64),
5193                        values: entry.get("values").and_then(Value::as_object).cloned(),
5194                        changed_fields: entry.get("changedFields").and_then(Value::as_array).map(
5195                            |values| {
5196                                values
5197                                    .iter()
5198                                    .filter_map(Value::as_str)
5199                                    .map(str::to_owned)
5200                                    .collect()
5201                            },
5202                        ),
5203                    });
5204                }
5205                commits.push(OutboxCommit {
5206                    client_commit_id,
5207                    ops,
5208                });
5209            }
5210            commits
5211        };
5212
5213        self.prune_commit_outcomes()?;
5214        let active = self.commit_outcomes(CommitOutcomeQuery {
5215            active_only: true,
5216            ..CommitOutcomeQuery::default()
5217        })?;
5218        self.conflicts = active
5219            .iter()
5220            .flat_map(|outcome| outcome.results.iter())
5221            .filter_map(|result| match result {
5222                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
5223                _ => None,
5224            })
5225            .collect();
5226        self.rejections = active
5227            .iter()
5228            .flat_map(|outcome| outcome.results.iter())
5229            .filter_map(|result| match result {
5230                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
5231                _ => None,
5232            })
5233            .collect();
5234
5235        self.lease_state = self
5236            .get_meta(LEASE_STATE_KEY)
5237            .map(|raw| serde_json::from_str(&raw))
5238            .transpose()
5239            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
5240        self.schema_floor = self
5241            .get_meta(SCHEMA_FLOOR_KEY)
5242            .map(|raw| serde_json::from_str(&raw))
5243            .transpose()
5244            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
5245        self.stopped = self.schema_floor.is_some();
5246        // A replica left in unactivated preflight reopens gated: the quarantine
5247        // decision persists with the data across handle teardown and restart.
5248        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
5249            self.security_preflight = true;
5250        }
5251        Ok(())
5252    }
5253
5254    /// Remove registrations for tables the running schema no longer knows.
5255    /// Otherwise the server rejects every pull with `sync.unknown_table`.
5256    fn prune_unknown_subscriptions(&mut self) -> Result<(), String> {
5257        let valid_tables: BTreeSet<String> = self
5258            .schema
5259            .tables
5260            .iter()
5261            .map(|table| table.name.clone())
5262            .collect();
5263        let stale_ids: Vec<String> = self
5264            .subs
5265            .iter()
5266            .filter(|sub| !valid_tables.contains(&sub.table))
5267            .map(|sub| sub.id.clone())
5268            .collect();
5269        for id in &stale_ids {
5270            self.conn
5271                .execute(
5272                    "DELETE FROM _syncular_windows WHERE sub_id = ?1",
5273                    rusqlite::params![id],
5274                )
5275                .map_err(|error| error.to_string())?;
5276            self.conn
5277                .execute(
5278                    "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
5279                    rusqlite::params![id],
5280                )
5281                .map_err(|error| error.to_string())?;
5282            self.conn
5283                .execute(
5284                    "DELETE FROM _syncular_subscriptions WHERE id = ?1",
5285                    rusqlite::params![id],
5286                )
5287                .map_err(|error| error.to_string())?;
5288        }
5289        self.subs.retain(|sub| valid_tables.contains(&sub.table));
5290        Ok(())
5291    }
5292
5293    #[must_use]
5294    pub fn local_revision(&self) -> u64 {
5295        self.get_meta(LOCAL_REVISION_KEY)
5296            .and_then(|value| value.parse().ok())
5297            .unwrap_or(0)
5298    }
5299
5300    #[must_use]
5301    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
5302        SyncStatusSnapshot {
5303            current_schema_version: self.schema.version,
5304            outbox: self.outbox.len(),
5305            upgrading: self.upgrading,
5306            lease_state: self.lease_state.clone(),
5307            schema_floor: self.schema_floor.clone(),
5308            sync_needed: self.sync_needed,
5309        }
5310    }
5311
5312    pub fn diagnostics_snapshot(
5313        &self,
5314        request: &ClientDiagnosticsRequest,
5315    ) -> Result<ClientDiagnosticsSnapshot, String> {
5316        if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
5317            return Err(format!(
5318                "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
5319            ));
5320        }
5321        let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
5322        for sub in &self.subs {
5323            let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
5324            let complete =
5325                sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
5326            let state = match sub.state {
5327                SubState::Revoked => "revoked",
5328                SubState::Failed => "failed",
5329                SubState::Active if reset => "reset",
5330                SubState::Active if complete => "complete",
5331                SubState::Active => "bootstrapping",
5332            };
5333            subscriptions.insert(
5334                sub.id.clone(),
5335                DiagnosticSubscription {
5336                    id: sub.id.clone(),
5337                    table: sub.table.clone(),
5338                    state: state.to_owned(),
5339                    complete,
5340                    cursor: Some(sub.cursor),
5341                    reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
5342                },
5343            );
5344        }
5345        for expected in &request.expected_subscriptions {
5346            if expected.id.is_empty() || expected.table.is_empty() {
5347                return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
5348            }
5349            if subscriptions
5350                .get(&expected.id)
5351                .is_some_and(|registered| registered.table != expected.table)
5352            {
5353                subscriptions.insert(
5354                    expected.id.clone(),
5355                    DiagnosticSubscription {
5356                        id: expected.id.clone(),
5357                        table: expected.table.clone(),
5358                        state: "failed".to_owned(),
5359                        complete: false,
5360                        cursor: None,
5361                        reason_code: Some("client.subscription_intent_mismatch".to_owned()),
5362                    },
5363                );
5364            } else {
5365                subscriptions.entry(expected.id.clone()).or_insert_with(|| {
5366                    DiagnosticSubscription {
5367                        id: expected.id.clone(),
5368                        table: expected.table.clone(),
5369                        state: "unregistered".to_owned(),
5370                        complete: false,
5371                        cursor: None,
5372                        reason_code: None,
5373                    }
5374                });
5375            }
5376        }
5377        let mut ordered_subscriptions = Vec::new();
5378        let mut included = BTreeSet::new();
5379        for expected in &request.expected_subscriptions {
5380            if included.insert(expected.id.clone()) {
5381                if let Some(subscription) = subscriptions.get(&expected.id) {
5382                    ordered_subscriptions.push(subscription.clone());
5383                }
5384            }
5385        }
5386        for (id, subscription) in &subscriptions {
5387            if included.insert(id.clone()) {
5388                ordered_subscriptions.push(subscription.clone());
5389            }
5390        }
5391        let subscriptions_truncated =
5392            ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
5393        ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
5394        let captured_at_ms = self.clock_now_ms();
5395        let lease = if let Some(error_code) = self
5396            .lease_state
5397            .as_ref()
5398            .and_then(|state| state.error_code.clone())
5399        {
5400            ClientDiagnosticsLease {
5401                state: "stopped".to_owned(),
5402                expires_at_ms: self
5403                    .lease_state
5404                    .as_ref()
5405                    .and_then(|state| state.expires_at_ms),
5406                error_code: Some(Self::diagnostic_code(&error_code)),
5407            }
5408        } else if let Some(expires_at_ms) = self
5409            .lease_state
5410            .as_ref()
5411            .and_then(|state| state.expires_at_ms)
5412        {
5413            ClientDiagnosticsLease {
5414                state: if expires_at_ms <= captured_at_ms {
5415                    "expired".to_owned()
5416                } else {
5417                    "active".to_owned()
5418                },
5419                expires_at_ms: Some(expires_at_ms),
5420                error_code: None,
5421            }
5422        } else {
5423            ClientDiagnosticsLease {
5424                state: "none".to_owned(),
5425                expires_at_ms: None,
5426                error_code: None,
5427            }
5428        };
5429        let connectivity = match self.last_round.as_ref() {
5430            Some(round) if round.status == "succeeded" => "online",
5431            Some(round)
5432                if round.status == "failed"
5433                    && round
5434                        .error_code
5435                        .as_deref()
5436                        .is_some_and(Self::retryable_transport_code) =>
5437            {
5438                "offline"
5439            }
5440            _ => "unknown",
5441        };
5442        Ok(ClientDiagnosticsSnapshot {
5443            version: CLIENT_DIAGNOSTICS_VERSION,
5444            captured_at_ms,
5445            host: ClientDiagnosticsHost {
5446                kind: "direct".to_owned(),
5447                role: "single".to_owned(),
5448                connectivity: connectivity.to_owned(),
5449                realtime: if self.realtime_connected {
5450                    "connected".to_owned()
5451                } else {
5452                    "disconnected".to_owned()
5453                },
5454            },
5455            security_lifecycle: self.security_lifecycle().to_owned(),
5456            schema: ClientDiagnosticsSchema {
5457                current_version: self.schema.version,
5458                upgrading: self.upgrading,
5459                required_version: self
5460                    .schema_floor
5461                    .as_ref()
5462                    .and_then(|floor| floor.required_schema_version),
5463                latest_version: self
5464                    .schema_floor
5465                    .as_ref()
5466                    .and_then(|floor| floor.latest_schema_version),
5467            },
5468            replica: ClientDiagnosticsReplica {
5469                local_revision: self.local_revision().to_string(),
5470                sync_needed: self.sync_needed,
5471                pending_outbox: self.outbox.len(),
5472            },
5473            lease,
5474            subscriptions: ordered_subscriptions,
5475            subscriptions_truncated,
5476            last_round: self.last_round.clone(),
5477            last_change: self.last_change.clone(),
5478            storage: self.diagnostics_storage(),
5479        })
5480    }
5481
5482    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
5483        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
5484            let page_count: i64 = self
5485                .conn
5486                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
5487            let page_size: i64 = self
5488                .conn
5489                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
5490            // Reuse compiled aggregates; every invocation still reads current storage.
5491            let outbox_bytes: i64 = self
5492                .conn
5493                .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
5494                .query_row([], |row| row.get(0))?;
5495            let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
5496                "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
5497            )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
5498            let blob_bytes = if self.schema_has_blobs() {
5499                self.conn
5500                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
5501                    .query_row([], |row| row.get(0))?
5502            } else {
5503                0
5504            };
5505            let pressure = self
5506                .limits
5507                .blob_cache_max_bytes
5508                .is_some_and(|limit| blob_bytes > limit);
5509            Ok(ClientDiagnosticsStorage {
5510                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
5511                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
5512                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
5513                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
5514                retained_outcome_entries: Some(outcome_entries.max(0)),
5515                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
5516                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
5517            })
5518        };
5519        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
5520            status: "unreadable".to_owned(),
5521            database_bytes_approx: None,
5522            pending_outbox_bytes_approx: None,
5523            retained_outcome_bytes_approx: None,
5524            retained_outcome_entries: None,
5525            blob_cache_bytes_approx: None,
5526            pressure_reason_code: None,
5527        })
5528    }
5529
5530    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
5531        self.change_queue.drain(..).collect()
5532    }
5533
5534    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
5535        self.sync_intent_queue.drain(..).collect()
5536    }
5537
5538    fn schedule_background_retry(&mut self) {
5539        self.sync_intent_queue.push_back(SyncIntent::Background {
5540            delay_ms: self.retry_delay_ms,
5541        });
5542        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
5543    }
5544
5545    fn reset_background_retry(&mut self) {
5546        self.retry_delay_ms = 250;
5547    }
5548
5549    fn retryable_transport_code(code: &str) -> bool {
5550        code == "transport.failed"
5551            || code == "transport.unavailable"
5552            || code == "sync.transport_failed"
5553    }
5554
5555    fn diagnostic_code(code: &str) -> String {
5556        let valid = !code.is_empty()
5557            && code.len() <= 96
5558            && code.contains('.')
5559            && code
5560                .bytes()
5561                .next()
5562                .is_some_and(|byte| byte.is_ascii_lowercase())
5563            && code.bytes().all(|byte| {
5564                byte.is_ascii_lowercase()
5565                    || byte.is_ascii_digit()
5566                    || matches!(byte, b'.' | b'_' | b'-')
5567            });
5568        if valid {
5569            code.to_owned()
5570        } else {
5571            "client.unknown_failure".to_owned()
5572        }
5573    }
5574
5575    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
5576        if self.sync_needed != value {
5577            if self.begin_observation("syncular_status").is_ok() {
5578                self.sync_needed = value;
5579                let batch = ChangeAccumulator {
5580                    status: true,
5581                    ..ChangeAccumulator::default()
5582                };
5583                if self.finish_observation("syncular_status", batch).is_err() {
5584                    self.rollback_observation("syncular_status");
5585                }
5586            } else {
5587                self.sync_needed = value;
5588            }
5589        }
5590        if value && interactive {
5591            self.sync_intent_queue.push_back(SyncIntent::Interactive);
5592        }
5593    }
5594
5595    fn begin_observation(&self, name: &str) -> Result<(), String> {
5596        self.conn
5597            .execute_batch(&format!("SAVEPOINT {name}"))
5598            .map_err(|error| error.to_string())
5599    }
5600
5601    fn rollback_observation(&self, name: &str) {
5602        let _ = self
5603            .conn
5604            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"));
5605    }
5606
5607    fn finish_observation(&mut self, name: &str, batch: ChangeAccumulator) -> Result<(), String> {
5608        #[cfg(feature = "bench-internals")]
5609        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
5610        if !batch.touched() {
5611            self.conn
5612                .execute_batch(&format!("RELEASE {name}"))
5613                .map_err(|error| error.to_string())?;
5614            return Ok(());
5615        }
5616        let revision = self
5617            .local_revision()
5618            .checked_add(1)
5619            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
5620        self.conn
5621            .execute(
5622                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
5623                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
5624            )
5625            .map_err(|error| error.to_string())?;
5626        let status = batch.status.then(|| self.status_snapshot());
5627        let event = ClientChangeBatch {
5628            revision: revision.to_string(),
5629            tables: batch
5630                .tables
5631                .into_iter()
5632                .map(|(table, scope_keys)| TableChange {
5633                    table,
5634                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
5635                })
5636                .collect(),
5637            windows: batch
5638                .windows
5639                .into_iter()
5640                .map(|((base_key, table), units)| WindowChange {
5641                    base_key,
5642                    table,
5643                    units: units.into_iter().collect(),
5644                })
5645                .collect(),
5646            status,
5647            conflicts_changed: batch.conflicts,
5648            rejections_changed: batch.rejections,
5649            outcomes_changed: batch.outcomes,
5650        };
5651        self.conn
5652            .execute_batch(&format!("RELEASE {name}"))
5653            .map_err(|error| error.to_string())?;
5654        let mut diagnostic_tables = event
5655            .tables
5656            .iter()
5657            .map(|entry| entry.table.clone())
5658            .collect::<BTreeSet<_>>()
5659            .into_iter()
5660            .collect::<Vec<_>>();
5661        let mut diagnostic_windows = event
5662            .windows
5663            .iter()
5664            .map(|entry| entry.table.clone())
5665            .collect::<BTreeSet<_>>()
5666            .into_iter()
5667            .collect::<Vec<_>>();
5668        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
5669            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
5670        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
5671        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
5672        self.last_change = Some(DiagnosticLastChange {
5673            revision: event.revision.clone(),
5674            recorded_at_ms: self.clock_now_ms(),
5675            tables: diagnostic_tables,
5676            windows: diagnostic_windows,
5677            domains_truncated,
5678            status_changed: event.status.is_some(),
5679            conflicts_changed: event.conflicts_changed,
5680            rejections_changed: event.rejections_changed,
5681            outcomes_changed: event.outcomes_changed,
5682        });
5683        self.change_queue.push_back(event);
5684        Ok(())
5685    }
5686
5687    fn record_scope_map(
5688        &self,
5689        batch: &mut ChangeAccumulator,
5690        table_name: &str,
5691        scopes: &[(String, Vec<String>)],
5692    ) {
5693        let Some(table) = self.schema.table(table_name) else {
5694            return;
5695        };
5696        for (variable, values) in scopes {
5697            let Some(scope) = table
5698                .scope_variables
5699                .iter()
5700                .find(|scope| &scope.variable == variable)
5701            else {
5702                continue;
5703            };
5704            for value in values {
5705                batch.scope(table_name, format!("{}:{value}", scope.prefix));
5706            }
5707        }
5708    }
5709
5710    /// Record a row's current scope keys from the base or visible table.
5711    fn record_row_scopes(
5712        &self,
5713        batch: &mut ChangeAccumulator,
5714        table_name: &str,
5715        row_id: &str,
5716        base: bool,
5717    ) -> bool {
5718        let Some(table) = self.schema.table(table_name) else {
5719            return false;
5720        };
5721        if table.scope_variables.is_empty() {
5722            return false;
5723        }
5724        let columns = table
5725            .scope_variables
5726            .iter()
5727            .map(|scope| quote_ident(&scope.column))
5728            .collect::<Vec<_>>()
5729            .join(", ");
5730        let full_table = if base {
5731            base_table(table_name)
5732        } else {
5733            visible_table(table_name)
5734        };
5735        let sql = format!(
5736            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
5737            row_id_predicate(table)
5738        );
5739        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
5740            return false;
5741        };
5742        let values = stmt.query_row(rusqlite::params![row_id], |row| {
5743            let mut values = Vec::with_capacity(table.scope_variables.len());
5744            for index in 0..table.scope_variables.len() {
5745                values.push(row.get::<_, Option<String>>(index)?);
5746            }
5747            Ok(values)
5748        });
5749        let Ok(values) = values else {
5750            return false;
5751        };
5752        let mut recorded = false;
5753        for (scope, value) in table.scope_variables.iter().zip(values) {
5754            if let Some(value) = value {
5755                batch.scope(table_name, format!("{}:{value}", scope.prefix));
5756                recorded = true;
5757            }
5758        }
5759        recorded
5760    }
5761
5762    fn record_commit_changes(
5763        &self,
5764        batch: &mut ChangeAccumulator,
5765        tables: &[String],
5766        changes: &[ssp2::model::Change],
5767    ) {
5768        #[cfg(feature = "bench-internals")]
5769        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
5770        for change in changes {
5771            let Some(table_name) = tables.get(change.table_index as usize) else {
5772                continue;
5773            };
5774            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
5775            if let Some(table) = self.schema.table(table_name) {
5776                for (variable, value) in &change.scopes {
5777                    if let Some(scope) = table
5778                        .scope_variables
5779                        .iter()
5780                        .find(|scope| &scope.variable == variable)
5781                    {
5782                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
5783                        precise = true;
5784                    }
5785                }
5786            }
5787            if !precise {
5788                batch.table(table_name);
5789            }
5790        }
5791    }
5792
5793    fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
5794        if effective.is_empty() {
5795            return false;
5796        }
5797        let Some(table) = self.schema.table(table_name) else {
5798            return false;
5799        };
5800        let mut clauses = Vec::new();
5801        let mut params = Vec::new();
5802        for (variable, values) in effective {
5803            let Some(column) = table.scope_column(variable) else {
5804                return false;
5805            };
5806            if values.is_empty() {
5807                return false;
5808            }
5809            let placeholders = values
5810                .iter()
5811                .map(|value| {
5812                    params.push(SqlValue::Text(value.clone()));
5813                    "?"
5814                })
5815                .collect::<Vec<_>>()
5816                .join(", ");
5817            clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
5818        }
5819        let sql = format!(
5820            "SELECT 1 FROM {} WHERE {} LIMIT 1",
5821            base_table(table_name),
5822            clauses.join(" AND ")
5823        );
5824        self.conn
5825            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
5826            .is_ok()
5827    }
5828
5829    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
5830    pub fn upgrading(&self) -> bool {
5831        self.upgrading
5832    }
5833
5834    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
5835    /// keeping this client's local database (identity, outbox, tables). The
5836    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
5837    /// version changed. Mirrors the TS client's boot-time detection —
5838    /// the Rust core has no persistent restart, so recreation IS the boot.
5839    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
5840        let new_schema = parse_schema_json(schema_json)?;
5841        let marker: Option<i32> = self
5842            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
5843            .and_then(|v| v.parse().ok());
5844        self.schema = new_schema;
5845        self.create_bookkeeping_tables()?;
5846        if marker != Some(self.schema.version) {
5847            self.run_schema_reset()?;
5848        }
5849        self.prune_unknown_subscriptions()?;
5850        // The conformance recreate is the in-memory equivalent of reopening a
5851        // durable client. Apply the same startup catch-up contract even when
5852        // the schema itself did not change.
5853        self.enqueue_startup_sync_if_needed()?;
5854        Ok(())
5855    }
5856
5857    fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
5858        let startup_work = !self.stopped
5859            && (!self.outbox.is_empty()
5860                || !self.load_pending_evictions()?.is_empty()
5861                || self.subs.iter().any(|sub| sub.state == SubState::Active));
5862        if startup_work {
5863            self.sync_needed = true;
5864            self.sync_intent_queue.push_back(SyncIntent::Interactive);
5865        }
5866        Ok(())
5867    }
5868
5869    /// A native client persists schema-floor stops across process restarts.
5870    /// Once the running app already satisfies that floor, the persisted stop
5871    /// is only stale evidence from an older server round (for example, the
5872    /// server was restarted after catching up to an app that was ahead).
5873    /// Clear it and let the normal startup pull re-negotiate. If the server is
5874    /// still incompatible it will return the floor again in that first round.
5875    fn clear_satisfied_persisted_schema_floor(&mut self) {
5876        let satisfied = self
5877            .schema_floor
5878            .as_ref()
5879            .and_then(|floor| floor.required_schema_version)
5880            .is_some_and(|required| self.schema.version >= required);
5881        if !satisfied {
5882            return;
5883        }
5884        self.schema_floor = None;
5885        self.stopped = false;
5886        self.delete_meta(SCHEMA_FLOOR_KEY);
5887    }
5888
5889    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
5890    /// and leaseState. Drops/recreates every synced table from the new
5891    /// schema, resets subscription sync-state (keeping registrations), clears
5892    /// the schema-floor stop state, rewrites the marker, drops outbox commits
5893    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
5894    fn run_schema_reset(&mut self) -> Result<(), String> {
5895        self.begin_observation("syncular_schema_reset")?;
5896        let mut batch = ChangeAccumulator::default();
5897        let result = self.run_schema_reset_observed(&mut batch, true);
5898        if let Err(error) = result {
5899            self.rollback_observation("syncular_schema_reset");
5900            return Err(error);
5901        }
5902        if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
5903            self.rollback_observation("syncular_schema_reset");
5904            return Err(error);
5905        }
5906        Ok(())
5907    }
5908
5909    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
5910        let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
5911        self.begin_observation("syncular_log_epoch_reset")?;
5912        let mut batch = ChangeAccumulator::default();
5913        let result = self.run_schema_reset_observed(&mut batch, false);
5914        if let Err(error) = result {
5915            self.rollback_observation("syncular_log_epoch_reset");
5916            return Err(error);
5917        }
5918        self.set_meta(LOG_EPOCH_KEY, log_epoch);
5919        self.sync_needed = true;
5920        batch.status = true;
5921        if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
5922            self.rollback_observation("syncular_log_epoch_reset");
5923            return Err(error);
5924        }
5925        self.sync_intent_queue.push_back(SyncIntent::Interactive);
5926        Ok(resets)
5927    }
5928
5929    fn run_schema_reset_observed(
5930        &mut self,
5931        batch: &mut ChangeAccumulator,
5932        drop_incompatible: bool,
5933    ) -> Result<(), String> {
5934        self.upgrading = true;
5935        batch.status = true;
5936        for table in &self.schema.tables {
5937            batch.table(&table.name);
5938        }
5939        for (base_key, unit, table) in self.load_registered_window_units() {
5940            batch.window(&base_key, &table, &unit);
5941        }
5942        // The per-table insert SQL is derived from the OLD column lists.
5943        self.insert_sql.borrow_mut().clear();
5944        self.overlay_dirty.set(true);
5945        // Drop every synced table (base + visible) that currently exists —
5946        // discovered from sqlite_master so a bump that adds/removes tables is
5947        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
5948        // and the blob cache) are preserved; base tables are `_syncular_base_*`
5949        // so they are matched explicitly, not by the bookkeeping filter.
5950        // Drop virtual tables first so SQLite removes every FTS shadow table
5951        // atomically instead of the generic discovery tearing it apart.
5952        let virtual_tables: Vec<String> = {
5953            let mut stmt = self
5954                .conn
5955                .prepare(
5956                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
5957                )
5958                .map_err(|e| e.to_string())?;
5959            let rows = stmt
5960                .query_map([], |row| row.get::<_, String>(0))
5961                .map_err(|e| e.to_string())?;
5962            rows.filter_map(Result::ok)
5963                .filter(|name| is_synced_table_name(name))
5964                .collect()
5965        };
5966        for name in virtual_tables {
5967            self.conn
5968                .execute_batch(&format!(
5969                    "DROP TABLE IF EXISTS {}",
5970                    quote_ident(&format!("_syncular_fts_{name}"))
5971                ))
5972                .map_err(|e| e.to_string())?;
5973            self.conn
5974                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
5975                .map_err(|e| e.to_string())?;
5976        }
5977        let existing: Vec<String> = {
5978            let mut stmt = self
5979                .conn
5980                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
5981                .map_err(|e| e.to_string())?;
5982            let rows = stmt
5983                .query_map([], |row| row.get::<_, String>(0))
5984                .map_err(|e| e.to_string())?;
5985            rows.filter_map(Result::ok)
5986                .filter(|name| is_synced_table_name(name))
5987                .collect()
5988        };
5989        for name in &existing {
5990            if let Some(table) = name.strip_prefix("_syncular_base_") {
5991                batch.table(table);
5992            } else if !name.starts_with("_syncular_") {
5993                batch.table(name);
5994            }
5995        }
5996        for name in existing {
5997            self.conn
5998                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
5999                .map_err(|e| e.to_string())?;
6000        }
6001        // Recreate the synced tables from the NEW schema.
6002        self.create_synced_tables()?;
6003        // Reset every subscription's sync-state, keeping the registration.
6004        for sub in &mut self.subs {
6005            sub.cursor = -1;
6006            sub.bootstrap_state = None;
6007            sub.effective = None;
6008            sub.state = SubState::Active;
6009            sub.reason_code = None;
6010            sub.synced_once = false;
6011        }
6012        let subs = self.subs.clone();
6013        for sub in &subs {
6014            self.persist_sub(sub)?;
6015        }
6016        // The stop state is over: this client now ships a servable schema.
6017        self.stopped = false;
6018        self.schema_floor = None;
6019        self.delete_meta(SCHEMA_FLOOR_KEY);
6020        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
6021        self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
6022        // §7.4.4: drop outbox commits that cannot re-encode under the new
6023        // schema (a referenced column/table the bump removed), surfacing each
6024        // as a `sync.outbox_incompatible` rejection.
6025        if drop_incompatible && self.drop_incompatible_outbox()? {
6026            batch.rejections = true;
6027            batch.status = true;
6028            batch.outcomes = true;
6029        }
6030        // Re-apply the surviving outbox optimistically over the empty tables.
6031        self.rebuild_overlay();
6032        Ok(())
6033    }
6034
6035    /// §7.4.4: a persisted upsert whose values reference a column the current
6036    /// schema lacks (or a removed table) cannot be encoded. Drop the commit
6037    /// and raise a client-local `sync.outbox_incompatible` rejection.
6038    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
6039        let schema = &self.schema;
6040        let incompatible = self
6041            .outbox
6042            .iter()
6043            .filter(|commit| {
6044                commit.ops.iter().any(|op| {
6045                    if !op.upsert {
6046                        return false;
6047                    }
6048                    match schema.table(&op.table) {
6049                        None => true,
6050                        Some(table) => op.values.as_ref().is_some_and(|values| {
6051                            values
6052                                .keys()
6053                                .any(|key| !table.columns.iter().any(|c| &c.name == key))
6054                        }),
6055                    }
6056                })
6057            })
6058            .cloned()
6059            .collect::<Vec<_>>();
6060        if incompatible.is_empty() {
6061            return Ok(false);
6062        }
6063        let mut rejections = Vec::new();
6064        for commit in &incompatible {
6065            let results = commit
6066                .ops
6067                .iter()
6068                .enumerate()
6069                .map(|(op_index, operation)| {
6070                    let rejection = RejectionRecord {
6071                        client_commit_id: commit.client_commit_id.clone(),
6072                        op_index: op_index as i32,
6073                        code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
6074                        message: "the persisted commit cannot encode under the current schema"
6075                            .to_owned(),
6076                        retryable: false,
6077                        details: None,
6078                        operation: Some(CommitOperation::from(operation)),
6079                    };
6080                    rejections.push(rejection.clone());
6081                    CommitOperationOutcome::Error { rejection }
6082                })
6083                .collect::<Vec<_>>();
6084            self.persist_commit_outcome(
6085                &commit.client_commit_id,
6086                CommitOutcomeStatus::Rejected,
6087                &results,
6088                Some(&commit.ops),
6089            )?;
6090            self.delete_outbox_persisted(&commit.client_commit_id)?;
6091        }
6092        self.prune_commit_outcomes()?;
6093        let incompatible_ids = incompatible
6094            .iter()
6095            .map(|commit| commit.client_commit_id.as_str())
6096            .collect::<BTreeSet<_>>();
6097        self.outbox
6098            .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
6099        self.rejections.extend(rejections);
6100        Ok(true)
6101    }
6102
6103    /// §7.4.3: (re)create the base + visible table pair for every synced
6104    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
6105    fn create_synced_tables(&self) -> Result<(), String> {
6106        for table in &self.schema.tables {
6107            // The base half + the visible half form the synced-table pair. An
6108            // index name is global in SQLite, so the base half's indexes are
6109            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
6110            for (full, index_prefix) in [
6111                (base_table(&table.name), "_syncular_base_"),
6112                (visible_table(&table.name), ""),
6113            ] {
6114                let mut cols: Vec<String> =
6115                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
6116                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
6117                let sql = format!(
6118                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
6119                    cols.join(", "),
6120                    quote_ident(&table.primary_key)
6121                );
6122                self.conn.execute(&sql, []).map_err(|e| e.to_string())?;
6123                // Local secondary indexes (CREATE INDEX subset). Created on
6124                // both halves so mirror reads hit an index on either. Runs on
6125                // both the initial create and the §7.4.3 reset recreate path.
6126                for index in &table.indexes {
6127                    let unique = if index.unique { "UNIQUE " } else { "" };
6128                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
6129                    let cols_sql = index
6130                        .columns
6131                        .iter()
6132                        .map(|c| quote_ident(c))
6133                        .collect::<Vec<_>>()
6134                        .join(", ");
6135                    let index_sql = format!(
6136                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
6137                    );
6138                    self.conn
6139                        .execute(&index_sql, [])
6140                        .map_err(|e| e.to_string())?;
6141                }
6142            }
6143        }
6144        // FTS exists only on the visible half. It is a local search
6145        // projection, never a synced/base table or a wire-schema member.
6146        for table in &self.schema.tables {
6147            for index in &table.fts_indexes {
6148                self.create_fts_projection(table, index)?;
6149            }
6150        }
6151        Ok(())
6152    }
6153
6154    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
6155        let count: i64 = self
6156            .conn
6157            .query_row(
6158                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
6159                rusqlite::params![index.name],
6160                |row| row.get(0),
6161            )
6162            .map_err(|e| e.to_string())?;
6163        Ok(count > 0)
6164    }
6165
6166    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
6167        for suffix in ["ai", "ad", "au"] {
6168            self.conn
6169                .execute(
6170                    &format!(
6171                        "DROP TRIGGER IF EXISTS {}",
6172                        quote_ident(&format!("{}_{suffix}", index.name))
6173                    ),
6174                    [],
6175                )
6176                .map_err(|e| e.to_string())?;
6177        }
6178        Ok(())
6179    }
6180
6181    fn create_fts_triggers(
6182        &self,
6183        table: &TableSchema,
6184        index: &FtsIndexSchema,
6185    ) -> Result<(), String> {
6186        let fts = quote_ident(&index.name);
6187        let source = visible_table(&table.name);
6188        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
6189        let pk = quote_ident(&table.primary_key);
6190        let projection_columns = std::iter::once(source_id.clone())
6191            .chain(index.columns.iter().map(|column| quote_ident(column)))
6192            .collect::<Vec<_>>()
6193            .join(", ");
6194        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
6195            .chain(
6196                index
6197                    .columns
6198                    .iter()
6199                    .map(|column| format!("new.{}", quote_ident(column))),
6200            )
6201            .collect::<Vec<_>>()
6202            .join(", ");
6203        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
6204        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)");
6205        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)");
6206        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})");
6207        let bi = quote_ident(&format!("{}_bi", index.name));
6208        let ai = quote_ident(&format!("{}_ai", index.name));
6209        let ad = quote_ident(&format!("{}_ad", index.name));
6210        let au = quote_ident(&format!("{}_au", index.name));
6211        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
6212        let sql = format!(
6213            "DROP TRIGGER IF EXISTS {bi};
6214             DROP TRIGGER IF EXISTS {ai};
6215             DROP TRIGGER IF EXISTS {ad};
6216             DROP TRIGGER IF EXISTS {au};
6217             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
6218             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
6219             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
6220             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
6221        );
6222        self.conn.execute_batch(&sql).map_err(|e| e.to_string())
6223    }
6224
6225    fn rebuild_fts_projection(
6226        &self,
6227        table: &TableSchema,
6228        index: &FtsIndexSchema,
6229    ) -> Result<(), String> {
6230        let fts = quote_ident(&index.name);
6231        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
6232        let pk = quote_ident(&table.primary_key);
6233        let indexed_columns = index
6234            .columns
6235            .iter()
6236            .map(|column| quote_ident(column))
6237            .collect::<Vec<_>>();
6238        let projection_columns = std::iter::once(source_id.clone())
6239            .chain(indexed_columns.iter().cloned())
6240            .collect::<Vec<_>>()
6241            .join(", ");
6242        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
6243        let sql = format!(
6244            "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};",
6245            columns = indexed_columns.join(", "),
6246            source = visible_table(&table.name),
6247        );
6248        self.conn.execute_batch(&sql).map_err(|e| e.to_string())
6249    }
6250
6251    fn create_fts_projection(
6252        &self,
6253        table: &TableSchema,
6254        index: &FtsIndexSchema,
6255    ) -> Result<(), String> {
6256        self.begin_observation("syncular_fts_schema")?;
6257        let result = (|| {
6258            let existed = self.fts_projection_exists(index)?;
6259            let tokenizer = index.tokenize.replace('\'', "''");
6260            let columns = index
6261                .columns
6262                .iter()
6263                .map(|column| quote_ident(column))
6264                .collect::<Vec<_>>()
6265                .join(", ");
6266            let sql = format!(
6267            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
6268            fts = quote_ident(&index.name),
6269            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
6270        );
6271            self.conn.execute(&sql, []).map_err(|error| {
6272                format!(
6273                    "cannot create local FTS5 projection {:?}: {error}",
6274                    index.name
6275                )
6276            })?;
6277            let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
6278            let mapped: bool = self
6279                .conn
6280                .query_row(
6281                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
6282                    [format!("_syncular_fts_{}", index.name)],
6283                    |row| row.get(0),
6284                )
6285                .map_err(|e| e.to_string())?;
6286            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())?;
6287            if !mapped && existed {
6288                self.conn
6289                    .execute_batch(&format!(
6290                        "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
6291                        quote_ident(FTS_SOURCE_ID_COLUMN),
6292                        quote_ident(&index.name)
6293                    ))
6294                    .map_err(|e| e.to_string())?;
6295            }
6296            self.create_fts_triggers(table, index)?;
6297            if !existed {
6298                self.rebuild_fts_projection(table, index)?;
6299            }
6300            Ok(())
6301        })();
6302        if let Err(error) = result {
6303            self.rollback_observation("syncular_fts_schema");
6304            return Err(error);
6305        }
6306        if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
6307            self.rollback_observation("syncular_fts_schema");
6308            return Err(error.to_string());
6309        }
6310        Ok(())
6311    }
6312
6313    // -- persistence write-through --------------------------------------------
6314
6315    fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
6316        let state = serde_json::json!({
6317            "requested": scope_map_to_json(&sub.requested),
6318            "params": sub.params,
6319            "cursor": sub.cursor,
6320            "bootstrapState": sub.bootstrap_state,
6321            "status": sub.state.name(),
6322            "reasonCode": sub.reason_code,
6323            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
6324            "syncedOnce": sub.synced_once,
6325        });
6326        self.conn.execute(
6327            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
6328            rusqlite::params![sub.id, sub.table, state.to_string()],
6329        ).map(|_| ()).map_err(|error| error.to_string())
6330    }
6331
6332    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
6333        let ops: Vec<Value> = commit
6334            .ops
6335            .iter()
6336            .map(|op| {
6337                serde_json::json!({
6338                    "op": if op.upsert { "upsert" } else { "delete" },
6339                    "table": op.table,
6340                    "rowId": op.row_id,
6341                    "baseVersion": op.base_version,
6342                    "values": op.values.clone().map(Value::Object),
6343                    "changedFields": op.changed_fields,
6344                })
6345            })
6346            .collect();
6347        self.conn
6348            .execute(
6349                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
6350                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
6351            )
6352            .map_err(|error| error.to_string())?;
6353        for operation in &commit.ops {
6354            let Some(values) = &operation.values else {
6355                continue;
6356            };
6357            let Some(table) = self.schema.table(&operation.table) else {
6358                continue;
6359            };
6360            for column in table
6361                .columns
6362                .iter()
6363                .filter(|column| column.ty == ColumnType::BlobRef)
6364            {
6365                let Some(Value::String(raw)) = values.get(&column.name) else {
6366                    continue;
6367                };
6368                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
6369                    continue;
6370                };
6371                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
6372                    continue;
6373                };
6374                self.conn
6375                    .execute(
6376                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
6377                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
6378                        rusqlite::params![commit.client_commit_id, blob_id],
6379                    )
6380                    .map_err(|error| error.to_string())?;
6381            }
6382        }
6383        Ok(())
6384    }
6385
6386    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
6387        self.conn
6388            .execute(
6389                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
6390                rusqlite::params![client_commit_id],
6391            )
6392            .map_err(|error| error.to_string())?;
6393        self.conn
6394            .execute(
6395                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
6396                rusqlite::params![client_commit_id],
6397            )
6398            .map(|_| ())
6399            .map_err(|error| error.to_string())
6400    }
6401
6402    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
6403        match status {
6404            CommitOutcomeStatus::Applied => "applied",
6405            CommitOutcomeStatus::Cached => "cached",
6406            CommitOutcomeStatus::Conflict => "conflict",
6407            CommitOutcomeStatus::Rejected => "rejected",
6408        }
6409    }
6410
6411    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
6412        match resolution {
6413            CommitOutcomeResolution::Active => "active",
6414            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
6415            CommitOutcomeResolution::Superseded => "superseded",
6416            CommitOutcomeResolution::Dismissed => "dismissed",
6417        }
6418    }
6419
6420    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
6421        match value {
6422            "applied" => Ok(CommitOutcomeStatus::Applied),
6423            "cached" => Ok(CommitOutcomeStatus::Cached),
6424            "conflict" => Ok(CommitOutcomeStatus::Conflict),
6425            "rejected" => Ok(CommitOutcomeStatus::Rejected),
6426            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
6427        }
6428    }
6429
6430    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
6431        match value {
6432            "active" => Ok(CommitOutcomeResolution::Active),
6433            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
6434            "superseded" => Ok(CommitOutcomeResolution::Superseded),
6435            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
6436            _ => Err(format!(
6437                "invalid persisted commit outcome resolution {value:?}"
6438            )),
6439        }
6440    }
6441
6442    fn persist_commit_outcome(
6443        &self,
6444        client_commit_id: &str,
6445        status: CommitOutcomeStatus,
6446        results: &[CommitOperationOutcome],
6447        operations: Option<&[OutboxOp]>,
6448    ) -> Result<(), String> {
6449        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
6450        let operations_json = operations
6451            .map(|items| {
6452                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
6453            })
6454            .transpose()
6455            .map_err(|error| error.to_string())?;
6456        self.conn
6457            .execute(
6458                "INSERT INTO _syncular_commit_outcomes (
6459                   client_commit_id, status, recorded_at_ms, results_json,
6460                   operations_json, resolution
6461                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
6462                rusqlite::params![
6463                    client_commit_id,
6464                    Self::outcome_status_name(status),
6465                    self.clock_now_ms(),
6466                    results_json,
6467                    operations_json
6468                ],
6469            )
6470            .map(|_| ())
6471            .map_err(|error| error.to_string())
6472    }
6473
6474    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
6475        let StoredCommitOutcomeRow {
6476            sequence,
6477            client_commit_id,
6478            status,
6479            recorded_at_ms,
6480            results_json,
6481            operations_json,
6482            resolution,
6483            resolved_at_ms,
6484            replacement_client_commit_id,
6485        } = row;
6486        Ok(CommitOutcome {
6487            sequence,
6488            client_commit_id,
6489            status: Self::parse_outcome_status(&status)?,
6490            recorded_at_ms,
6491            results: serde_json::from_str(&results_json)
6492                .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
6493            operations: operations_json
6494                .map(|value| {
6495                    serde_json::from_str(&value).map_err(|error| {
6496                        format!("invalid persisted commit outcome operations: {error}")
6497                    })
6498                })
6499                .transpose()?,
6500            resolution: Self::parse_outcome_resolution(&resolution)?,
6501            resolved_at_ms,
6502            replacement_client_commit_id,
6503        })
6504    }
6505
6506    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
6507        let row = self
6508            .conn
6509            .query_row(
6510                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
6511                        resolution, resolved_at_ms, replacement_client_commit_id
6512                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
6513                rusqlite::params![client_commit_id],
6514                |row| {
6515                    Ok(StoredCommitOutcomeRow {
6516                        sequence: row.get(0)?,
6517                        client_commit_id: row.get(1)?,
6518                        status: row.get(2)?,
6519                        recorded_at_ms: row.get(3)?,
6520                        results_json: row.get(4)?,
6521                        operations_json: row.get(5)?,
6522                        resolution: row.get(6)?,
6523                        resolved_at_ms: row.get(7)?,
6524                        replacement_client_commit_id: row.get(8)?,
6525                    })
6526                },
6527            )
6528            .optional()
6529            .map_err(|error| error.to_string())?;
6530        row.map(Self::outcome_from_row).transpose()
6531    }
6532
6533    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
6534        if query.limit == Some(0) {
6535            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
6536        }
6537        let mut sql = String::from(
6538            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
6539                    resolution, resolved_at_ms, replacement_client_commit_id
6540               FROM _syncular_commit_outcomes",
6541        );
6542        if query.active_only {
6543            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
6544        }
6545        sql.push_str(" ORDER BY seq DESC");
6546        if let Some(limit) = query.limit {
6547            sql.push_str(&format!(" LIMIT {limit}"));
6548        }
6549        let mut stmt = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
6550        let rows = stmt
6551            .query_map([], |row| {
6552                Ok(StoredCommitOutcomeRow {
6553                    sequence: row.get(0)?,
6554                    client_commit_id: row.get(1)?,
6555                    status: row.get(2)?,
6556                    recorded_at_ms: row.get(3)?,
6557                    results_json: row.get(4)?,
6558                    operations_json: row.get(5)?,
6559                    resolution: row.get(6)?,
6560                    resolved_at_ms: row.get(7)?,
6561                    replacement_client_commit_id: row.get(8)?,
6562                })
6563            })
6564            .map_err(|error| error.to_string())?;
6565        let mut outcomes = Vec::new();
6566        for row in rows {
6567            outcomes.push(Self::outcome_from_row(
6568                row.map_err(|error| error.to_string())?,
6569            )?);
6570        }
6571        Ok(outcomes)
6572    }
6573
6574    fn prune_commit_outcomes(&self) -> Result<(), String> {
6575        #[cfg(test)]
6576        self.outcome_prune_count
6577            .set(self.outcome_prune_count.get() + 1);
6578        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
6579        let count = self
6580            .conn
6581            .query_row(
6582                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
6583                [],
6584                |row| row.get::<_, i64>(0),
6585            )
6586            .map_err(|error| error.to_string())? as usize;
6587        let excess = count.saturating_sub(max_entries);
6588        if excess == 0 {
6589            return Ok(());
6590        }
6591        let mut stmt = self
6592            .conn
6593            .prepare(
6594                "SELECT seq FROM _syncular_commit_outcomes
6595                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
6596                  ORDER BY seq ASC LIMIT ?1",
6597            )
6598            .map_err(|error| error.to_string())?;
6599        let rows = stmt
6600            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
6601            .map_err(|error| error.to_string())?;
6602        let sequences = rows
6603            .collect::<Result<Vec<_>, _>>()
6604            .map_err(|error| error.to_string())?;
6605        drop(stmt);
6606        for sequence in sequences {
6607            self.conn
6608                .execute(
6609                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
6610                    rusqlite::params![sequence],
6611                )
6612                .map_err(|error| error.to_string())?;
6613        }
6614        Ok(())
6615    }
6616
6617    // -- driver surface ---------------------------------------------------------
6618
6619    pub fn subscribe(
6620        &mut self,
6621        id: String,
6622        table: String,
6623        scopes: Vec<(String, Vec<String>)>,
6624        params: Option<String>,
6625    ) -> Result<(), String> {
6626        if self.schema.table(&table).is_none() {
6627            return Err(format!("unknown table {table:?}"));
6628        }
6629        if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
6630            let same_intent = existing.table == table
6631                && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
6632                && existing.params == params;
6633            if same_intent {
6634                return Ok(());
6635            }
6636            return Err(
6637                "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
6638                    .to_owned(),
6639            );
6640        }
6641        let sub = Subscription {
6642            id: id.clone(),
6643            table,
6644            requested: scopes,
6645            params,
6646            cursor: -1,
6647            bootstrap_state: None,
6648            state: SubState::Active,
6649            reason_code: None,
6650            effective: None,
6651            synced_once: false,
6652        };
6653        self.persist_sub(&sub)?;
6654        self.subs.push(sub);
6655        Ok(())
6656    }
6657
6658    pub fn unsubscribe(&mut self, id: &str) {
6659        self.subs.retain(|s| s.id != id);
6660        let _ = self.conn.execute(
6661            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
6662            rusqlite::params![id],
6663        );
6664    }
6665
6666    // -- windowed subscriptions (§4.8) ------------------------------------------
6667
6668    /// §4.8: set the live window units for a base — a value-sharded family
6669    /// of subscriptions, one per unit. Added units get fresh subscriptions
6670    /// (image-lane bootstrap on the next sync); removed units are
6671    /// unsubscribed with their first eviction chunk in one transaction (E1–E4).
6672    /// Idempotent; re-entry cancels any deferred eviction.
6673    pub fn set_window(
6674        &mut self,
6675        base: &WindowBase,
6676        units: &[String],
6677    ) -> Result<CommandEffects, String> {
6678        let table = self
6679            .schema
6680            .table(&base.table)
6681            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
6682        if table.scope_column(&base.variable).is_none() {
6683            return Err(format!(
6684                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
6685                base.table, base.variable
6686            ));
6687        }
6688        let base_key = window_base_key(base);
6689        let wanted: std::collections::HashSet<&String> = units.iter().collect();
6690        let live = self.load_window_units(&base_key);
6691        self.begin_observation("syncular_window")?;
6692        let mut batch = ChangeAccumulator::default();
6693        let mut changed = false;
6694
6695        let prior_subs = self.subs.clone();
6696        let widened = (|| {
6697            // Widen: units wanted but not live → fresh subscription + registry row.
6698            for unit in units {
6699                if live.iter().any(|(u, _)| u == unit) {
6700                    continue;
6701                }
6702                let sub_id = derive_sub_id(base, unit);
6703                self.delete_pending_evict(&sub_id)?;
6704                self.insert_window_unit(&base_key, unit, &sub_id)?;
6705                self.subscribe(
6706                    sub_id,
6707                    base.table.clone(),
6708                    unit_scopes(base, unit),
6709                    base.params.clone(),
6710                )?;
6711                batch.window(&base_key, &base.table, unit);
6712                changed = true;
6713            }
6714
6715            self.finish_observation("syncular_window", batch)
6716        })();
6717        if let Err(error) = widened {
6718            self.rollback_observation("syncular_window");
6719            self.subs = prior_subs;
6720            return Err(error);
6721        }
6722        for (unit, sub_id) in live {
6723            if wanted.contains(&unit) {
6724                continue;
6725            }
6726            self.rebuild_overlay_if_dirty();
6727            let prior_subs = self.subs.clone();
6728            self.begin_observation("syncular_window")?;
6729            let effective = self
6730                .subs
6731                .iter()
6732                .find(|sub| sub.id == sub_id)
6733                .and_then(|sub| sub.effective.clone())
6734                .unwrap_or_else(|| unit_scopes(base, &unit));
6735            let mut batch = ChangeAccumulator::default();
6736            self.record_scope_map(&mut batch, &base.table, &effective);
6737            batch.window(&base_key, &base.table, &unit);
6738            let result = self.evict_unit(&base_key, base, &unit, &sub_id);
6739            let remaining = match result {
6740                Ok(remaining) => remaining,
6741                Err(error) => {
6742                    self.rollback_observation("syncular_window");
6743                    self.subs = prior_subs;
6744                    return Err(error);
6745                }
6746            };
6747            if let Err(error) = self.finish_observation("syncular_window", batch) {
6748                self.rollback_observation("syncular_window");
6749                self.subs = prior_subs;
6750                return Err(error);
6751            }
6752            if remaining {
6753                self.drain_pending_evictions()?;
6754            }
6755            changed = true;
6756        }
6757        Ok(if changed {
6758            CommandEffects::interactive()
6759        } else {
6760            CommandEffects::none()
6761        })
6762    }
6763
6764    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
6765    /// the subset still bootstrap-pending. Registration alone is not
6766    /// completeness — a unit is pending until its subscription completes a
6767    /// bootstrap round (cursor advances past -1 with no resume token held).
6768    pub fn window_state(&self, base: &WindowBase) -> WindowState {
6769        let mut units = Vec::new();
6770        let mut pending = Vec::new();
6771        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
6772            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
6773                Some(sub) => {
6774                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
6775                }
6776                None => true,
6777            };
6778            if is_pending {
6779                pending.push(unit.clone());
6780            }
6781            units.push(unit);
6782        }
6783        WindowState { units, pending }
6784    }
6785
6786    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
6787        let mut stmt = match self
6788            .conn
6789            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
6790        {
6791            Ok(stmt) => stmt,
6792            Err(_) => return Vec::new(),
6793        };
6794        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
6795            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6796        });
6797        match rows {
6798            Ok(rows) => rows.filter_map(Result::ok).collect(),
6799            Err(_) => Vec::new(),
6800        }
6801    }
6802
6803    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
6804        let mut stmt = match self.conn.prepare(
6805            "SELECT windows.base, windows.unit, subscriptions.tbl
6806               FROM _syncular_windows AS windows
6807               JOIN _syncular_subscriptions AS subscriptions
6808                 ON subscriptions.id = windows.sub_id
6809               ORDER BY windows.base, windows.unit",
6810        ) {
6811            Ok(stmt) => stmt,
6812            Err(_) => return Vec::new(),
6813        };
6814        let rows = stmt.query_map([], |row| {
6815            Ok((
6816                row.get::<_, String>(0)?,
6817                row.get::<_, String>(1)?,
6818                row.get::<_, String>(2)?,
6819            ))
6820        });
6821        match rows {
6822            Ok(rows) => rows.filter_map(Result::ok).collect(),
6823            Err(_) => Vec::new(),
6824        }
6825    }
6826
6827    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
6828        self.conn
6829            .query_row(
6830                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
6831                rusqlite::params![sub_id],
6832                |row| Ok((row.get(0)?, row.get(1)?)),
6833            )
6834            .ok()
6835    }
6836
6837    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
6838        self.conn
6839            .execute(
6840                "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
6841                rusqlite::params![base_key, unit, sub_id],
6842            )
6843            .map(|_| ())
6844            .map_err(|error| error.to_string())
6845    }
6846
6847    fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
6848        self.conn
6849            .execute(
6850                "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
6851                rusqlite::params![base_key, unit],
6852            )
6853            .map(|_| ())
6854            .map_err(|error| error.to_string())
6855    }
6856
6857    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
6858    /// Deletes the unit's rows except those pinned by a pending outbox
6859    /// commit (E1); records a deferred eviction if any pin remains; discards
6860    /// the subscription's cursor/resume/effective-echo (E3) and version
6861    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
6862    /// nothing.
6863    fn evict_unit(
6864        &mut self,
6865        base_key: &str,
6866        base: &WindowBase,
6867        unit: &str,
6868        sub_id: &str,
6869    ) -> Result<bool, String> {
6870        let effective = self
6871            .subs
6872            .iter()
6873            .find(|s| s.id == sub_id)
6874            .and_then(|s| s.effective.clone())
6875            .unwrap_or_else(|| unit_scopes(base, unit));
6876        let pinned = self.pinned_row_ids(&base.table);
6877        let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
6878        self.delete_window_unit(base_key, unit)?;
6879        self.conn
6880            .execute(
6881                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
6882                [sub_id],
6883            )
6884            .map_err(|error| error.to_string())?;
6885        self.subs.retain(|sub| sub.id != sub_id);
6886        if deferred {
6887            self.save_pending_evict(sub_id, &base.table, &effective)?;
6888        } else {
6889            self.delete_pending_evict(sub_id)?;
6890        }
6891        Ok(remaining)
6892    }
6893
6894    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
6895    /// primary keys. Returns (more unpinned work, any scoped rows remain).
6896    /// A missing scope-column mapping fails without deleting rows.
6897    fn evict_scope_rows(
6898        &self,
6899        table_name: &str,
6900        effective: &[(String, Vec<String>)],
6901        pinned: &std::collections::HashSet<String>,
6902    ) -> Result<(bool, bool), String> {
6903        if effective.is_empty() {
6904            return Ok((false, false));
6905        }
6906        let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
6907        let mut clauses = Vec::new();
6908        let mut params: Vec<SqlValue> = Vec::new();
6909        for (variable, values) in effective {
6910            let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
6911            if values.is_empty() {
6912                return Ok((false, false));
6913            }
6914            let holes = values
6915                .iter()
6916                .map(|v| {
6917                    params.push(SqlValue::Text(v.clone()));
6918                    "?"
6919                })
6920                .collect::<Vec<_>>()
6921                .join(", ");
6922            clauses.push(format!("{} IN ({holes})", quote_ident(column)));
6923        }
6924        let scope = clauses.join(" AND ");
6925        let scope_params = params.len();
6926        let pk = quote_ident(&table.primary_key);
6927        if !pinned.is_empty() {
6928            let holes = pinned
6929                .iter()
6930                .map(|id| {
6931                    params.push(SqlValue::Text(id.clone()));
6932                    "?"
6933                })
6934                .collect::<Vec<_>>()
6935                .join(", ");
6936            clauses.push(format!("{pk} NOT IN ({holes})"));
6937        }
6938        let predicate = clauses.join(" AND ");
6939        let base = base_table(table_name);
6940        let visible = visible_table(table_name);
6941        let mut ids: Vec<SqlValue> = Vec::new();
6942        for source in [&base, &visible] {
6943            let mut stmt = self
6944                .conn
6945                .prepare_cached(&format!(
6946                    "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
6947                ))
6948                .map_err(|e| e.to_string())?;
6949            ids = stmt
6950                .query_map(rusqlite::params_from_iter(&params), |row| row.get(0))
6951                .map_err(|e| e.to_string())?
6952                .collect::<Result<_, _>>()
6953                .map_err(|e| e.to_string())?;
6954            if !ids.is_empty() {
6955                break;
6956            }
6957        }
6958        if !ids.is_empty() {
6959            let holes = vec!["?"; ids.len()].join(", ");
6960            for source in [&base, &visible] {
6961                self.conn
6962                    .execute(
6963                        &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
6964                        rusqlite::params_from_iter(&ids),
6965                    )
6966                    .map_err(|e| e.to_string())?;
6967            }
6968        }
6969        let mut remaining = false;
6970        let mut deferred = false;
6971        for source in [&base, &visible] {
6972            remaining |= self
6973                .conn
6974                .query_row(
6975                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
6976                    rusqlite::params_from_iter(&params),
6977                    |row| row.get::<_, bool>(0),
6978                )
6979                .map_err(|e| e.to_string())?;
6980            deferred |= self
6981                .conn
6982                .query_row(
6983                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
6984                    rusqlite::params_from_iter(&params[..scope_params]),
6985                    |row| row.get::<_, bool>(0),
6986                )
6987                .map_err(|e| e.to_string())?;
6988        }
6989        Ok((remaining, deferred))
6990    }
6991
6992    /// Resume committed eviction chunks, retaining outbox pins and their durable marker.
6993    fn drain_pending_evictions(&mut self) -> Result<(), String> {
6994        let pending = self.load_pending_evictions()?;
6995        if pending.is_empty() {
6996            return Ok(());
6997        }
6998        self.rebuild_overlay_if_dirty();
6999        for (sub_id, table_name, effective) in pending {
7000            if self.schema.table(&table_name).is_none() {
7001                self.delete_pending_evict(&sub_id)?;
7002                continue;
7003            }
7004            loop {
7005                std::thread::yield_now();
7006                self.begin_observation("syncular_evict")?;
7007                let pinned = self.pinned_row_ids(&table_name);
7008                let result = self.evict_scope_rows(&table_name, &effective, &pinned);
7009                let (remaining, deferred) = match result {
7010                    Ok(result) => result,
7011                    Err(error) => {
7012                        self.rollback_observation("syncular_evict");
7013                        return Err(error);
7014                    }
7015                };
7016                if !deferred {
7017                    if let Err(error) = self.delete_pending_evict(&sub_id) {
7018                        self.rollback_observation("syncular_evict");
7019                        return Err(error);
7020                    }
7021                }
7022                let mut batch = ChangeAccumulator::default();
7023                self.record_scope_map(&mut batch, &table_name, &effective);
7024                if let Err(error) = self.finish_observation("syncular_evict", batch) {
7025                    self.rollback_observation("syncular_evict");
7026                    return Err(error);
7027                }
7028                if !remaining {
7029                    break;
7030                }
7031            }
7032        }
7033        Ok(())
7034    }
7035
7036    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
7037    /// commit — rows that MUST NOT be evicted until the commit drains.
7038    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
7039        let mut pinned = std::collections::HashSet::new();
7040        for commit in &self.outbox {
7041            for op in &commit.ops {
7042                if op.table == table {
7043                    pinned.insert(op.row_id.clone());
7044                }
7045            }
7046        }
7047        pinned
7048    }
7049
7050    fn save_pending_evict(
7051        &self,
7052        sub_id: &str,
7053        table: &str,
7054        effective: &[(String, Vec<String>)],
7055    ) -> Result<(), String> {
7056        self.conn.execute(
7057            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
7058            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
7059        ).map(|_| ()).map_err(|error| error.to_string())
7060    }
7061
7062    fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
7063        self.conn
7064            .execute(
7065                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
7066                rusqlite::params![sub_id],
7067            )
7068            .map(|_| ())
7069            .map_err(|error| error.to_string())
7070    }
7071
7072    fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
7073        let mut stmt = self
7074            .conn
7075            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
7076            .map_err(|error| error.to_string())?;
7077        let rows = stmt
7078            .query_map([], |row| {
7079                Ok((
7080                    row.get::<_, String>(0)?,
7081                    row.get::<_, String>(1)?,
7082                    row.get::<_, String>(2)?,
7083                ))
7084            })
7085            .map_err(|error| error.to_string())?;
7086        rows.map(|row| {
7087            let (sub_id, table, json) = row.map_err(|error| error.to_string())?;
7088            let value: Value = serde_json::from_str(&json)
7089                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
7090            let effective = json_to_scope_map(&value)
7091                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
7092            Ok((sub_id, table, effective))
7093        })
7094        .collect()
7095    }
7096
7097    /// Record one atomic local commit (§7.1) and apply it optimistically.
7098    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
7099        if mutations.is_empty() {
7100            return Err("a commit must contain at least one operation (§6.1)".to_owned());
7101        }
7102        let mut ops = Vec::with_capacity(mutations.len());
7103        for mutation in mutations {
7104            match mutation {
7105                Mutation::Upsert {
7106                    table,
7107                    values,
7108                    base_version,
7109                } => {
7110                    let schema_table = self
7111                        .schema
7112                        .table(&table)
7113                        .ok_or_else(|| format!("unknown table {table:?}"))?;
7114                    // §5: value keys are accepted in snake_case AND the
7115                    // generated row types' camelCase; normalize to SQL truth
7116                    // before the pk lookup / codec see them.
7117                    let values = normalize_values_casing(schema_table, values)?;
7118                    let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
7119                    // Validate the payload encodes with the current codec
7120                    // (and, §5.11, that the encrypt seam has its keys).
7121                    encode_row_json(schema_table, &row_id, &values, &self.encryption)?;
7122                    ops.push(OutboxOp {
7123                        upsert: true,
7124                        table,
7125                        row_id,
7126                        base_version,
7127                        values: Some(values),
7128                        changed_fields: None,
7129                    });
7130                }
7131                Mutation::Delete {
7132                    table,
7133                    row_id,
7134                    base_version,
7135                } => {
7136                    if self.schema.table(&table).is_none() {
7137                        return Err(format!("unknown table {table:?}"));
7138                    }
7139                    ops.push(OutboxOp {
7140                        upsert: false,
7141                        table,
7142                        row_id,
7143                        base_version,
7144                        values: None,
7145                        changed_fields: None,
7146                    });
7147                }
7148            }
7149        }
7150        self.record_outbox_commit(ops)
7151    }
7152
7153    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
7154        let commit = OutboxCommit {
7155            client_commit_id: uuid::Uuid::new_v4().to_string(),
7156            ops,
7157        };
7158        self.begin_observation("syncular_mutation")?;
7159        let mut batch = ChangeAccumulator::default();
7160        for op in &commit.ops {
7161            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
7162            if let Some(values) = &op.values {
7163                if let Some(table) = self.schema.table(&op.table) {
7164                    for scope in &table.scope_variables {
7165                        if let Some(Value::String(value)) = values.get(&scope.column) {
7166                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
7167                            precise = true;
7168                        }
7169                    }
7170                }
7171            }
7172            if !precise {
7173                batch.table(&op.table);
7174            }
7175        }
7176        if let Err(error) = self.persist_outbox_insert(&commit) {
7177            self.rollback_observation("syncular_mutation");
7178            return Err(error);
7179        }
7180        let was_dirty = self.overlay_dirty.get();
7181        // A clean overlay already contains the FIFO fold of every previous
7182        // commit. Appending a commit only needs its own operations applied.
7183        if !was_dirty {
7184            self.apply_outbox_ops(&commit.ops);
7185        }
7186        let id = commit.client_commit_id.clone();
7187        self.outbox.push(commit);
7188        if was_dirty {
7189            self.rebuild_overlay();
7190        }
7191        batch.status = true;
7192        if let Err(error) = self.finish_observation("syncular_mutation", batch) {
7193            self.rollback_observation("syncular_mutation");
7194            self.outbox.pop();
7195            self.overlay_dirty.set(was_dirty);
7196            return Err(error);
7197        }
7198        Ok(id)
7199    }
7200
7201    /// Merge a partial update over the current visible local row, then record
7202    /// the ordinary full-row upsert. This keeps patch semantics identical
7203    /// across the TypeScript and native cores without weakening the wire's
7204    /// full-row invariant.
7205    pub fn patch(
7206        &mut self,
7207        table: &str,
7208        row_id: &str,
7209        partial: Map<String, Value>,
7210        base_version: Option<i64>,
7211    ) -> Result<String, String> {
7212        let schema_table = self
7213            .schema
7214            .table(table)
7215            .ok_or_else(|| format!("unknown table {table:?}"))?;
7216        let partial = normalize_values_casing(schema_table, partial)?;
7217        let mut values = self
7218            .read_rows(table)?
7219            .into_iter()
7220            .find(|row| row.row_id == row_id)
7221            .map(|row| row.values)
7222            .ok_or_else(|| {
7223                format!(
7224                    "sync.invalid_request: table {table:?} has no local row with primary key {row_id:?} to patch"
7225                )
7226            })?;
7227        let mut changed_fields = partial.keys().cloned().collect::<Vec<_>>();
7228        changed_fields.sort();
7229        values.extend(partial);
7230        let row_id_from_values = render_row_id_json(values.get(&schema_table.primary_key))?;
7231        if row_id_from_values != row_id {
7232            return Err("sync.invalid_request: patch cannot change the primary key".to_owned());
7233        }
7234        encode_row_json(schema_table, row_id, &values, &self.encryption)?;
7235        self.record_outbox_commit(vec![OutboxOp {
7236            upsert: true,
7237            table: table.to_owned(),
7238            row_id: row_id.to_owned(),
7239            base_version,
7240            values: Some(values),
7241            changed_fields: Some(changed_fields),
7242        }])
7243    }
7244
7245    pub fn pending_commit_ids(&self) -> Vec<String> {
7246        self.outbox
7247            .iter()
7248            .map(|c| c.client_commit_id.clone())
7249            .collect()
7250    }
7251
7252    pub fn conflicts(&self) -> &[ConflictRecord] {
7253        &self.conflicts
7254    }
7255
7256    pub fn rejections(&self) -> &[RejectionRecord] {
7257        &self.rejections
7258    }
7259
7260    pub fn resolve_commit_outcome(
7261        &mut self,
7262        input: ResolveCommitOutcomeInput,
7263    ) -> Result<CommitOutcome, String> {
7264        let current = self
7265            .commit_outcome(&input.client_commit_id)?
7266            .ok_or_else(|| {
7267                format!(
7268                    "sync.outcome_not_found: no durable outcome exists for {:?}",
7269                    input.client_commit_id
7270                )
7271            })?;
7272        if current.resolution != CommitOutcomeResolution::Active {
7273            return Ok(current);
7274        }
7275        if input.resolution == CommitOutcomeResolution::Active {
7276            return Err("sync.invalid_request: resolution must leave active state".to_owned());
7277        }
7278        match input.resolution {
7279            CommitOutcomeResolution::Superseded => {
7280                let replacement = input
7281                    .replacement_client_commit_id
7282                    .as_deref()
7283                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
7284                    .ok_or_else(|| {
7285                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
7286                            .to_owned()
7287                    })?;
7288                let _ = replacement;
7289            }
7290            _ if input.replacement_client_commit_id.is_some() => {
7291                return Err(
7292                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
7293                        .to_owned(),
7294                );
7295            }
7296            _ => {}
7297        }
7298        let allowed = match current.status {
7299            CommitOutcomeStatus::Conflict => matches!(
7300                input.resolution,
7301                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
7302            ),
7303            CommitOutcomeStatus::Rejected => {
7304                input.resolution == CommitOutcomeResolution::Superseded
7305            }
7306            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
7307                input.resolution == CommitOutcomeResolution::Dismissed
7308            }
7309        };
7310        if !allowed {
7311            return Err(format!(
7312                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
7313                input.resolution, current.status
7314            ));
7315        }
7316
7317        self.begin_observation("syncular_outcome_resolution")?;
7318        let result = (|| {
7319            self.conn
7320                .execute(
7321                    "UPDATE _syncular_commit_outcomes
7322                        SET resolution = ?1, resolved_at_ms = ?2,
7323                            replacement_client_commit_id = ?3
7324                      WHERE client_commit_id = ?4 AND resolution = 'active'",
7325                    rusqlite::params![
7326                        Self::outcome_resolution_name(input.resolution),
7327                        self.clock_now_ms(),
7328                        input.replacement_client_commit_id,
7329                        input.client_commit_id
7330                    ],
7331                )
7332                .map_err(|error| error.to_string())?;
7333            let resolved = self
7334                .commit_outcome(&current.client_commit_id)?
7335                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
7336            self.prune_commit_outcomes()?;
7337            Ok(resolved)
7338        })();
7339        let resolved = match result {
7340            Ok(outcome) => outcome,
7341            Err(error) => {
7342                self.rollback_observation("syncular_outcome_resolution");
7343                return Err(error);
7344            }
7345        };
7346        self.conflicts
7347            .retain(|record| record.client_commit_id != current.client_commit_id);
7348        self.rejections
7349            .retain(|record| record.client_commit_id != current.client_commit_id);
7350        let batch = ChangeAccumulator {
7351            conflicts: current.status == CommitOutcomeStatus::Conflict,
7352            rejections: current.status == CommitOutcomeStatus::Rejected,
7353            outcomes: true,
7354            ..ChangeAccumulator::default()
7355        };
7356        if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
7357            self.rollback_observation("syncular_outcome_resolution");
7358            return Err(error);
7359        }
7360        Ok(resolved)
7361    }
7362
7363    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
7364        self.schema_floor.as_ref()
7365    }
7366
7367    /// §7.3.5: the client's opaque auth-lease state, if any.
7368    pub fn lease_state(&self) -> Option<&LeaseState> {
7369        self.lease_state.as_ref()
7370    }
7371
7372    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
7373    /// the two lease codes set it; other errors leave leaseState untouched.
7374    fn record_lease_error(&mut self, code: &str) {
7375        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
7376            return;
7377        }
7378        let mut next = self.lease_state.clone().unwrap_or_default();
7379        next.error_code = Some(code.to_owned());
7380        self.set_lease_state(Some(next));
7381    }
7382
7383    fn set_lease_state(&mut self, next: Option<LeaseState>) {
7384        if self.lease_state == next {
7385            return;
7386        }
7387        if self.begin_observation("syncular_lease").is_err() {
7388            return;
7389        }
7390        self.lease_state = next;
7391        if let Some(lease) = &self.lease_state {
7392            if let Ok(json) = serde_json::to_string(lease) {
7393                self.set_meta(LEASE_STATE_KEY, &json);
7394            }
7395        } else {
7396            self.delete_meta(LEASE_STATE_KEY);
7397        }
7398        let batch = ChangeAccumulator {
7399            status: true,
7400            ..ChangeAccumulator::default()
7401        };
7402        if self.finish_observation("syncular_lease", batch).is_err() {
7403            self.rollback_observation("syncular_lease");
7404        }
7405    }
7406
7407    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
7408        if self.schema_floor == next {
7409            return;
7410        }
7411        if self.begin_observation("syncular_schema_floor").is_err() {
7412            return;
7413        }
7414        self.schema_floor = next;
7415        self.stopped = self.schema_floor.is_some();
7416        if let Some(floor) = &self.schema_floor {
7417            if let Ok(json) = serde_json::to_string(floor) {
7418                self.set_meta(SCHEMA_FLOOR_KEY, &json);
7419            }
7420        } else {
7421            self.delete_meta(SCHEMA_FLOOR_KEY);
7422        }
7423        let batch = ChangeAccumulator {
7424            status: true,
7425            ..ChangeAccumulator::default()
7426        };
7427        if self
7428            .finish_observation("syncular_schema_floor", batch)
7429            .is_err()
7430        {
7431            self.rollback_observation("syncular_schema_floor");
7432        }
7433    }
7434
7435    fn set_upgrading(&mut self, value: bool) {
7436        if self.upgrading == value {
7437            return;
7438        }
7439        if self.begin_observation("syncular_upgrading").is_err() {
7440            return;
7441        }
7442        self.upgrading = value;
7443        let batch = ChangeAccumulator {
7444            status: true,
7445            ..ChangeAccumulator::default()
7446        };
7447        if self
7448            .finish_observation("syncular_upgrading", batch)
7449            .is_err()
7450        {
7451            self.rollback_observation("syncular_upgrading");
7452        }
7453    }
7454
7455    pub fn sync_needed(&self) -> bool {
7456        self.sync_needed
7457    }
7458
7459    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
7460        let sub = self.subs.iter().find(|s| s.id == id)?;
7461        Some(SubscriptionStateView {
7462            id: sub.id.clone(),
7463            table: sub.table.clone(),
7464            status: sub.state.name().to_owned(),
7465            cursor: sub.cursor,
7466            has_resume_token: sub.bootstrap_state.is_some(),
7467            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
7468            reason_code: sub.reason_code.clone(),
7469        })
7470    }
7471
7472    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
7473        let schema_table = self
7474            .schema
7475            .table(table)
7476            .ok_or_else(|| format!("unknown table {table:?}"))?;
7477        let sql = format!(
7478            "SELECT * FROM {} ORDER BY {} ASC",
7479            visible_table(table),
7480            quote_ident(&schema_table.primary_key)
7481        );
7482        let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?;
7483        let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
7484        let mut out = Vec::new();
7485        while let Some(row) = rows.next().map_err(|e| e.to_string())? {
7486            let mut values = Map::new();
7487            for (i, column) in schema_table.columns.iter().enumerate() {
7488                let value = row.get_ref(i).map_err(|e| e.to_string())?;
7489                values.insert(column.name.clone(), sql_ref_to_json(column, value));
7490            }
7491            let version: i64 = row
7492                .get(schema_table.columns.len())
7493                .map_err(|e| e.to_string())?;
7494            let row_id = match values.get(&schema_table.primary_key) {
7495                Some(Value::String(s)) => s.clone(),
7496                Some(Value::Number(n)) => n.to_string(),
7497                Some(Value::Bool(b)) => b.to_string(),
7498                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
7499            };
7500            out.push(RowState {
7501                row_id,
7502                version,
7503                values,
7504            });
7505        }
7506        Ok(out)
7507    }
7508
7509    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
7510    //
7511    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
7512    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
7513    // the op with `yrs`, re-encodes the whole doc state, and pushes it as a
7514    // baseVersion-less upsert through the ordinary `mutate` path (§5.10.3
7515    // "crdt-only divergence merges cleanly"). No local merge — merging is
7516    // server-side; the overlay's last-write-wins re-materializes the edit
7517    // immediately (optimistic apply, §7.1) and the server-merged bytes arrive
7518    // on the next pull, idempotently. Byte-compatible with `@syncular/crdt-yjs`.
7519
7520    /// The current stored value of a `crdt` column for one row — the visible
7521    /// (optimistic) bytes, or `None` when the row is absent or the column is
7522    /// NULL (the empty document, §5.10.1). Errors if the column is not a
7523    /// `crdt` column (guards the app against a typo'd column name).
7524    #[cfg(feature = "crdt-yjs")]
7525    fn crdt_column_bytes(
7526        &self,
7527        table: &str,
7528        row_id: &str,
7529        column: &str,
7530    ) -> Result<Option<Vec<u8>>, String> {
7531        let schema_table = self
7532            .schema
7533            .table(table)
7534            .ok_or_else(|| format!("unknown table {table:?}"))?;
7535        let col = schema_table
7536            .columns
7537            .iter()
7538            .find(|c| c.name == column)
7539            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
7540        if col.ty != ColumnType::Crdt {
7541            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
7542        }
7543        let sql = format!(
7544            "SELECT {} FROM {} WHERE {}",
7545            quote_ident(column),
7546            visible_table(table),
7547            row_id_predicate(schema_table)
7548        );
7549        let bytes: Option<Vec<u8>> = self
7550            .conn
7551            .prepare_cached(&sql)
7552            .map_err(|error| error.to_string())?
7553            .query_row(rusqlite::params![row_id], |row| {
7554                row.get::<_, Option<Vec<u8>>>(0)
7555            })
7556            .map_err(|e| match e {
7557                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
7558                other => other.to_string(),
7559            })?;
7560        Ok(bytes)
7561    }
7562
7563    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
7564    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
7565    /// An absent row / NULL column is the empty document (empty string).
7566    #[cfg(feature = "crdt-yjs")]
7567    pub fn crdt_text(
7568        &self,
7569        table: &str,
7570        row_id: &str,
7571        column: &str,
7572        name: &str,
7573    ) -> Result<String, String> {
7574        let bytes = self
7575            .crdt_column_bytes(table, row_id, column)?
7576            .unwrap_or_default();
7577        crate::crdt::text(&bytes, name)
7578    }
7579
7580    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and push
7581    /// the resulting full-state update through the normal (baseVersion-less)
7582    /// mutate path. Returns the enqueued `clientCommitId`.
7583    #[cfg(feature = "crdt-yjs")]
7584    pub fn crdt_insert_text(
7585        &mut self,
7586        table: &str,
7587        row_id: &str,
7588        column: &str,
7589        name: &str,
7590        index: u32,
7591        value: &str,
7592    ) -> Result<String, String> {
7593        let current = self
7594            .crdt_column_bytes(table, row_id, column)?
7595            .unwrap_or_default();
7596        let update = crate::crdt::insert_text(&current, name, index, value)?;
7597        self.crdt_push_update(table, row_id, column, &update)
7598    }
7599
7600    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and push
7601    /// the resulting full-state update. Returns the enqueued `clientCommitId`.
7602    #[cfg(feature = "crdt-yjs")]
7603    pub fn crdt_delete_text(
7604        &mut self,
7605        table: &str,
7606        row_id: &str,
7607        column: &str,
7608        name: &str,
7609        index: u32,
7610        len: u32,
7611    ) -> Result<String, String> {
7612        let current = self
7613            .crdt_column_bytes(table, row_id, column)?
7614            .unwrap_or_default();
7615        let update = crate::crdt::delete_text(&current, name, index, len)?;
7616        self.crdt_push_update(table, row_id, column, &update)
7617    }
7618
7619    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
7620    /// `crdt` column's current state and push the resulting full state. The
7621    /// app authored the update with its own `yrs` model. Returns the enqueued
7622    /// `clientCommitId`.
7623    #[cfg(feature = "crdt-yjs")]
7624    pub fn crdt_apply_update(
7625        &mut self,
7626        table: &str,
7627        row_id: &str,
7628        column: &str,
7629        update: &[u8],
7630    ) -> Result<String, String> {
7631        let current = self
7632            .crdt_column_bytes(table, row_id, column)?
7633            .unwrap_or_default();
7634        let next = crate::crdt::apply_update(&current, update)?;
7635        self.crdt_push_update(table, row_id, column, &next)
7636    }
7637
7638    /// Shared tail of the crdt edit methods: build the full-row upsert that
7639    /// carries the new crdt bytes and enqueue it. The row's other columns are
7640    /// preserved from the current visible row (so a crdt edit does not clobber
7641    /// the LWW columns); a brand-new row is seeded with just the primary key +
7642    /// crdt column. Pushed baseVersion-less (§5.10.3 crdt-only-divergence rule).
7643    #[cfg(feature = "crdt-yjs")]
7644    fn crdt_push_update(
7645        &mut self,
7646        table: &str,
7647        row_id: &str,
7648        column: &str,
7649        crdt_bytes: &[u8],
7650    ) -> Result<String, String> {
7651        let schema_table = self
7652            .schema
7653            .table(table)
7654            .ok_or_else(|| format!("unknown table {table:?}"))?
7655            .clone();
7656        // The current visible row's values (preserving LWW columns), or a
7657        // fresh row keyed by row_id if it does not exist yet.
7658        let mut values: Map<String, Value> = self
7659            .read_rows(table)?
7660            .into_iter()
7661            .find(|r| r.row_id == row_id)
7662            .map(|r| r.values)
7663            .unwrap_or_else(|| {
7664                let mut map = Map::new();
7665                map.insert(
7666                    schema_table.primary_key.clone(),
7667                    Value::from(row_id.to_owned()),
7668                );
7669                map
7670            });
7671        // Replace the crdt column with the new bytes in the driver envelope.
7672        let mut bytes_obj = Map::new();
7673        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
7674        values.insert(column.to_owned(), Value::Object(bytes_obj));
7675        self.mutate(vec![Mutation::Upsert {
7676            table: table.to_owned(),
7677            values,
7678            base_version: None,
7679        }])
7680    }
7681
7682    /// Run an arbitrary read-only SQL query against the local database and
7683    /// return each row as a `column-name → JSON value` map. This is the seam
7684    /// the React `useSyncQuery` live-query API needs (it takes app-authored
7685    /// SQL over the visible tables/views, not a fixed table read like
7686    /// [`read_rows`]).
7687    ///
7688    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
7689    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
7690    /// same envelope the command surface uses everywhere else. Output BLOB
7691    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
7692    ///
7693    /// The result column typing is dynamic (SQLite's stored affinity), because
7694    /// arbitrary SQL can alias, join, and compute — there is no schema column
7695    /// to consult per output cell, unlike [`read_rows`].
7696    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
7697        query_connection(&self.conn, sql, params)
7698    }
7699
7700    /// Repository benchmark and conformance access, present only with `bench-internals`.
7701    #[cfg(feature = "bench-internals")]
7702    #[doc(hidden)]
7703    pub fn benchmark_connection(&mut self) -> &mut Connection {
7704        &mut self.conn
7705    }
7706
7707    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
7708    #[cfg(feature = "bench-internals")]
7709    #[doc(hidden)]
7710    pub fn benchmark_phases(
7711        &self,
7712        enabled: Option<bool>,
7713        reset: bool,
7714    ) -> Result<Option<Value>, String> {
7715        self.benchmark_phases.configure(enabled, reset)?;
7716        Ok(self.benchmark_phases.snapshot())
7717    }
7718
7719    /// Rows, coverage, and local revision from one SQLite read snapshot.
7720    pub fn query_snapshot(
7721        &mut self,
7722        sql: &str,
7723        params: &[QueryValue],
7724        coverage: &[WindowCoverage],
7725    ) -> Result<QuerySnapshot, String> {
7726        snapshot_connection(&self.conn, sql, params, coverage)
7727    }
7728
7729    // -- request building ---------------------------------------------------------
7730
7731    fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
7732        #[cfg(feature = "bench-internals")]
7733        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
7734        let log_epoch = self.get_meta(LOG_EPOCH_KEY);
7735        let mut frames = vec![Frame::ReqHeader {
7736            client_id: self.client_id.clone(),
7737            schema_version: self.schema.version,
7738            log_epoch: log_epoch.clone(),
7739        }];
7740        let mut pushed_ids = Vec::new();
7741        let mut ops_in_request = 0usize;
7742        let mut deferred_commits = 0usize;
7743        #[cfg(feature = "bench-internals")]
7744        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
7745        for (index, commit) in self
7746            .outbox
7747            .iter()
7748            .take(if log_epoch.is_some() {
7749                self.outbox.len()
7750            } else {
7751                0
7752            })
7753            .enumerate()
7754        {
7755            // §6.1 splitBatch: stop at the operation cap — commits apply in
7756            // order, so everything from the first non-fitting commit on is
7757            // deferred to the next round. A single over-cap commit still goes
7758            // alone (commits are atomic and cannot be split).
7759            if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
7760                deferred_commits = self.outbox.len() - index;
7761                break;
7762            }
7763            ops_in_request += commit.ops.len();
7764            let operations = commit
7765                .ops
7766                .iter()
7767                .map(|op| {
7768                    let payload = op.values.as_ref().and_then(|values| {
7769                        let table = self.schema.table(&op.table)?;
7770                        // §0: outbox entries encode at send time with the
7771                        // current codec (validated at mutate()). §5.11:
7772                        // encrypted columns are encrypted here.
7773                        encode_row_json(table, &op.row_id, values, &self.encryption).ok()
7774                    });
7775                    ssp2::model::Operation {
7776                        table: op.table.clone(),
7777                        row_id: op.row_id.clone(),
7778                        op: if op.upsert { Op::Upsert } else { Op::Delete },
7779                        base_version: op.base_version,
7780                        payload,
7781                    }
7782                })
7783                .collect();
7784            frames.push(Frame::PushCommit {
7785                client_commit_id: commit.client_commit_id.clone(),
7786                operations,
7787            });
7788            pushed_ids.push(commit.client_commit_id.clone());
7789        }
7790        #[cfg(feature = "bench-internals")]
7791        drop(outbox_phase);
7792        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
7793        // bare URL — capability negotiation, decided per transport.
7794        let accept = self.limits.accept.unwrap_or(if url_capable {
7795            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
7796        } else {
7797            DEFAULT_ACCEPT
7798        });
7799        frames.push(Frame::PullHeader {
7800            limit_commits: self.limits.limit_commits.unwrap_or(0),
7801            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
7802            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
7803            accept,
7804        });
7805        let mut fresh = Vec::new();
7806        for sub in &self.subs {
7807            if sub.state != SubState::Active {
7808                continue;
7809            }
7810            let mut scopes = sub.requested.clone();
7811            sort_scope_map(&mut scopes);
7812            frames.push(Frame::Subscription {
7813                id: sub.id.clone(),
7814                table: sub.table.clone(),
7815                scopes,
7816                params: sub.params.clone().map(RawJson),
7817                cursor: sub.cursor,
7818                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
7819            });
7820            fresh.push((
7821                sub.id.clone(),
7822                sub.cursor < 0 && sub.bootstrap_state.is_none(),
7823            ));
7824        }
7825        let message = Message {
7826            wire_version: WIRE_VERSION,
7827            msg_kind: MsgKind::Request,
7828            frames,
7829        };
7830        (
7831            message,
7832            RequestMeta {
7833                pushed_ids,
7834                fresh,
7835                accept,
7836                deferred_commits,
7837            },
7838        )
7839    }
7840
7841    // -- sync -------------------------------------------------------------------
7842
7843    /// Cloneable observation handle; no client lock is needed to read it.
7844    pub fn progress(&self) -> ProgressObserver {
7845        self.progress.clone()
7846    }
7847
7848    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
7849        self.progress.start();
7850        let started_at_ms = self.clock_now_ms();
7851        let outcome = self.sync_inner(transport);
7852        self.progress.update(|p| match &outcome {
7853            SyncOutcome::Ok(report) if report.failed.is_empty() => {
7854                p.state = ProgressState::Complete
7855            }
7856            SyncOutcome::Ok(_) => {
7857                p.state = ProgressState::Failed;
7858                p.error_code = Some("sync.scope_revoked".into());
7859            }
7860            SyncOutcome::Failed { error_code, .. } => {
7861                p.state = ProgressState::Failed;
7862                p.error_code = Some(Self::diagnostic_code(error_code));
7863            }
7864        });
7865        let completed_at_ms = self.clock_now_ms();
7866        self.last_round = Some(match &outcome {
7867            SyncOutcome::Ok(report) => DiagnosticLastRound {
7868                status: "succeeded".to_owned(),
7869                started_at_ms,
7870                completed_at_ms,
7871                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
7872                counters: Some(DiagnosticRoundCounters {
7873                    pushed: report.pushed,
7874                    applied: report.applied.len(),
7875                    rejected: report.rejected.len(),
7876                    retryable: report.retryable.len(),
7877                    conflicts: report.conflicts,
7878                    commits_applied: report.commits_applied,
7879                    segment_rows_applied: report.segment_rows_applied,
7880                    bootstrapping: report.bootstrapping.len(),
7881                    resets: report.resets.len(),
7882                    revoked: report.revoked.len(),
7883                    failed: report.failed.len(),
7884                    deferred_commits: report.deferred_commits,
7885                }),
7886                error_code: None,
7887            },
7888            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
7889                status: "failed".to_owned(),
7890                started_at_ms,
7891                completed_at_ms,
7892                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
7893                counters: None,
7894                error_code: Some(Self::diagnostic_code(error_code)),
7895            },
7896        });
7897        outcome
7898    }
7899
7900    fn sync_inner(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
7901        if self.stopped {
7902            // §1.6: the client stopped at the schema floor; syncing is inert
7903            // until an upgrade. The outbox is preserved for replay.
7904            return SyncOutcome::Ok(SyncReport {
7905                schema_floor: self.schema_floor.clone(),
7906                ..SyncReport::default()
7907            });
7908        }
7909        // §8.4: the coalesced sync-needed signal clears when a pull round
7910        // BEGINS, so a wake-up landing mid-round survives it.
7911        self.set_sync_needed(false, false);
7912        // §5.9.7 B4: upload pending blobs before pushing the referencing
7913        // rows, so the server-side existence check (§6.6) passes.
7914        if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
7915            if let Err(TransportError { code, message }) = self.flush_blob_uploads(transport) {
7916                if Self::retryable_transport_code(&code) {
7917                    self.schedule_background_retry();
7918                }
7919                return SyncOutcome::Failed {
7920                    error_code: code,
7921                    message,
7922                };
7923            }
7924        }
7925        if let Err(message) = self.drain_pending_evictions() {
7926            return SyncOutcome::Failed {
7927                error_code: "storage.failed".into(),
7928                message,
7929            };
7930        }
7931        let (message, meta) = self.build_request(transport.supports_url_fetch());
7932        let request_bytes = {
7933            #[cfg(feature = "bench-internals")]
7934            let _phase = self.benchmark_phases.start(Phase::RequestEncode);
7935            encode_message(&message)
7936        };
7937        // §8.7: rounds ride the socket whenever it is connected (one
7938        // loop, no fallback pair); the transport seam stays bytes-in /
7939        // bytes-out either way. Registration-at-round-end is server-side.
7940        let round = if self.realtime_connected {
7941            transport.realtime_sync(&request_bytes)
7942        } else {
7943            transport.sync(&request_bytes)
7944        };
7945        let response_bytes = match round {
7946            Ok(bytes) => bytes,
7947            Err(TransportError { code, message }) => {
7948                // §7.3.5: a request-level lease code stops-and-surfaces —
7949                // record it in leaseState (no local-data purge, §7.3.4).
7950                self.record_lease_error(&code);
7951                if Self::retryable_transport_code(&code) {
7952                    self.schedule_background_retry();
7953                }
7954                return SyncOutcome::Failed {
7955                    error_code: code,
7956                    message,
7957                };
7958            }
7959        };
7960        #[cfg(feature = "bench-internals")]
7961        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
7962        let response = match decode_message(&response_bytes) {
7963            Ok(message) => message,
7964            Err(error) => {
7965                // §1.2 rule 1 / §1.4 rule 5: truncated or malformed
7966                // responses abort without persisting anything.
7967                return SyncOutcome::Failed {
7968                    error_code: error.code.as_str().to_owned(),
7969                    message: error.detail,
7970                };
7971            }
7972        };
7973        #[cfg(feature = "bench-internals")]
7974        drop(decode_phase);
7975        if response.msg_kind != MsgKind::Response {
7976            return SyncOutcome::Failed {
7977                error_code: "sync.invalid_request".to_owned(),
7978                message: "expected a response message".to_owned(),
7979            };
7980        }
7981        let mut outcome = self.process_response(transport, response, &meta);
7982        if let SyncOutcome::Ok(report) = &mut outcome {
7983            report.deferred_commits = meta.deferred_commits;
7984        }
7985        match &outcome {
7986            SyncOutcome::Ok(_) => self.reset_background_retry(),
7987            SyncOutcome::Failed { error_code, .. }
7988                if Self::retryable_transport_code(error_code) =>
7989            {
7990                self.schedule_background_retry();
7991            }
7992            SyncOutcome::Failed { .. } => {}
7993        }
7994        if meta.deferred_commits > 0 {
7995            // §6.1 splitBatch: commits past the operation cap wait for the
7996            // next round — keep the host's sync signal raised until then.
7997            self.set_sync_needed(true, true);
7998        }
7999        outcome
8000    }
8001
8002    pub fn sync_until_idle(
8003        &mut self,
8004        transport: &mut dyn Transport,
8005        max_rounds: Option<u32>,
8006    ) -> SyncOutcome {
8007        let rounds = max_rounds.unwrap_or(20).max(1);
8008        let mut aggregate = SyncReport::default();
8009        let mut spent = 0;
8010        while spent < rounds {
8011            spent += 1;
8012            let before = self
8013                .subs
8014                .iter()
8015                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
8016                .collect::<Vec<_>>();
8017            match self.sync(transport) {
8018                SyncOutcome::Failed {
8019                    error_code,
8020                    message,
8021                } => {
8022                    return SyncOutcome::Failed {
8023                        error_code,
8024                        message,
8025                    };
8026                }
8027                SyncOutcome::Ok(report) => {
8028                    if max_rounds.is_none()
8029                        && report.segment_rows_applied > 0
8030                        && !report.bootstrapping.is_empty()
8031                        && before
8032                            != self
8033                                .subs
8034                                .iter()
8035                                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
8036                                .collect::<Vec<_>>()
8037                    {
8038                        spent = 0;
8039                    }
8040                    aggregate.pushed += report.pushed;
8041                    aggregate.applied.extend(report.applied.iter().cloned());
8042                    aggregate.rejected.extend(report.rejected.iter().cloned());
8043                    aggregate.retryable.extend(report.retryable.iter().cloned());
8044                    aggregate.conflicts += report.conflicts;
8045                    aggregate.commits_applied += report.commits_applied;
8046                    aggregate.segment_rows_applied += report.segment_rows_applied;
8047                    aggregate.bootstrapping = report.bootstrapping.clone();
8048                    aggregate.resets.extend(report.resets.iter().cloned());
8049                    aggregate.revoked.extend(report.revoked.iter().cloned());
8050                    aggregate.failed.extend(report.failed.iter().cloned());
8051                    aggregate.deferred_commits = report.deferred_commits;
8052                    if report.schema_floor.is_some() {
8053                        aggregate.schema_floor = report.schema_floor.clone();
8054                    }
8055                    // §4.5: pull again whenever the response contained
8056                    // commits or segments; resets re-bootstrap; a pending
8057                    // resume token continues paging (§4.7); a raised
8058                    // sync-needed signal covers §6.1 splitBatch remainders
8059                    // (deferred outbox commits push on the next round).
8060                    let more = !report.bootstrapping.is_empty()
8061                        || report.commits_applied > 0
8062                        || report.segment_rows_applied > 0
8063                        || !report.resets.is_empty()
8064                        || self.sync_needed;
8065                    if !more {
8066                        return SyncOutcome::Ok(aggregate);
8067                    }
8068                }
8069            }
8070        }
8071        SyncOutcome::Failed {
8072            error_code: "sync.invalid_request".into(),
8073            message: "sync did not reach idle within the round budget".into(),
8074        }
8075    }
8076
8077    fn process_response(
8078        &mut self,
8079        transport: &mut dyn Transport,
8080        response: Message,
8081        meta: &RequestMeta,
8082    ) -> SyncOutcome {
8083        #[cfg(feature = "bench-internals")]
8084        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
8085        let mut report = SyncReport {
8086            pushed: meta.pushed_ids.len() as u32,
8087            ..SyncReport::default()
8088        };
8089        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
8090            HashMap::new();
8091        let pushed_ids = meta
8092            .pushed_ids
8093            .iter()
8094            .map(String::as_str)
8095            .collect::<HashSet<_>>();
8096        let mut last_final_push_result_id: Option<String> = None;
8097        for frame in &response.frames {
8098            match frame {
8099                Frame::PushResultDetails {
8100                    client_commit_id,
8101                    entries,
8102                } => {
8103                    let details = rejection_details_by_commit
8104                        .entry(client_commit_id.clone())
8105                        .or_default();
8106                    for entry in entries {
8107                        let parsed = match RejectionDetails::parse(&entry.details.0) {
8108                            Ok(value) => value,
8109                            Err(message) => {
8110                                return SyncOutcome::Failed {
8111                                    error_code: "sync.invalid_request".to_owned(),
8112                                    message,
8113                                };
8114                            }
8115                        };
8116                        details.insert(entry.op_index, parsed);
8117                    }
8118                }
8119                Frame::PushResult {
8120                    client_commit_id,
8121                    status,
8122                    results,
8123                    ..
8124                } if pushed_ids.contains(client_commit_id.as_str())
8125                    && Self::push_result_is_final(*status, results) =>
8126                {
8127                    last_final_push_result_id = Some(client_commit_id.clone());
8128                }
8129                _ => {}
8130            }
8131        }
8132        let mut frames = response.frames.into_iter().peekable();
8133        if response.wire_version < 2 {
8134            return SyncOutcome::Failed {
8135                error_code: "client.invalid_host_response".to_owned(),
8136                message: "server response does not carry wire version 2 log-epoch state".to_owned(),
8137            };
8138        }
8139        match frames.next() {
8140            Some(Frame::RespHeader {
8141                required_schema_version,
8142                latest_schema_version,
8143                log_epoch,
8144                reset_required,
8145            }) => {
8146                let Some(log_epoch) = log_epoch else {
8147                    return SyncOutcome::Failed {
8148                        error_code: "client.invalid_host_response".to_owned(),
8149                        message: "response header omits logEpoch".to_owned(),
8150                    };
8151                };
8152                let Some(reset_required) = reset_required else {
8153                    return SyncOutcome::Failed {
8154                        error_code: "client.invalid_host_response".to_owned(),
8155                        message: "response header omits resetRequired".to_owned(),
8156                    };
8157                };
8158                if let Some(required) = required_schema_version {
8159                    // §1.6 schema-floor response: nothing else is processed;
8160                    // stop syncing and surface the upgrade requirement.
8161                    let floor = SchemaFloor {
8162                        required_schema_version: Some(required),
8163                        latest_schema_version,
8164                    };
8165                    self.set_schema_floor(Some(floor.clone()));
8166                    report.schema_floor = Some(floor);
8167                    return SyncOutcome::Ok(report);
8168                }
8169                if reset_required {
8170                    if frames.next().is_some() {
8171                        return SyncOutcome::Failed {
8172                            error_code: "client.invalid_host_response".to_owned(),
8173                            message: "log-epoch reset response contains body frames".to_owned(),
8174                        };
8175                    }
8176                    match self.run_log_epoch_reset(&log_epoch) {
8177                        Ok(resets) => {
8178                            report.resets = resets;
8179                            return SyncOutcome::Ok(report);
8180                        }
8181                        Err(message) => {
8182                            return SyncOutcome::Failed {
8183                                error_code: "sync.local_corrupt".to_owned(),
8184                                message,
8185                            };
8186                        }
8187                    }
8188                }
8189                if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
8190                    return SyncOutcome::Failed {
8191                        error_code: "client.invalid_host_response".to_owned(),
8192                        message: "server changed logEpoch without requiring a reset".to_owned(),
8193                    };
8194                }
8195            }
8196            _ => {
8197                return SyncOutcome::Failed {
8198                    error_code: "sync.invalid_request".to_owned(),
8199                    message: "response does not start with RESP_HEADER".to_owned(),
8200                };
8201            }
8202        }
8203
8204        let mut failure: Option<(String, String)> = None;
8205        while let Some(frame) = frames.next() {
8206            match frame {
8207                frame @ Frame::PushResult { .. } => {
8208                    let successful = matches!(
8209                        &frame,
8210                        Frame::PushResult {
8211                            status: PushStatus::Applied | PushStatus::Cached,
8212                            ..
8213                        }
8214                    );
8215                    let mut results = vec![frame];
8216                    if successful {
8217                        while matches!(
8218                            frames.peek(),
8219                            Some(Frame::PushResult {
8220                                status: PushStatus::Applied | PushStatus::Cached,
8221                                ..
8222                            })
8223                        ) {
8224                            if let Some(next) = frames.next() {
8225                                results.push(next);
8226                            }
8227                        }
8228                    }
8229                    if self
8230                        .handle_push_results(
8231                            &results,
8232                            &pushed_ids,
8233                            &rejection_details_by_commit,
8234                            &mut report,
8235                            last_final_push_result_id.as_deref(),
8236                        )
8237                        .is_err()
8238                    {
8239                        failure = Some((
8240                            "client.outcome_persistence_failed".to_owned(),
8241                            "local commit outcome could not be persisted".to_owned(),
8242                        ));
8243                        break;
8244                    }
8245                }
8246                Frame::PushResultDetails { .. } => {}
8247                Frame::SubStart {
8248                    id,
8249                    status,
8250                    reason_code,
8251                    effective_scopes,
8252                    bootstrap: _,
8253                } => {
8254                    let mut body = Vec::new();
8255                    let mut sub_end: Option<(i64, Option<String>)> = None;
8256                    for inner in frames.by_ref() {
8257                        match inner {
8258                            Frame::SubEnd {
8259                                next_cursor,
8260                                bootstrap_state,
8261                            } => {
8262                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
8263                                break;
8264                            }
8265                            Frame::Unknown { .. } => {}
8266                            other => body.push(other),
8267                        }
8268                    }
8269                    if let Err(SectionError::Abort(code, message)) = self.process_section(
8270                        transport,
8271                        &id,
8272                        status,
8273                        &reason_code,
8274                        effective_scopes,
8275                        body,
8276                        sub_end,
8277                        meta,
8278                        &mut report,
8279                    ) {
8280                        failure = Some((code, message));
8281                        break;
8282                    }
8283                }
8284                Frame::Lease {
8285                    lease_id,
8286                    expires_at_ms,
8287                } => {
8288                    // §7.3.5: persist the opaque lease; a fresh lease clears
8289                    // any prior lease error (the outage/revocation is over).
8290                    self.set_lease_state(Some(LeaseState {
8291                        lease_id: Some(lease_id),
8292                        expires_at_ms: Some(expires_at_ms),
8293                        error_code: None,
8294                    }));
8295                }
8296                Frame::Error { code, message, .. } => {
8297                    // §1.6: the whole request failed; already-completed
8298                    // subscriptions keep their applied data and cursors.
8299                    failure = Some((code, message));
8300                    break;
8301                }
8302                Frame::Unknown { .. } => {}
8303                _ => {
8304                    failure = Some((
8305                        "sync.invalid_request".to_owned(),
8306                        "unexpected frame in response".to_owned(),
8307                    ));
8308                    break;
8309                }
8310            }
8311        }
8312
8313        // §7.1: reconcile the visible overlay once at the response boundary
8314        // after all outbox acknowledgements and server data applied so a
8315        // batch of PUSH_RESULT frames cannot trigger repeated full-table
8316        // rebuilds. This also covers a round that aborted mid-way.
8317        if self.overlay_dirty.get() {
8318            self.rebuild_overlay();
8319        }
8320        if let Some((error_code, message)) = failure {
8321            return SyncOutcome::Failed {
8322                error_code,
8323                message,
8324            };
8325        }
8326        // §4.8 E1: the push half may have drained commits that pinned rows of
8327        // a shrunk window unit — retry any deferred evictions now.
8328        if let Err(message) = self.drain_pending_evictions() {
8329            return SyncOutcome::Failed {
8330                error_code: "storage.failed".into(),
8331                message,
8332            };
8333        }
8334        self.ack_after_pull(transport);
8335        // §7.4.5: the reset is over once the first post-reset pull round
8336        // leaves no subscription mid-bootstrap — the tables are rebuilt.
8337        if self.upgrading && report.bootstrapping.is_empty() {
8338            self.set_upgrading(false);
8339        }
8340        SyncOutcome::Ok(report)
8341    }
8342
8343    // -- push results (§6.3, §7.2) ------------------------------------------------
8344
8345    fn handle_push_results(
8346        &mut self,
8347        frames: &[Frame],
8348        pushed_ids: &HashSet<&str>,
8349        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
8350        report: &mut SyncReport,
8351        last_final_push_result_id: Option<&str>,
8352    ) -> Result<(), String> {
8353        self.begin_observation("syncular_push_results")?;
8354        let conflict_count = self.conflicts.len();
8355        let rejection_count = self.rejections.len();
8356        let applied_count = report.applied.len();
8357        let rejected_count = report.rejected.len();
8358        let retryable_count = report.retryable.len();
8359        let was_dirty = self.overlay_dirty.get();
8360        let mut removed = Vec::new();
8361        let mut batch = ChangeAccumulator::default();
8362        let persisted = (|| {
8363            for frame in frames {
8364                let Frame::PushResult {
8365                    client_commit_id,
8366                    status,
8367                    results,
8368                    ..
8369                } = frame
8370                else {
8371                    return Err("unexpected acknowledgement frame".to_owned());
8372                };
8373                if !pushed_ids.contains(client_commit_id.as_str()) {
8374                    continue;
8375                }
8376                let Some(index) = self
8377                    .outbox
8378                    .iter()
8379                    .position(|commit| commit.client_commit_id == *client_commit_id)
8380                else {
8381                    continue;
8382                };
8383                let status = *status;
8384                let rejection_details = rejection_details_by_commit.get(client_commit_id);
8385                let operations = self.outbox[index].ops.clone();
8386                match status {
8387                    PushStatus::Applied | PushStatus::Cached => {
8388                        // §7.2: a lost ack replays as `cached` — proceed as if the
8389                        // ack had arrived.
8390                        let journal_results = results
8391                            .iter()
8392                            .map(|result| {
8393                                let op_index = match result {
8394                                    OpResult::Applied { op_index }
8395                                    | OpResult::Conflict { op_index, .. }
8396                                    | OpResult::Error { op_index, .. } => *op_index,
8397                                };
8398                                CommitOperationOutcome::Applied { op_index }
8399                            })
8400                            .collect::<Vec<_>>();
8401                        let outcome_status = if status == PushStatus::Applied {
8402                            CommitOutcomeStatus::Applied
8403                        } else {
8404                            CommitOutcomeStatus::Cached
8405                        };
8406                        let persisted = self
8407                            .persist_commit_outcome(
8408                                client_commit_id,
8409                                outcome_status,
8410                                &journal_results,
8411                                None,
8412                            )
8413                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
8414                        persisted?;
8415                        self.overlay_dirty.set(true);
8416                        batch.status = true;
8417                        batch.outcomes = true;
8418                    }
8419                    PushStatus::Rejected => {
8420                        if results.iter().any(|result| {
8421                            matches!(
8422                                result,
8423                                OpResult::Error {
8424                                    code,
8425                                    retryable: true,
8426                                    ..
8427                                } if code == "sync.idempotency_cache_miss"
8428                            )
8429                        }) {
8430                            // §6.3/§7.2: a serving failure, not an outcome — keep the
8431                            // exact commit queued for an identical retry.
8432                            report.retryable.push(client_commit_id.to_owned());
8433                            continue;
8434                        }
8435
8436                        let mut journal_results = Vec::with_capacity(results.len());
8437                        let mut conflicts = Vec::new();
8438                        let mut rejections = Vec::new();
8439                        for result in results {
8440                            match result {
8441                                OpResult::Applied { op_index } => {
8442                                    journal_results.push(CommitOperationOutcome::Applied {
8443                                        op_index: *op_index,
8444                                    });
8445                                }
8446                                OpResult::Conflict {
8447                                    op_index,
8448                                    code,
8449                                    message,
8450                                    server_version,
8451                                    server_row,
8452                                } => {
8453                                    let operation = operations
8454                                        .get(*op_index as usize)
8455                                        .map(CommitOperation::from);
8456                                    let (table, row_id) = operation
8457                                        .as_ref()
8458                                        .map(|op| (op.table.clone(), op.row_id.clone()))
8459                                        .unwrap_or_default();
8460                                    let server_row_json = self
8461                                        .schema
8462                                        .table(&table)
8463                                        .and_then(|t| {
8464                                            decode_row_bytes(t, server_row, &self.encryption)
8465                                                .ok()
8466                                                .map(|row| (t, row))
8467                                        })
8468                                        .map(|(t, row)| {
8469                                            let mut map = Map::new();
8470                                            for (i, column) in t.columns.iter().enumerate() {
8471                                                map.insert(
8472                                                    column.name.clone(),
8473                                                    column_value_to_json(
8474                                                        row.get(i).unwrap_or(&None),
8475                                                    ),
8476                                                );
8477                                            }
8478                                            map
8479                                        })
8480                                        .unwrap_or_default();
8481                                    let conflict = ConflictRecord {
8482                                        client_commit_id: client_commit_id.to_owned(),
8483                                        op_index: *op_index,
8484                                        table,
8485                                        row_id,
8486                                        code: code.clone(),
8487                                        message: message.clone(),
8488                                        server_version: *server_version,
8489                                        server_row: server_row_json,
8490                                        operation,
8491                                    };
8492                                    journal_results.push(CommitOperationOutcome::Conflict {
8493                                        conflict: conflict.clone(),
8494                                    });
8495                                    conflicts.push(conflict);
8496                                }
8497                                OpResult::Error {
8498                                    op_index,
8499                                    code,
8500                                    message,
8501                                    retryable,
8502                                } => {
8503                                    let rejection = RejectionRecord {
8504                                        client_commit_id: client_commit_id.to_owned(),
8505                                        op_index: *op_index,
8506                                        code: code.clone(),
8507                                        message: message.clone(),
8508                                        retryable: *retryable,
8509                                        details: rejection_details
8510                                            .and_then(|details| details.get(op_index))
8511                                            .cloned(),
8512                                        operation: operations
8513                                            .get(*op_index as usize)
8514                                            .map(CommitOperation::from),
8515                                    };
8516                                    journal_results.push(CommitOperationOutcome::Error {
8517                                        rejection: rejection.clone(),
8518                                    });
8519                                    rejections.push(rejection);
8520                                }
8521                            }
8522                        }
8523                        let outcome_status = if conflicts.is_empty() {
8524                            CommitOutcomeStatus::Rejected
8525                        } else {
8526                            CommitOutcomeStatus::Conflict
8527                        };
8528                        let persisted = self
8529                            .persist_commit_outcome(
8530                                client_commit_id,
8531                                outcome_status,
8532                                &journal_results,
8533                                Some(&operations),
8534                            )
8535                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
8536                        persisted?;
8537                        batch.conflicts = !conflicts.is_empty();
8538                        batch.rejections = !rejections.is_empty();
8539                        batch.status = true;
8540                        batch.outcomes = true;
8541                        self.conflicts.extend(conflicts);
8542                        self.rejections.extend(rejections);
8543                        self.overlay_dirty.set(true);
8544                    }
8545                }
8546                removed.push((index, self.outbox.remove(index)));
8547                if batch.status {
8548                    for operation in &operations {
8549                        if !self.record_row_scopes(
8550                            &mut batch,
8551                            &operation.table,
8552                            &operation.row_id,
8553                            false,
8554                        ) {
8555                            batch.table(&operation.table);
8556                        }
8557                    }
8558                }
8559                match status {
8560                    PushStatus::Applied | PushStatus::Cached => {
8561                        report.applied.push(client_commit_id.to_owned())
8562                    }
8563                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
8564                }
8565            }
8566            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)) {
8567                self.prune_commit_outcomes()?;
8568            }
8569            self.finish_observation("syncular_push_results", batch)
8570        })();
8571        if let Err(error) = persisted {
8572            self.rollback_observation("syncular_push_results");
8573            for (index, commit) in removed.into_iter().rev() {
8574                self.outbox.insert(index, commit);
8575            }
8576            self.conflicts.truncate(conflict_count);
8577            self.rejections.truncate(rejection_count);
8578            self.overlay_dirty.set(was_dirty);
8579            report.applied.truncate(applied_count);
8580            report.rejected.truncate(rejected_count);
8581            report.retryable.truncate(retryable_count);
8582            return Err(error);
8583        }
8584        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
8585        Ok(())
8586    }
8587
8588    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
8589        status != PushStatus::Rejected
8590            || !results.iter().any(|result| {
8591                matches!(
8592                    result,
8593                    OpResult::Error {
8594                        code,
8595                        retryable: true,
8596                        ..
8597                    } if code == "sync.idempotency_cache_miss"
8598                )
8599            })
8600    }
8601
8602    // -- subscription sections ------------------------------------------------------
8603
8604    #[allow(clippy::too_many_arguments)]
8605    fn process_section(
8606        &mut self,
8607        transport: &mut dyn Transport,
8608        id: &str,
8609        status: SubStatus,
8610        reason_code: &str,
8611        effective_scopes: Vec<(String, Vec<String>)>,
8612        body: Vec<Frame>,
8613        sub_end: Option<(i64, Option<String>)>,
8614        meta: &RequestMeta,
8615        report: &mut SyncReport,
8616    ) -> Result<(), SectionError> {
8617        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
8618            return Ok(()); // unknown echo: ignore
8619        };
8620        if status != SubStatus::Active && sub_end.is_none() {
8621            return Err(SectionError::Abort(
8622                "sync.invalid_request".into(),
8623                "subscription section without SUB_END".into(),
8624            ));
8625        }
8626        match status {
8627            SubStatus::Revoked => {
8628                self.begin_observation("syncular_revocation")
8629                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
8630                let previous = self.subs[sub_index].clone();
8631                let was_dirty = self.overlay_dirty.get();
8632                let previous_outbox = self.outbox.clone();
8633                let rejection_count = self.rejections.len();
8634                let revoked_count = report.revoked.len();
8635                let failed_count = report.failed.len();
8636                let result =
8637                    (|| {
8638                        let mut batch = ChangeAccumulator::default();
8639                        let registered = self.window_unit_by_sub_id(id);
8640                        // §3.3: stop pulling, purge exactly the last effective grant.
8641                        let (table, effective) = {
8642                            let sub = &self.subs[sub_index];
8643                            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
8644                        };
8645                        let purged = self.purge_scope_rows(&table, &effective);
8646                        match purged {
8647                            Ok(()) => {
8648                                self.record_scope_map(&mut batch, &table, &effective);
8649                                let sub = &mut self.subs[sub_index];
8650                                sub.state = SubState::Revoked;
8651                                sub.reason_code = Some(if reason_code.is_empty() {
8652                                    "sync.scope_revoked".to_owned()
8653                                } else {
8654                                    reason_code.to_owned()
8655                                });
8656                                report.revoked.push(id.to_owned());
8657                                let doomed_effective = effective;
8658                                let sub_table = table;
8659                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
8660                                    |message| SectionError::Abort("storage.failed".into(), message),
8661                                )?;
8662                                let dropped = self
8663                                    .drop_doomed_outbox(&sub_table, &doomed_effective)
8664                                    .map_err(|message| {
8665                                        SectionError::Abort("storage.failed".to_owned(), message)
8666                                    })?;
8667                                if dropped {
8668                                    batch.status = true;
8669                                    batch.rejections = true;
8670                                    batch.outcomes = true;
8671                                }
8672                                // §5.9.7 B2: revocation deletes now-unauthorized blob
8673                                // bodies (evicted ≠ revoked).
8674                                self.delete_unreferenced_cached_blobs();
8675                            }
8676                            Err(()) => {
8677                                // §3.3 fail closed: no local mapping — never clear by
8678                                // approximation; fatal configuration error.
8679                                let sub = &mut self.subs[sub_index];
8680                                sub.state = SubState::Failed;
8681                                sub.reason_code = Some("sync.scope_revoked".to_owned());
8682                                report.failed.push(id.to_owned());
8683                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
8684                                    |message| SectionError::Abort("storage.failed".into(), message),
8685                                )?;
8686                            }
8687                        }
8688                        if let Some((base_key, unit)) = registered {
8689                            batch.window(&base_key, &self.subs[sub_index].table, &unit);
8690                        }
8691                        self.rebuild_overlay_if_dirty();
8692                        self.finish_observation("syncular_revocation", batch)
8693                            .map_err(|message| {
8694                                SectionError::Abort("storage.failed".to_owned(), message)
8695                            })?;
8696                        Ok(())
8697                    })();
8698                if result.is_err() {
8699                    self.rollback_observation("syncular_revocation");
8700                    self.subs[sub_index] = previous;
8701                    self.overlay_dirty.set(was_dirty);
8702                    self.outbox = previous_outbox;
8703                    self.rejections.truncate(rejection_count);
8704                    report.revoked.truncate(revoked_count);
8705                    report.failed.truncate(failed_count);
8706                }
8707                result
8708            }
8709            SubStatus::Reset => {
8710                self.begin_observation("syncular_reset")
8711                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
8712                let previous = self.subs[sub_index].clone();
8713                let was_dirty = self.overlay_dirty.get();
8714                let reset_count = report.resets.len();
8715                let result = (|| {
8716                    let mut batch = ChangeAccumulator::default();
8717                    let registered = self.window_unit_by_sub_id(id);
8718                    // §4.6: discard cursor + bootstrap state, keep local rows —
8719                    // reset is a staleness signal, not a purge signal.
8720                    let sub = &mut self.subs[sub_index];
8721                    sub.cursor = -1;
8722                    sub.bootstrap_state = None;
8723                    report.resets.push(id.to_owned());
8724                    self.persist_sub(&self.subs[sub_index].clone())
8725                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
8726                    if let Some((base_key, unit)) = registered {
8727                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
8728                    }
8729                    self.finish_observation("syncular_reset", batch)
8730                        .map_err(|message| {
8731                            SectionError::Abort("storage.failed".to_owned(), message)
8732                        })?;
8733                    Ok(())
8734                })();
8735                if result.is_err() {
8736                    self.rollback_observation("syncular_reset");
8737                    self.subs[sub_index] = previous;
8738                    self.overlay_dirty.set(was_dirty);
8739                    report.resets.truncate(reset_count);
8740                }
8741                result
8742            }
8743            SubStatus::Active => {
8744                let fresh = meta
8745                    .fresh
8746                    .iter()
8747                    .find(|(fid, _)| fid == id)
8748                    .map(|(_, fresh)| *fresh)
8749                    .unwrap_or(false);
8750                let previous = self.subs[sub_index].clone();
8751                self.subs[sub_index].effective = Some(effective_scopes);
8752                let outcome = self
8753                    .apply_section_body(transport, sub_index, body, fresh, meta, report)
8754                    .and_then(|()| {
8755                        let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
8756                            SectionError::Abort(
8757                                "sync.invalid_request".into(),
8758                                "subscription section without SUB_END".into(),
8759                            )
8760                        })?;
8761                        self.apply_sub_end(sub_index, cursor, bootstrap_state)
8762                            .map_err(|message| {
8763                                SectionError::Abort("storage.failed".into(), message)
8764                            })
8765                    });
8766                match outcome {
8767                    Ok(()) => {
8768                        if self.subs[sub_index].bootstrap_state.is_some() {
8769                            report.bootstrapping.push(id.to_owned());
8770                        }
8771                        Ok(())
8772                    }
8773                    Err(SectionError::FailClosed) => {
8774                        self.subs[sub_index] = previous.clone();
8775                        self.begin_observation("syncular_section_failure")
8776                            .map_err(|message| {
8777                                SectionError::Abort("storage.failed".into(), message)
8778                            })?;
8779                        let persisted = (|| {
8780                            let mut batch = ChangeAccumulator::default();
8781                            if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
8782                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
8783                            }
8784                            self.subs[sub_index].state = SubState::Failed;
8785                            self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
8786                            self.persist_sub(&self.subs[sub_index])?;
8787                            self.finish_observation("syncular_section_failure", batch)
8788                        })();
8789                        if let Err(message) = persisted {
8790                            self.rollback_observation("syncular_section_failure");
8791                            self.subs[sub_index] = previous;
8792                            return Err(SectionError::Abort("storage.failed".into(), message));
8793                        }
8794                        report.failed.push(id.to_owned());
8795                        Ok(())
8796                    }
8797                    Err(error) => {
8798                        // Frame/block transactions already completed. Preserve their
8799                        // rows and revisions, but leave this subscription cursor unchanged.
8800                        self.subs[sub_index] = previous;
8801                        Err(error)
8802                    }
8803                }
8804            }
8805        }
8806    }
8807
8808    fn apply_sub_end(
8809        &mut self,
8810        sub_index: usize,
8811        cursor: i64,
8812        bootstrap_state: Option<String>,
8813    ) -> Result<(), String> {
8814        #[cfg(feature = "bench-internals")]
8815        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
8816        let previous = self.subs[sub_index].clone();
8817        self.begin_observation("syncular_sub_end")?;
8818        let persisted = (|| {
8819            let mut batch = ChangeAccumulator::default();
8820            let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
8821                && cursor >= 0
8822                && bootstrap_state.is_none();
8823            if completed {
8824                if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
8825                    batch.window(&base_key, &previous.table, &unit);
8826                }
8827            }
8828            let sub = &mut self.subs[sub_index];
8829            sub.cursor = cursor;
8830            sub.bootstrap_state = bootstrap_state;
8831            sub.synced_once = true;
8832            self.persist_sub(&self.subs[sub_index])?;
8833            self.finish_observation("syncular_sub_end", batch)
8834        })();
8835        if persisted.is_err() {
8836            self.rollback_observation("syncular_sub_end");
8837            self.subs[sub_index] = previous;
8838        }
8839        persisted
8840    }
8841
8842    fn apply_section_body(
8843        &mut self,
8844        transport: &mut dyn Transport,
8845        sub_index: usize,
8846        body: Vec<Frame>,
8847        fresh: bool,
8848        meta: &RequestMeta,
8849        report: &mut SyncReport,
8850    ) -> Result<(), SectionError> {
8851        let mut saw_segment = false;
8852        for frame in body {
8853            match frame {
8854                Frame::Commit {
8855                    tables, changes, ..
8856                } => {
8857                    self.apply_commit_frame(&tables, &changes)
8858                        .map_err(|(c, m)| SectionError::Abort(c, m))?;
8859                    report.commits_applied += 1;
8860                }
8861                Frame::SegmentInline { payload } => {
8862                    let segment = decode_rows_segment(&payload)
8863                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
8864                    self.progress.update(|p| {
8865                        p.phase = ProgressPhase::Import;
8866                        p.subscription_id = Some(self.subs[sub_index].id.clone());
8867                        p.table = Some(self.subs[sub_index].table.clone());
8868                        p.segment_id = None;
8869                        p.bytes_received = payload.len() as u64;
8870                        p.bytes_total = Some(payload.len() as u64);
8871                        p.rows_processed = 0;
8872                        p.rows_total =
8873                            Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
8874                    });
8875                    let first = !saw_segment;
8876                    saw_segment = true;
8877                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
8878                    report.segment_rows_applied += applied;
8879                }
8880                Frame::SegmentRef {
8881                    segment_id,
8882                    byte_length,
8883                    media_type,
8884                    table,
8885                    row_count,
8886                    as_of_commit_seq,
8887                    scope_digest,
8888                    row_cursor,
8889                    next_row_cursor,
8890                    url,
8891                    url_expires_at_ms,
8892                    ..
8893                } => {
8894                    // §4.2: reject a descriptor whose mediaType was not
8895                    // advertised — never skip or guess.
8896                    let advertised = match media_type {
8897                        MediaType::Rows => {
8898                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
8899                                || meta.accept & ACCEPT_INLINE_ROWS != 0
8900                        }
8901                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
8902                    };
8903                    if !advertised {
8904                        return Err(SectionError::Abort(
8905                            "sync.invalid_request".to_owned(),
8906                            format!(
8907                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
8908                                media_type.name()
8909                            ),
8910                        ));
8911                    }
8912                    self.progress.update(|p| {
8913                        p.phase = ProgressPhase::Download;
8914                        p.subscription_id = Some(self.subs[sub_index].id.clone());
8915                        p.table = Some(table.clone());
8916                        p.segment_id = Some(segment_id.clone());
8917                        p.bytes_received = 0;
8918                        p.bytes_total = u64::try_from(byte_length).ok();
8919                        p.rows_processed = 0;
8920                        p.rows_total = u64::try_from(row_count).ok();
8921                    });
8922                    let observer = self.progress.clone();
8923                    let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
8924                    let bytes = if let Some(url) = url {
8925                        // §5.4: a url-carrying descriptor MUST be fetched
8926                        // from that URL; failure invalidates the whole
8927                        // descriptor (no fall-through to §5.5 — re-pull
8928                        // recovers, §1.4 rule 5).
8929                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
8930                            return Err(SectionError::Abort(
8931                                "sync.invalid_request".to_owned(),
8932                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
8933                                    .to_owned(),
8934                            ));
8935                        }
8936                        // §5.4: MUST NOT start a fetch at/past expiry.
8937                        if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
8938                            return Err(SectionError::Abort(
8939                                "sync.segment_expired".to_owned(),
8940                                format!(
8941                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
8942                                ),
8943                            ));
8944                        }
8945                        transport
8946                            .fetch_url(&url, &mut on_progress)
8947                            .map_err(|e| SectionError::Abort(e.code, e.message))?
8948                    } else {
8949                        let requested_scopes_json =
8950                            canonical_scope_json(&self.subs[sub_index].requested);
8951                        transport
8952                            .download_segment(
8953                                &SegmentRequest {
8954                                    segment_id: segment_id.clone(),
8955                                    table,
8956                                    requested_scopes_json,
8957                                },
8958                                &mut on_progress,
8959                            )
8960                            .map_err(|e| SectionError::Abort(e.code, e.message))?
8961                    };
8962                    // §5.1: verify the content address before applying.
8963                    on_progress(bytes.len() as u64);
8964                    let digest = Sha256::digest(&bytes);
8965                    let expected = segment_id
8966                        .strip_prefix("sha256:")
8967                        .unwrap_or(segment_id.as_str());
8968                    if bytes_to_hex(&digest) != expected {
8969                        return Err(SectionError::Abort(
8970                            "sync.invalid_request".to_owned(),
8971                            "segment bytes do not match the content address (§5.1)".to_owned(),
8972                        ));
8973                    }
8974                    self.progress.update(|p| p.phase = ProgressPhase::Import);
8975                    if media_type == MediaType::Sqlite {
8976                        // §5.3: images are whole-table — a paged sqlite
8977                        // descriptor is invalid.
8978                        if row_cursor.is_some() || next_row_cursor.is_some() {
8979                            return Err(SectionError::Abort(
8980                                "sync.invalid_request".to_owned(),
8981                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
8982                                    .to_owned(),
8983                            ));
8984                        }
8985                        let first = !saw_segment;
8986                        saw_segment = true;
8987                        let applied = self.apply_sqlite_segment(
8988                            sub_index,
8989                            &bytes,
8990                            fresh && first,
8991                            row_count,
8992                            as_of_commit_seq,
8993                            &scope_digest,
8994                        )?;
8995                        report.segment_rows_applied += applied;
8996                    } else {
8997                        let segment = decode_rows_segment(&bytes).map_err(|e| {
8998                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
8999                        })?;
9000                        let first = row_cursor.is_none();
9001                        saw_segment = true;
9002                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
9003                        report.segment_rows_applied += applied;
9004                    }
9005                }
9006                Frame::Unknown { .. } => {}
9007                Frame::Error { code, message, .. } => {
9008                    return Err(SectionError::Abort(code, message))
9009                }
9010                _ => {
9011                    return Err(SectionError::Abort(
9012                        "sync.invalid_request".to_owned(),
9013                        "unexpected frame inside a subscription section".to_owned(),
9014                    ));
9015                }
9016            }
9017        }
9018        Ok(())
9019    }
9020
9021    // Both pull and realtime frames use the same durable observation boundary.
9022    fn apply_commit_frame(
9023        &mut self,
9024        tables: &[String],
9025        changes: &[ssp2::model::Change],
9026    ) -> Result<(), (String, String)> {
9027        #[cfg(feature = "bench-internals")]
9028        let _phase = self.benchmark_phases.start(Phase::CommitApply);
9029        let was_dirty = self.overlay_dirty.get();
9030        self.begin_observation("syncular_commit")
9031            .map_err(|message| ("storage.failed".into(), message))?;
9032        let applied = (|| {
9033            let mut batch = ChangeAccumulator::default();
9034            self.record_commit_changes(&mut batch, tables, changes);
9035            self.apply_commit_changes(tables, changes)?;
9036            self.rebuild_overlay_if_dirty();
9037            self.finish_observation("syncular_commit", batch)
9038                .map_err(|message| ("storage.failed".into(), message))
9039        })();
9040        if applied.is_err() {
9041            self.rollback_observation("syncular_commit");
9042            self.overlay_dirty.set(was_dirty);
9043        }
9044        applied
9045    }
9046
9047    fn apply_commit_changes(
9048        &mut self,
9049        tables: &[String],
9050        changes: &[ssp2::model::Change],
9051    ) -> Result<(), (String, String)> {
9052        // A clean overlay can reconcile changed primary keys independently
9053        // when their tables have no secondary unique constraints. Such a
9054        // constraint can change whether another row's pending write succeeds,
9055        // so those frames still require the complete FIFO replay below.
9056        let mirror_visible = !self.overlay_dirty.get()
9057            && (self.outbox.is_empty()
9058                || changes.iter().all(|change| {
9059                    tables
9060                        .get(change.table_index as usize)
9061                        .and_then(|name| self.schema.table(name))
9062                        .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
9063                }));
9064        for change in changes {
9065            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
9066                (
9067                    "sync.invalid_request".to_owned(),
9068                    "change tableIndex out of range".to_owned(),
9069                )
9070            })?;
9071            let table = self.schema.table(table_name).ok_or_else(|| {
9072                (
9073                    "sync.schema_mismatch".to_owned(),
9074                    format!("change targets unknown table {table_name:?}"),
9075                )
9076            })?;
9077            match change.op {
9078                Op::Upsert => {
9079                    let payload = change.row.as_ref().ok_or_else(|| {
9080                        (
9081                            "sync.invalid_request".to_owned(),
9082                            "upsert change without row payload".to_owned(),
9083                        )
9084                    })?;
9085                    // §5.11: decrypt encrypted columns on apply.
9086                    let row = {
9087                        #[cfg(feature = "bench-internals")]
9088                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
9089                        decode_row_bytes(table, payload, &self.encryption)
9090                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?
9091                    };
9092                    let version = change.row_version.unwrap_or(0);
9093                    let table_name = table.name.clone();
9094                    self.write_base_row(&table_name, &row, version)
9095                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
9096                    if mirror_visible {
9097                        self.write_row(&visible_table(&table_name), &table_name, &row, version)
9098                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
9099                    }
9100                }
9101                Op::Delete => {
9102                    self.delete_base_row(table_name, &change.row_id)
9103                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
9104                    if mirror_visible {
9105                        let sql = format!(
9106                            "DELETE FROM {} WHERE {}",
9107                            visible_table(table_name),
9108                            row_id_predicate(table)
9109                        );
9110                        self.conn
9111                            .prepare_cached(&sql)
9112                            .and_then(|mut statement| {
9113                                statement.execute(rusqlite::params![change.row_id])
9114                            })
9115                            .map_err(|error| {
9116                                ("sync.invalid_request".to_owned(), error.to_string())
9117                            })?;
9118                    }
9119                }
9120            }
9121        }
9122        if mirror_visible {
9123            if !self.outbox.is_empty() {
9124                let changed_rows: HashSet<(&str, &str)> = changes
9125                    .iter()
9126                    .map(|change| {
9127                        (
9128                            tables[change.table_index as usize].as_str(),
9129                            change.row_id.as_str(),
9130                        )
9131                    })
9132                    .collect();
9133                self.apply_outbox_ops(
9134                    self.outbox
9135                        .iter()
9136                        .flat_map(|commit| &commit.ops)
9137                        .filter(|op| {
9138                            changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
9139                        }),
9140                );
9141            }
9142            self.overlay_dirty.set(false);
9143        }
9144        Ok(())
9145    }
9146
9147    /// §5.6 segment application: validate against the generated schema,
9148    /// clear the grant on a fresh bootstrap's first page (fail closed
9149    /// without a mapping), then replace-or-upsert each row with its
9150    /// segment-carried server version (§5.2).
9151    fn apply_segment(
9152        &mut self,
9153        sub_index: usize,
9154        segment: &RowsSegment,
9155        first_fresh_page: bool,
9156    ) -> Result<u32, SectionError> {
9157        let (sub_table, effective) = {
9158            let sub = &self.subs[sub_index];
9159            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
9160        };
9161        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
9162            SectionError::Abort(
9163                "sync.schema_mismatch".to_owned(),
9164                format!("subscription table {sub_table:?} is not in the client schema"),
9165            )
9166        })?;
9167        // §5.2: the column table validates against the generated schema —
9168        // order, names, types, nullability; mismatch is fatal. §5.11: the
9169        // server sends the WIRE types (bytes for an encrypted column), so
9170        // validate against wire_columns.
9171        let matches = segment.table == table.name
9172            && segment.schema_version == self.schema.version
9173            && segment.columns.len() == table.wire_columns.len()
9174            && segment
9175                .columns
9176                .iter()
9177                .zip(table.wire_columns.iter())
9178                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
9179        if !matches {
9180            return Err(SectionError::Abort(
9181                "sync.schema_mismatch".to_owned(),
9182                "segment column table does not match the generated schema (§5.2)".to_owned(),
9183            ));
9184        }
9185        let mut applied = 0u32;
9186        let empty = Vec::new();
9187        let blocks = if segment.blocks.is_empty() {
9188            std::slice::from_ref(&empty)
9189        } else {
9190            &segment.blocks
9191        };
9192        for (index, block) in blocks.iter().enumerate() {
9193            let clear = first_fresh_page && index == 0;
9194            if !clear && block.is_empty() {
9195                continue;
9196            }
9197            let was_dirty = self.overlay_dirty.get();
9198            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
9199                && self
9200                    .outbox
9201                    .iter()
9202                    .flat_map(|commit| &commit.ops)
9203                    .any(|op| op.table == table.name);
9204            let mirror_visible = !reconcile_pending;
9205            self.begin_observation("syncular_segment_block")
9206                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9207            let outcome = (|| {
9208                self.rebuild_overlay_if_dirty();
9209                let mut changed_keys = Vec::new();
9210                let mut batch = ChangeAccumulator::default();
9211                if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
9212                    batch.table(&table.name);
9213                }
9214                if clear {
9215                    self.purge_scope_rows(&table.name, &effective)
9216                        .map_err(|()| SectionError::FailClosed)?;
9217                }
9218                for (row_index, row) in block.iter().enumerate() {
9219                    let decrypted;
9220                    let values = if table.has_encrypted_columns() {
9221                        let mut values = row.values.clone();
9222                        crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
9223                            .map_err(|message| {
9224                            SectionError::Abort("client.decrypt_failed".into(), message)
9225                        })?;
9226                        decrypted = values;
9227                        &decrypted
9228                    } else {
9229                        &row.values
9230                    };
9231                    self.write_base_row(&table.name, values, row.server_version)
9232                        .map_err(|message| {
9233                            SectionError::Abort("sync.invalid_request".into(), message)
9234                        })?;
9235                    if reconcile_pending {
9236                        changed_keys.push(
9237                            owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
9238                                |message| SectionError::Abort("storage.failed".into(), message),
9239                            )?,
9240                        );
9241                    }
9242                    if mirror_visible {
9243                        self.write_row(
9244                            &visible_table(&table.name),
9245                            &table.name,
9246                            values,
9247                            row.server_version,
9248                        )
9249                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9250                    }
9251                    let processed = u64::from(applied) + row_index as u64 + 1;
9252                    if processed.is_multiple_of(1024) {
9253                        self.progress.update(|p| p.rows_processed = processed);
9254                    }
9255                }
9256                if reconcile_pending {
9257                    self.reconcile_imported_rows(&table, &mut changed_keys)
9258                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9259                }
9260                self.apply_outbox_ops(
9261                    self.outbox
9262                        .iter()
9263                        .flat_map(|commit| &commit.ops)
9264                        .filter(|op| op.table == table.name),
9265                );
9266                self.overlay_dirty.set(false);
9267                self.finish_observation("syncular_segment_block", batch)
9268                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
9269            })();
9270            if let Err(error) = outcome {
9271                self.rollback_observation("syncular_segment_block");
9272                self.overlay_dirty.set(was_dirty);
9273                return Err(error);
9274            }
9275            applied += block.len() as u32;
9276            self.progress
9277                .update(|p| p.rows_processed = u64::from(applied));
9278            std::thread::yield_now();
9279        }
9280        self.progress
9281            .update(|p| p.rows_processed = u64::from(applied));
9282        Ok(applied)
9283    }
9284
9285    /// §5.3 sqlite-image application: validate the in-file metadata
9286    /// against the descriptor, validate column names/order against the
9287    /// generated schema, run the §5.6 first-page clear when fresh, then
9288    /// replace-or-upsert every image row with its `_syncular_version`.
9289    /// Mechanics: the image lands in a temp file read through a second
9290    /// rusqlite connection, equivalent to ATTACH + INSERT…SELECT.
9291    /// Each image chunk commits its writes and revision before readers continue.
9292    fn apply_sqlite_segment(
9293        &mut self,
9294        sub_index: usize,
9295        bytes: &[u8],
9296        first_fresh_page: bool,
9297        row_count: i64,
9298        as_of_commit_seq: i64,
9299        scope_digest: &str,
9300    ) -> Result<u32, SectionError> {
9301        let invalid = |detail: &str| {
9302            SectionError::Abort(
9303                "sync.invalid_request".to_owned(),
9304                format!("sqlite segment rejected: {detail} (§5.3)"),
9305            )
9306        };
9307        let (sub_table, effective) = {
9308            let sub = &self.subs[sub_index];
9309            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
9310        };
9311        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
9312            SectionError::Abort(
9313                "sync.schema_mismatch".to_owned(),
9314                format!("subscription table {sub_table:?} is not in the client schema"),
9315            )
9316        })?;
9317
9318        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
9319        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
9320        let img = match rusqlite::Connection::open_with_flags(
9321            &path,
9322            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
9323        ) {
9324            Ok(conn) => conn,
9325            Err(_) => {
9326                let _ = std::fs::remove_file(&path);
9327                return Err(invalid("bytes do not open as a SQLite database"));
9328            }
9329        };
9330        let outcome = self.apply_sqlite_image(
9331            &img,
9332            &table,
9333            first_fresh_page,
9334            &effective,
9335            row_count,
9336            as_of_commit_seq,
9337            scope_digest,
9338        );
9339        drop(img);
9340        let _ = std::fs::remove_file(&path);
9341        outcome
9342    }
9343
9344    #[allow(clippy::too_many_arguments)]
9345    fn apply_sqlite_image(
9346        &mut self,
9347        img: &rusqlite::Connection,
9348        table: &crate::schema::TableSchema,
9349        first_fresh_page: bool,
9350        effective: &[(String, Vec<String>)],
9351        row_count: i64,
9352        as_of_commit_seq: i64,
9353        scope_digest: &str,
9354    ) -> Result<u32, SectionError> {
9355        let invalid = |detail: String| {
9356            SectionError::Abort(
9357                "sync.invalid_request".to_owned(),
9358                format!("sqlite segment rejected: {detail} (§5.3)"),
9359            )
9360        };
9361
9362        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
9363        //    one row).
9364        type MetaRow = (i64, String, i64, i64, String, i64, i64);
9365        let meta: MetaRow = img
9366            .query_row(
9367                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
9368                        \"scopeDigest\", \"rowCount\",
9369                        (SELECT count(*) FROM _syncular_segment)
9370                 FROM _syncular_segment",
9371                [],
9372                |row| {
9373                    Ok((
9374                        row.get(0)?,
9375                        row.get(1)?,
9376                        row.get(2)?,
9377                        row.get(3)?,
9378                        row.get(4)?,
9379                        row.get(5)?,
9380                        row.get(6)?,
9381                    ))
9382                },
9383            )
9384            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
9385        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
9386        if meta_count != 1 {
9387            return Err(invalid(format!(
9388                "_syncular_segment must contain exactly one row, found {meta_count}"
9389            )));
9390        }
9391        if format != 1 {
9392            return Err(invalid(format!("format {format}")));
9393        }
9394        if meta_table != table.name {
9395            return Err(invalid(format!("image table {meta_table:?}")));
9396        }
9397        if schema_version != i64::from(self.schema.version) {
9398            return Err(invalid(format!("schemaVersion {schema_version}")));
9399        }
9400        if pin != as_of_commit_seq {
9401            return Err(invalid(format!("asOfCommitSeq {pin}")));
9402        }
9403        if digest != scope_digest {
9404            return Err(invalid("scopeDigest mismatch".to_owned()));
9405        }
9406        if meta_rows != row_count {
9407            return Err(invalid(format!("rowCount {meta_rows}")));
9408        }
9409
9410        // 2. Column names and order vs the generated schema (§5.3 rule 3).
9411        let mut names: Vec<String> = Vec::new();
9412        let mut primary_keys: Vec<String> = Vec::new();
9413        {
9414            let mut stmt = img
9415                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
9416                .map_err(|_| invalid("image data table missing".to_owned()))?;
9417            let mut rows = stmt
9418                .query([])
9419                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
9420            while let Some(row) = rows
9421                .next()
9422                .map_err(|_| invalid("image data table unreadable".to_owned()))?
9423            {
9424                if row.get::<_, i64>(5).map_err(|e| invalid(e.to_string()))? > 0 {
9425                    primary_keys.push(row.get(1).map_err(|e| invalid(e.to_string()))?);
9426                }
9427                names.push(
9428                    row.get::<_, String>(1)
9429                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
9430                );
9431            }
9432        }
9433        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
9434        expected.push("_syncular_version");
9435        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
9436            return Err(SectionError::Abort(
9437                "sync.schema_mismatch".to_owned(),
9438                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
9439            ));
9440        }
9441
9442        if primary_keys != vec![table.primary_key.clone()] {
9443            return Err(invalid(
9444                "image primary key does not match generated schema".into(),
9445            ));
9446        }
9447        let actual_count: i64 = img
9448            .query_row(
9449                &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
9450                [],
9451                |row| row.get(0),
9452            )
9453            .map_err(|e| invalid(e.to_string()))?;
9454        if actual_count != row_count {
9455            return Err(invalid("image row count does not match descriptor".into()));
9456        }
9457        let insert = self.insert_row_sql(&base_table(&table.name), table);
9458        let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
9459        let column_list = names
9460            .iter()
9461            .map(|n| quote_ident(n))
9462            .collect::<Vec<_>>()
9463            .join(", ");
9464        let mut stmt = img
9465            .prepare(&format!(
9466                "SELECT {column_list} FROM {} ORDER BY {}",
9467                quote_ident(&table.name),
9468                quote_ident(&table.primary_key)
9469            ))
9470            .map_err(|e| invalid(e.to_string()))?;
9471        let mut rows = stmt.query([]).map_err(|e| invalid(e.to_string()))?;
9472        let mut applied = 0u32;
9473        loop {
9474            let clear = applied == 0 && first_fresh_page;
9475            let was_dirty = self.overlay_dirty.get();
9476            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
9477                && self
9478                    .outbox
9479                    .iter()
9480                    .flat_map(|commit| &commit.ops)
9481                    .any(|op| op.table == table.name);
9482            let mirror_visible = !reconcile_pending;
9483            self.begin_observation("syncular_image_chunk")
9484                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9485            let result = (|| {
9486                self.rebuild_overlay_if_dirty();
9487                let mut changed_keys = Vec::new();
9488                let mut batch = ChangeAccumulator::default();
9489                if clear {
9490                    if self.scoped_rows_exist(&table.name, effective) {
9491                        batch.table(&table.name);
9492                    }
9493                    self.purge_scope_rows(&table.name, effective)
9494                        .map_err(|()| SectionError::FailClosed)?;
9495                }
9496                let mut processed = 0u32;
9497                {
9498                    let mut ins = self
9499                        .conn
9500                        .prepare_cached(&insert)
9501                        .map_err(|e| invalid(e.to_string()))?;
9502                    let mut mirror = if mirror_visible {
9503                        Some(
9504                            self.conn
9505                                .prepare_cached(&visible_insert)
9506                                .map_err(|e| invalid(e.to_string()))?,
9507                        )
9508                    } else {
9509                        None
9510                    };
9511                    for _ in 0..1024 {
9512                        let Some(row) = rows.next().map_err(|e| invalid(e.to_string()))? else {
9513                            break;
9514                        };
9515                        for (i, column) in table.columns.iter().enumerate() {
9516                            let cell = row.get_ref(i).map_err(|e| invalid(e.to_string()))?;
9517                            let param = image_cell_param(column, cell).map_err(&invalid)?;
9518                            ins.raw_bind_parameter(i + 1, &param)
9519                                .map_err(|e| invalid(e.to_string()))?;
9520                            if let Some(mirror) = &mut mirror {
9521                                mirror
9522                                    .raw_bind_parameter(i + 1, &param)
9523                                    .map_err(|e| invalid(e.to_string()))?;
9524                            }
9525                        }
9526                        let version_index = table.columns.len();
9527                        let version: i64 =
9528                            row.get(version_index).map_err(|e| invalid(e.to_string()))?;
9529                        if version < 1 {
9530                            return Err(invalid("image row version must be positive".into()));
9531                        }
9532                        ins.raw_bind_parameter(version_index + 1, version)
9533                            .map_err(|e| invalid(e.to_string()))?;
9534                        ins.raw_execute().map_err(|e| invalid(e.to_string()))?;
9535                        if let Some(mirror) = &mut mirror {
9536                            mirror
9537                                .raw_bind_parameter(version_index + 1, version)
9538                                .map_err(|e| invalid(e.to_string()))?;
9539                            mirror.raw_execute().map_err(|e| invalid(e.to_string()))?;
9540                        }
9541                        if reconcile_pending {
9542                            changed_keys.push(
9543                                row.get::<_, SqlValue>(table.pk_index)
9544                                    .map_err(|error| invalid(error.to_string()))?,
9545                            );
9546                        }
9547                        processed += 1;
9548                    }
9549                }
9550                if processed > 0 {
9551                    batch.table(&table.name);
9552                    self.overlay_dirty.set(true);
9553                }
9554                if reconcile_pending {
9555                    self.reconcile_imported_rows(table, &mut changed_keys)
9556                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9557                }
9558                self.apply_outbox_ops(
9559                    self.outbox
9560                        .iter()
9561                        .flat_map(|commit| &commit.ops)
9562                        .filter(|op| op.table == table.name),
9563                );
9564                self.overlay_dirty.set(false);
9565                self.finish_observation("syncular_image_chunk", batch)
9566                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9567                Ok(processed)
9568            })();
9569            let processed = match result {
9570                Ok(processed) => processed,
9571                Err(error) => {
9572                    self.rollback_observation("syncular_image_chunk");
9573                    self.overlay_dirty.set(was_dirty);
9574                    return Err(error);
9575                }
9576            };
9577            applied += processed;
9578            self.progress
9579                .update(|p| p.rows_processed = u64::from(applied));
9580            std::thread::yield_now();
9581            if i64::from(applied) == actual_count {
9582                break;
9583            }
9584        }
9585        Ok(applied)
9586    }
9587
9588    /// Restore only imported and pending row identities before FIFO replay. All pending
9589    /// identities participate: a server row can free a unique value for a different edit.
9590    /// The caller owns the chunk transaction and publishes its revision after replay.
9591    fn reconcile_imported_rows(
9592        &self,
9593        table: &TableSchema,
9594        keys: &mut Vec<SqlValue>,
9595    ) -> Result<(), String> {
9596        let visible = visible_table(&table.name);
9597        let base = base_table(&table.name);
9598        let pk = quote_ident(&table.primary_key);
9599        let predicate = row_id_predicate(table);
9600        let mut lookup = self.conn.prepare_cached(&format!(
9601            "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
9602        )).map_err(|error| error.to_string())?;
9603        let mut deleted = HashSet::new();
9604        for op in self
9605            .outbox
9606            .iter()
9607            .flat_map(|commit| &commit.ops)
9608            .filter(|op| op.table == table.name)
9609        {
9610            if op.upsert {
9611                if let Some(values) = &op.values {
9612                    let key = json_to_column_value(
9613                        &table.columns[table.pk_index],
9614                        values.get(&table.primary_key),
9615                    )?;
9616                    keys.push(owned_sql_value(RowParam::Cell(&key))?);
9617                }
9618            } else if deleted.insert(&op.row_id) {
9619                keys.extend(
9620                    lookup
9621                        .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
9622                        .map_err(|error| error.to_string())?
9623                        .collect::<Result<Vec<_>, _>>()
9624                        .map_err(|error| error.to_string())?,
9625                );
9626            }
9627        }
9628        // Delete the complete affected set before restoring any base value, so
9629        // old optimistic unique values cannot collide with the new server rows.
9630        for chunk in keys.chunks(400) {
9631            let holes = vec!["?"; chunk.len()].join(",");
9632            self.conn
9633                .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
9634                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
9635                .map_err(|error| error.to_string())?;
9636        }
9637        let updates = table
9638            .columns
9639            .iter()
9640            .map(|column| column.name.as_str())
9641            .chain(std::iter::once("_syncular_version"))
9642            .filter(|name| *name != table.primary_key)
9643            .map(|name| {
9644                let name = quote_ident(name);
9645                format!("{name}=excluded.{name}")
9646            })
9647            .collect::<Vec<_>>()
9648            .join(",");
9649        for chunk in keys.chunks(400) {
9650            let holes = vec!["?"; chunk.len()].join(",");
9651            self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
9652                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
9653                .map_err(|error| error.to_string())?;
9654        }
9655        Ok(())
9656    }
9657
9658    // -- application-authorized local purge ---------------------------------------
9659
9660    fn compile_local_data_purge(
9661        &self,
9662        input: &LocalDataPurgeInput,
9663    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
9664        let invalid = |message: String| format!("sync.invalid_request: {message}");
9665        if input.purge_id.is_empty()
9666            || input.purge_id.len() > 128
9667            || !is_local_operation_code_like(&input.purge_id)
9668        {
9669            return Err(invalid(
9670                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
9671            ));
9672        }
9673        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
9674            return Err(invalid(format!(
9675                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
9676            )));
9677        }
9678
9679        let mut deduplicated: BTreeMap<
9680            String,
9681            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
9682        > = BTreeMap::new();
9683        for target in &input.targets {
9684            let table = self.schema.table(&target.table).ok_or_else(|| {
9685                invalid(format!(
9686                    "local purge names unknown table {:?}",
9687                    target.table
9688                ))
9689            })?;
9690            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
9691                return Err(invalid(format!(
9692                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
9693                    target.table
9694                )));
9695            }
9696            let mut selectors = Vec::with_capacity(target.selectors.len());
9697            let mut canonical_selectors = BTreeMap::new();
9698            for (column_name, raw_values) in &target.selectors {
9699                let Some((column_index, column)) = table
9700                    .columns
9701                    .iter()
9702                    .enumerate()
9703                    .find(|(_, column)| column.name == *column_name)
9704                else {
9705                    return Err(invalid(format!(
9706                        "local purge target {:?} names unknown column {:?}",
9707                        target.table, column_name
9708                    )));
9709                };
9710                let encrypted = table
9711                    .encrypted_columns
9712                    .iter()
9713                    .any(|candidate| candidate.index == column_index);
9714                if column.ty != ColumnType::String || encrypted {
9715                    return Err(invalid(format!(
9716                        "local purge selector {:?}.{:?} must be a plaintext string column",
9717                        target.table, column_name
9718                    )));
9719                }
9720                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
9721                    return Err(invalid(format!(
9722                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
9723                        target.table, column_name
9724                    )));
9725                }
9726                let mut values = raw_values.clone();
9727                values.sort();
9728                values.dedup();
9729                if values.iter().any(|value| {
9730                    value.is_empty()
9731                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
9732                        || !is_local_operation_code_like(value)
9733                }) {
9734                    return Err(invalid(format!(
9735                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
9736                    )));
9737                }
9738                selectors.push((column_name.clone(), values.clone()));
9739                canonical_selectors.insert(column_name.clone(), values);
9740            }
9741            let canonical = LocalDataPurgeTarget {
9742                table: target.table.clone(),
9743                selectors: canonical_selectors,
9744            };
9745            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
9746            deduplicated.insert(
9747                key,
9748                (
9749                    CompiledLocalDataPurgeTarget {
9750                        table: target.table.clone(),
9751                        selectors,
9752                    },
9753                    canonical,
9754                ),
9755            );
9756        }
9757        let targets = deduplicated
9758            .values()
9759            .map(|(compiled, _)| compiled.clone())
9760            .collect::<Vec<_>>();
9761        let canonical = deduplicated
9762            .values()
9763            .map(|(_, target)| target.clone())
9764            .collect::<Vec<_>>();
9765        let canonical_plan =
9766            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
9767        Ok((targets, canonical_plan))
9768    }
9769
9770    fn local_purge_base_row_ids(
9771        &self,
9772        targets: &[CompiledLocalDataPurgeTarget],
9773    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
9774        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
9775        for target in targets {
9776            let table = self
9777                .schema
9778                .table(&target.table)
9779                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
9780            let mut clauses = Vec::with_capacity(target.selectors.len());
9781            let mut params = Vec::new();
9782            for (column, values) in &target.selectors {
9783                clauses.push(format!(
9784                    "{} IN ({})",
9785                    quote_ident(column),
9786                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
9787                ));
9788                params.extend(values.iter().cloned().map(SqlValue::Text));
9789            }
9790            let sql = format!(
9791                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
9792                quote_ident(&table.primary_key),
9793                base_table(&target.table),
9794                clauses.join(" AND ")
9795            );
9796            let mut statement = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
9797            let rows = statement
9798                .query_map(rusqlite::params_from_iter(params), |row| {
9799                    row.get::<_, String>(0)
9800                })
9801                .map_err(|error| error.to_string())?;
9802            let ids = by_table.entry(target.table.clone()).or_default();
9803            for row in rows {
9804                ids.insert(row.map_err(|error| error.to_string())?);
9805            }
9806        }
9807        Ok(by_table)
9808    }
9809
9810    fn local_purge_values_match(
9811        target: &CompiledLocalDataPurgeTarget,
9812        values: &Map<String, Value>,
9813    ) -> bool {
9814        target.selectors.iter().all(|(column, allowed)| {
9815            values
9816                .get(column)
9817                .and_then(Value::as_str)
9818                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
9819        })
9820    }
9821
9822    /// Apply one host-authorized local security purge. The host MUST gate the
9823    /// corresponding subscriptions first; this primitive owns local SQLite
9824    /// cleanup and never grants/revokes server authority by itself.
9825    pub fn purge_local_data(
9826        &mut self,
9827        input: &LocalDataPurgeInput,
9828    ) -> Result<LocalDataPurgeResult, String> {
9829        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
9830        let meta_key = format!("localPurge:{}", input.purge_id);
9831        let applied_plan = self
9832            .conn
9833            .query_row(
9834                "SELECT value FROM _syncular_meta WHERE key = ?1",
9835                rusqlite::params![meta_key],
9836                |row| row.get::<_, String>(0),
9837            )
9838            .optional()
9839            .map_err(|error| error.to_string())?;
9840        if let Some(applied_plan) = applied_plan {
9841            if applied_plan != canonical_plan {
9842                return Err(format!(
9843                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
9844                    input.purge_id
9845                ));
9846            }
9847            return Ok(LocalDataPurgeResult {
9848                already_applied: true,
9849                purged_rows: 0,
9850                dropped_commits: 0,
9851            });
9852        }
9853
9854        let prior_outbox = self.outbox.clone();
9855        let prior_rejection_count = self.rejections.len();
9856        let prior_overlay_dirty = self.overlay_dirty.get();
9857        self.begin_observation("syncular_local_purge")?;
9858        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
9859            let row_ids = self.local_purge_base_row_ids(&targets)?;
9860            let doomed = self
9861                .outbox
9862                .iter()
9863                .filter(|commit| {
9864                    commit.ops.iter().any(|operation| {
9865                        let matching_targets = targets
9866                            .iter()
9867                            .filter(|target| target.table == operation.table)
9868                            .collect::<Vec<_>>();
9869                        if matching_targets.is_empty() {
9870                            return false;
9871                        }
9872                        if row_ids
9873                            .get(&operation.table)
9874                            .is_some_and(|ids| ids.contains(&operation.row_id))
9875                        {
9876                            return true;
9877                        }
9878                        operation.values.as_ref().is_some_and(|values| {
9879                            matching_targets
9880                                .iter()
9881                                .any(|target| Self::local_purge_values_match(target, values))
9882                        })
9883                    })
9884                })
9885                .cloned()
9886                .collect::<Vec<_>>();
9887            let doomed_ids = doomed
9888                .iter()
9889                .map(|commit| commit.client_commit_id.clone())
9890                .collect::<BTreeSet<_>>();
9891            let mut batch = ChangeAccumulator::default();
9892            let mut rejections = Vec::new();
9893            for commit in &doomed {
9894                for operation in &commit.ops {
9895                    batch.table(&operation.table);
9896                }
9897                let results = commit
9898                    .ops
9899                    .iter()
9900                    .enumerate()
9901                    .map(|(op_index, operation)| {
9902                        let rejection = RejectionRecord {
9903                            client_commit_id: commit.client_commit_id.clone(),
9904                            op_index: op_index as i32,
9905                            code: "client.local_data_purged".to_owned(),
9906                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
9907                            retryable: false,
9908                            details: None,
9909                            operation: Some(CommitOperation::from(operation)),
9910                        };
9911                        rejections.push(rejection.clone());
9912                        CommitOperationOutcome::Error { rejection }
9913                    })
9914                    .collect::<Vec<_>>();
9915                self.persist_commit_outcome(
9916                    &commit.client_commit_id,
9917                    CommitOutcomeStatus::Rejected,
9918                    &results,
9919                    Some(&commit.ops),
9920                )?;
9921                self.delete_outbox_persisted(&commit.client_commit_id)?;
9922            }
9923            if !doomed.is_empty() {
9924                self.outbox
9925                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
9926                self.rejections.extend(rejections);
9927                self.prune_commit_outcomes()?;
9928                self.overlay_dirty.set(true);
9929                batch.status = true;
9930                batch.rejections = true;
9931                batch.outcomes = true;
9932            }
9933
9934            let mut purged_rows = 0usize;
9935            for (table, ids) in &row_ids {
9936                if ids.is_empty() {
9937                    continue;
9938                }
9939                batch.table(table);
9940                for row_id in ids {
9941                    self.delete_base_row(table, row_id)?;
9942                    purged_rows += 1;
9943                }
9944            }
9945            self.rebuild_overlay_if_dirty();
9946            self.delete_unreferenced_cached_blobs();
9947            self.conn
9948                .execute(
9949                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
9950                    rusqlite::params![meta_key, canonical_plan],
9951                )
9952                .map_err(|error| error.to_string())?;
9953            Ok((
9954                batch,
9955                LocalDataPurgeResult {
9956                    already_applied: false,
9957                    purged_rows,
9958                    dropped_commits: doomed.len(),
9959                },
9960            ))
9961        })();
9962        let (batch, result) = match applied {
9963            Ok(value) => value,
9964            Err(error) => {
9965                self.rollback_observation("syncular_local_purge");
9966                self.outbox = prior_outbox;
9967                self.rejections.truncate(prior_rejection_count);
9968                self.overlay_dirty.set(prior_overlay_dirty);
9969                return Err(error);
9970            }
9971        };
9972        if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
9973            self.rollback_observation("syncular_local_purge");
9974            self.outbox = prior_outbox;
9975            self.rejections.truncate(prior_rejection_count);
9976            self.overlay_dirty.set(prior_overlay_dirty);
9977            return Err(error);
9978        }
9979        Ok(result)
9980    }
9981
9982    /// Application-authorized recovery of the replicated projection. Unlike a
9983    /// local security purge, this retains the entire outbox, device identity,
9984    /// lease, outcomes, subscription registrations, and protected bookkeeping.
9985    /// The projection reset, subscription rewind, optimistic replay, and
9986    /// idempotency marker share one savepoint for interruption safety.
9987    pub fn rebootstrap_local_data(
9988        &mut self,
9989        input: &LocalDataRebootstrapInput,
9990    ) -> Result<LocalDataRebootstrapResult, String> {
9991        if input.rebootstrap_id.is_empty()
9992            || input.rebootstrap_id.len() > 128
9993            || !is_local_operation_code_like(&input.rebootstrap_id)
9994        {
9995            return Err(
9996                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
9997                    .to_owned(),
9998            );
9999        }
10000        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
10001        if let Some(persisted) = self.get_meta_strict(&meta_key)? {
10002            let (retained_commits, reset_subscriptions) =
10003                decode_local_rebootstrap_receipt(&persisted)?;
10004            return Ok(LocalDataRebootstrapResult {
10005                already_applied: true,
10006                retained_commits,
10007                reset_subscriptions,
10008            });
10009        }
10010        if self.stopped || self.schema_floor.is_some() {
10011            return Err(
10012                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
10013                    .to_owned(),
10014            );
10015        }
10016
10017        let retained_commits = self.outbox.len();
10018        let reset_subscriptions = self.subs.len();
10019        let prior_subs = self.subs.clone();
10020        let prior_upgrading = self.upgrading;
10021        let prior_stopped = self.stopped;
10022        let prior_schema_floor = self.schema_floor.clone();
10023        let prior_overlay_dirty = self.overlay_dirty.get();
10024        let prior_sync_needed = self.sync_needed;
10025        let prior_sync_intents = self.sync_intent_queue.clone();
10026        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
10027
10028        self.begin_observation("syncular_local_rebootstrap")?;
10029        let mut batch = ChangeAccumulator::default();
10030        let applied = (|| -> Result<(), String> {
10031            self.run_schema_reset_observed(&mut batch, false)?;
10032            self.conn
10033                .execute(
10034                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
10035                    rusqlite::params![meta_key, receipt],
10036                )
10037                .map_err(|error| error.to_string())?;
10038            self.sync_needed = true;
10039            self.sync_intent_queue.push_back(SyncIntent::Interactive);
10040            batch.status = true;
10041            Ok(())
10042        })();
10043        if let Err(error) = applied {
10044            self.rollback_observation("syncular_local_rebootstrap");
10045            self.subs = prior_subs;
10046            self.upgrading = prior_upgrading;
10047            self.stopped = prior_stopped;
10048            self.schema_floor = prior_schema_floor;
10049            self.overlay_dirty.set(prior_overlay_dirty);
10050            self.sync_needed = prior_sync_needed;
10051            self.sync_intent_queue = prior_sync_intents;
10052            return Err(error);
10053        }
10054        if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
10055            self.rollback_observation("syncular_local_rebootstrap");
10056            self.subs = prior_subs;
10057            self.upgrading = prior_upgrading;
10058            self.stopped = prior_stopped;
10059            self.schema_floor = prior_schema_floor;
10060            self.overlay_dirty.set(prior_overlay_dirty);
10061            self.sync_needed = prior_sync_needed;
10062            self.sync_intent_queue = prior_sync_intents;
10063            return Err(error);
10064        }
10065
10066        Ok(LocalDataRebootstrapResult {
10067            already_applied: false,
10068            retained_commits,
10069            reset_subscriptions,
10070        })
10071    }
10072
10073    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
10074
10075    /// Delete base rows matching the effective scopes; `Err(())` = no local
10076    /// scope-column mapping for a key (the fail-closed case).
10077    fn purge_scope_rows(
10078        &mut self,
10079        table_name: &str,
10080        effective: &[(String, Vec<String>)],
10081    ) -> Result<(), ()> {
10082        if effective.is_empty() {
10083            return Ok(());
10084        }
10085        let table = self.schema.table(table_name).ok_or(())?.clone();
10086        let mut clauses = Vec::new();
10087        let mut params: Vec<SqlValue> = Vec::new();
10088        for (variable, values) in effective {
10089            let column = table.scope_column(variable).ok_or(())?;
10090            let placeholders: Vec<String> = values
10091                .iter()
10092                .map(|v| {
10093                    params.push(SqlValue::Text(v.clone()));
10094                    "?".to_owned()
10095                })
10096                .collect();
10097            clauses.push(format!(
10098                "{} IN ({})",
10099                quote_ident(column),
10100                placeholders.join(", ")
10101            ));
10102        }
10103        let sql = format!(
10104            "DELETE FROM {} WHERE {}",
10105            base_table(table_name),
10106            clauses.join(" AND ")
10107        );
10108        self.overlay_dirty.set(true);
10109        self.conn
10110            .execute(&sql, rusqlite::params_from_iter(&params))
10111            .map_err(|_| ())?;
10112        self.conn
10113            .execute(
10114                &format!(
10115                    "DELETE FROM {} WHERE {}",
10116                    visible_table(table_name),
10117                    clauses.join(" AND ")
10118                ),
10119                rusqlite::params_from_iter(&params),
10120            )
10121            .map_err(|_| ())?;
10122        Ok(())
10123    }
10124
10125    /// §3.3: drop pending commits whose upserts provably land in the
10126    /// revoked effective scopes — whole-commit, never per-operation.
10127    fn drop_doomed_outbox(
10128        &mut self,
10129        table_name: &str,
10130        effective: &[(String, Vec<String>)],
10131    ) -> Result<bool, String> {
10132        if effective.is_empty() {
10133            return Ok(false);
10134        }
10135        let Some(table) = self.schema.table(table_name).cloned() else {
10136            return Ok(false);
10137        };
10138        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
10139        for (variable, values) in effective {
10140            match table.scope_column(variable) {
10141                Some(column) => mappings.push((column, values)),
10142                None => return Ok(false), // not provable without a mapping
10143            }
10144        }
10145        let doomed: Vec<OutboxCommit> = self
10146            .outbox
10147            .iter()
10148            .filter(|commit| {
10149                commit.ops.iter().any(|op| {
10150                    op.upsert
10151                        && op.table == table_name
10152                        && op.values.as_ref().is_some_and(|values| {
10153                            mappings
10154                                .iter()
10155                                .all(|(column, allowed)| match values.get(*column) {
10156                                    Some(Value::String(s)) => allowed.contains(s),
10157                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
10158                                    _ => false,
10159                                })
10160                        })
10161                })
10162            })
10163            .cloned()
10164            .collect();
10165        if doomed.is_empty() {
10166            return Ok(false);
10167        }
10168        let mut rejections = Vec::new();
10169        for commit in &doomed {
10170            let results = commit
10171                .ops
10172                .iter()
10173                .enumerate()
10174                .map(|(op_index, operation)| {
10175                    let rejection = RejectionRecord {
10176                        client_commit_id: commit.client_commit_id.clone(),
10177                        op_index: op_index as i32,
10178                        code: "sync.scope_revoked".to_owned(),
10179                        message: "the commit was dropped because its effective scope was revoked"
10180                            .to_owned(),
10181                        retryable: false,
10182                        details: None,
10183                        operation: Some(CommitOperation::from(operation)),
10184                    };
10185                    rejections.push(rejection.clone());
10186                    CommitOperationOutcome::Error { rejection }
10187                })
10188                .collect::<Vec<_>>();
10189            self.persist_commit_outcome(
10190                &commit.client_commit_id,
10191                CommitOutcomeStatus::Rejected,
10192                &results,
10193                Some(&commit.ops),
10194            )?;
10195            self.delete_outbox_persisted(&commit.client_commit_id)?;
10196        }
10197        self.prune_commit_outcomes()?;
10198        let doomed_ids = doomed
10199            .iter()
10200            .map(|commit| commit.client_commit_id.as_str())
10201            .collect::<BTreeSet<_>>();
10202        self.outbox
10203            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
10204        self.rejections.extend(rejections);
10205        self.overlay_dirty.set(true);
10206        Ok(true)
10207    }
10208
10209    // -- blobs (§5.9) ----------------------------------------------------------------
10210
10211    /// §5.9.7: hash bytes into the content address, cache them, queue the
10212    /// upload (flushed before the next push, B4). Returns the canonical
10213    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
10214    /// column value.
10215    pub fn upload_blob(
10216        &mut self,
10217        bytes: &[u8],
10218        media_type: Option<String>,
10219        name: Option<String>,
10220    ) -> Result<Value, String> {
10221        let blob_id = blob_id_for(bytes);
10222        let now = self.clock_now_ms();
10223        let tx = self.conn.transaction().map_err(|e| e.to_string())?;
10224        tx.execute(
10225            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
10226             ON CONFLICT(blob_id) DO NOTHING",
10227            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
10228        )
10229        .map_err(|e| e.to_string())?;
10230        tx.execute(
10231            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
10232            rusqlite::params![blob_id, media_type, now],
10233        )
10234        .map_err(|e| e.to_string())?;
10235        tx.commit().map_err(|e| e.to_string())?;
10236        self.enforce_blob_cache_cap();
10237        let mut obj = Map::new();
10238        obj.insert("blobId".to_owned(), Value::from(blob_id));
10239        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
10240        if let Some(mt) = media_type {
10241            obj.insert("mediaType".to_owned(), Value::from(mt));
10242        }
10243        if let Some(n) = name {
10244            obj.insert("name".to_owned(), Value::from(n));
10245        }
10246        Ok(Value::Object(obj))
10247    }
10248
10249    /// §5.9.7: resolve an owned blob body without driver encoding. A
10250    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
10251    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
10252    pub fn fetch_blob_bytes(
10253        &mut self,
10254        transport: &mut dyn Transport,
10255        blob_id_or_ref: &str,
10256    ) -> Result<FetchedBlob, (String, String)> {
10257        let simple = |m: String| ("client.failed".to_owned(), m);
10258        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
10259            blob_id_or_ref.to_owned()
10260        } else {
10261            let value: Value = serde_json::from_str(blob_id_or_ref)
10262                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
10263            value
10264                .get("blobId")
10265                .and_then(Value::as_str)
10266                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
10267                .to_owned()
10268        };
10269        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
10270            return Ok(cached);
10271        }
10272        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
10273        // blob.not_found) verbatim so the harness can assert on it. The
10274        // authorized endpoint serves bytes inline OR (always-issue, presign
10275        // configured) a signed url the client fetches directly — no host auth,
10276        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
10277        #[cfg(feature = "bench-internals")]
10278        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
10279        let bytes = match transport
10280            .blob_download(&blob_id)
10281            .map_err(|e| (e.code, e.message))?
10282        {
10283            BlobDownload::Bytes(bytes) => bytes,
10284            BlobDownload::Url {
10285                url,
10286                url_expires_at_ms,
10287            } => {
10288                // §5.9.5: MUST NOT start a fetch at/past expiry.
10289                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
10290                    return Err((
10291                        "sync.segment_expired".to_owned(),
10292                        format!(
10293                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
10294                        ),
10295                    ));
10296                }
10297                transport
10298                    .fetch_blob_url(&url)
10299                    .map_err(|e| (e.code, e.message))?
10300            }
10301        };
10302        #[cfg(feature = "bench-internals")]
10303        drop(download_phase);
10304        #[cfg(feature = "bench-internals")]
10305        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
10306        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
10307        if blob_id_for(&bytes) != blob_id {
10308            return Err(simple(format!(
10309                "blob content address mismatch for {blob_id}"
10310            )));
10311        }
10312        #[cfg(feature = "bench-internals")]
10313        drop(validate_phase);
10314        #[cfg(feature = "bench-internals")]
10315        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
10316        let now = self.clock_now_ms();
10317        self.conn
10318            .execute(
10319                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
10320                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
10321            )
10322            .map_err(|e| simple(e.to_string()))?;
10323        self.conn
10324            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
10325            .map_err(|e| simple(e.to_string()))?;
10326        self.enforce_blob_cache_cap();
10327        #[cfg(feature = "bench-internals")]
10328        drop(insert_phase);
10329        self.get_cached_blob_bytes(&blob_id)
10330            .map_err(simple)?
10331            .ok_or_else(|| simple("blob cache write failed".to_owned()))
10332    }
10333
10334    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
10335        #[cfg(feature = "bench-internals")]
10336        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
10337        let mut stmt = self
10338            .conn
10339            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
10340            .map_err(|e| e.to_string())?;
10341        let mut rows = stmt
10342            .query(rusqlite::params![blob_id])
10343            .map_err(|e| e.to_string())?;
10344        if let Some(row) = rows.next().map_err(|e| e.to_string())? {
10345            let bytes: Vec<u8> = row.get(0).map_err(|e| e.to_string())?;
10346            let byte_length: i64 = row.get(1).map_err(|e| e.to_string())?;
10347            let media_type: Option<String> = row.get(2).map_err(|e| e.to_string())?;
10348            return Ok(Some(FetchedBlob {
10349                blob_id: blob_id.to_owned(),
10350                byte_length,
10351                bytes,
10352                media_type,
10353            }));
10354        }
10355        Ok(None)
10356    }
10357
10358    /// §5.9.7 B4: upload every queued blob before push.
10359    fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
10360        let pending: Vec<(String, Option<String>)> = {
10361            let mut stmt = self
10362                .conn
10363                .prepare(
10364                    "SELECT blob_id, media_type FROM (
10365                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
10366                      UNION ALL
10367                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
10368                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
10369                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
10370                    ) ORDER BY created_at_ms, blob_id",
10371                )
10372                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
10373            let rows = stmt
10374                .query_map([], |row| {
10375                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
10376                })
10377                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
10378            rows.collect::<Result<_, _>>()
10379                .map_err(|error| match error {
10380                    rusqlite::Error::InvalidColumnType(..)
10381                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
10382                        "sync.local_corrupt",
10383                        "Pending blob upload metadata is invalid",
10384                    ),
10385                    _ => TransportError::new("client.failed", error.to_string()),
10386                })?
10387        };
10388        for (blob_id, media_type) in pending {
10389            let stored: Option<(SqlValue, SqlValue)> = self
10390                .conn
10391                .query_row(
10392                    "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
10393                    rusqlite::params![blob_id],
10394                    |row| Ok((row.get(0)?, row.get(1)?)),
10395                )
10396                .optional()
10397                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
10398            let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
10399                return Err(TransportError::new(
10400                    "sync.local_corrupt",
10401                    "Pending blob upload body is missing or corrupt",
10402                ));
10403            };
10404            if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
10405                return Err(TransportError::new(
10406                    "sync.local_corrupt",
10407                    "Pending blob upload body is missing or corrupt",
10408                ));
10409            }
10410            self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
10411            self.conn
10412                .execute(
10413                    "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
10414                    rusqlite::params![blob_id],
10415                )
10416                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
10417        }
10418        Ok(())
10419    }
10420
10421    /// §5.9.3: upload one blob, preferring the presigned direct-to-storage
10422    /// grant when the transport supports it, else streaming through the direct
10423    /// host-authenticated endpoint (capability, not fallback). A `Url` grant
10424    /// PUTs direct with no host auth; on a grant PUT failure the client streams
10425    /// through the direct endpoint — a *different, host-authenticated
10426    /// capability*, not a fall-through of the grant's authority.
10427    fn upload_one(
10428        &self,
10429        transport: &mut dyn Transport,
10430        blob_id: &str,
10431        bytes: &[u8],
10432        media_type: Option<&str>,
10433    ) -> Result<(), TransportError> {
10434        match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
10435            BlobUploadGrant::Present => return Ok(()), // idempotent §5.9.3
10436            BlobUploadGrant::Url {
10437                url,
10438                url_expires_at_ms,
10439            } => {
10440                let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
10441                if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
10442                    return Ok(());
10443                }
10444                // Failed/expired grant PUT — stream through the direct endpoint.
10445            }
10446            BlobUploadGrant::None => {
10447                // No presign store — stream through the direct endpoint.
10448            }
10449        }
10450        transport.blob_upload(blob_id, bytes, media_type)
10451    }
10452
10453    fn visible_blob_ids_sql(&self) -> String {
10454        let mut selects = Vec::new();
10455        for table in &self.schema.tables {
10456            for column in table
10457                .columns
10458                .iter()
10459                .filter(|column| column.ty == ColumnType::BlobRef)
10460            {
10461                let identifier = quote_ident(&column.name);
10462                selects.push(format!(
10463                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
10464                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
10465                     AND json_type({identifier}, '$.blobId') = 'text'",
10466                    quote_ident(&table.name),
10467                ));
10468            }
10469        }
10470        if selects.is_empty() {
10471            "SELECT NULL AS blob_id WHERE 0".to_owned()
10472        } else {
10473            selects.join(" UNION ")
10474        }
10475    }
10476
10477    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
10478    /// staged uploads pin bodies directly; no derived refcount is stored.
10479    fn enforce_blob_cache_cap(&self) {
10480        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
10481            return;
10482        };
10483        let mut total: i64 = self
10484            .conn
10485            .query_row(
10486                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
10487                [],
10488                |row| row.get(0),
10489            )
10490            .unwrap_or(0);
10491        if total <= max_bytes {
10492            return;
10493        }
10494        let query = format!(
10495            "SELECT blob_id, byte_length FROM _syncular_blobs
10496             WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
10497               AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
10498               AND blob_id NOT IN ({})
10499             ORDER BY created_at_ms ASC, blob_id ASC",
10500            self.visible_blob_ids_sql(),
10501        );
10502        let candidates: Vec<(String, i64)> = {
10503            let Ok(mut stmt) = self.conn.prepare(&query) else {
10504                return;
10505            };
10506            let Ok(rows) = stmt.query_map([], |row| {
10507                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
10508            }) else {
10509                return;
10510            };
10511            rows.filter_map(Result::ok).collect()
10512        };
10513        for (blob_id, byte_length) in candidates {
10514            if total <= max_bytes {
10515                break;
10516            }
10517            let _ = self.conn.execute(
10518                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
10519                rusqlite::params![blob_id],
10520            );
10521            total -= byte_length;
10522        }
10523    }
10524
10525    fn delete_unreferenced_cached_blobs(&mut self) {
10526        #[cfg(feature = "bench-internals")]
10527        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
10528        if !self.schema_has_blobs() {
10529            return;
10530        }
10531        self.rebuild_overlay_if_dirty();
10532        let _ = self.conn.execute(
10533            &format!(
10534                "DELETE FROM _syncular_blobs
10535                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
10536                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
10537                   AND blob_id NOT IN ({})",
10538                self.visible_blob_ids_sql(),
10539            ),
10540            [],
10541        );
10542    }
10543
10544    // -- local row storage ----------------------------------------------------------
10545
10546    /// The cached per-table primary-key upsert SQL for `full_table` (see
10547    /// the `insert_sql` field: built once, reused per row).
10548    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
10549        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
10550            return sql.clone();
10551        }
10552        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
10553        columns.push(quote_ident("_syncular_version"));
10554        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
10555        let primary_key = quote_ident(&table.primary_key);
10556        let updates = columns
10557            .iter()
10558            .filter(|column| **column != primary_key)
10559            .map(|column| format!("{column}=excluded.{column}"))
10560            .collect::<Vec<_>>()
10561            .join(", ");
10562        let sql = format!(
10563            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
10564            columns.join(", "),
10565            placeholders.join(", ")
10566        );
10567        self.insert_sql
10568            .borrow_mut()
10569            .insert(full_table.to_owned(), sql.clone());
10570        sql
10571    }
10572
10573    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
10574        self.overlay_dirty.set(true);
10575        self.write_row(&base_table(table_name), table_name, row, version)
10576    }
10577
10578    fn write_row(
10579        &self,
10580        full_table: &str,
10581        table_name: &str,
10582        row: &Row,
10583        version: i64,
10584    ) -> Result<(), String> {
10585        #[cfg(feature = "bench-internals")]
10586        let _phase = self.benchmark_phases.start(Phase::RowWrite);
10587        let table = self
10588            .schema
10589            .table(table_name)
10590            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
10591        let sql = self.insert_row_sql(full_table, table);
10592        let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
10593        let params = row
10594            .iter()
10595            .map(RowParam::Cell)
10596            .chain(std::iter::once(RowParam::Version(version)));
10597        stmt.execute(rusqlite::params_from_iter(params))
10598            .map_err(|e| e.to_string())?;
10599        Ok(())
10600    }
10601
10602    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
10603        let table = self
10604            .schema
10605            .table(table_name)
10606            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
10607        self.overlay_dirty.set(true);
10608        let sql = format!(
10609            "DELETE FROM {} WHERE {}",
10610            base_table(table_name),
10611            row_id_predicate(table)
10612        );
10613        let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
10614        stmt.execute(rusqlite::params![row_id])
10615            .map_err(|e| e.to_string())?;
10616        Ok(())
10617    }
10618
10619    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
10620    /// the outbox changed since the last rebuild (the no-op sync round).
10621    fn rebuild_overlay_if_dirty(&mut self) {
10622        if self.overlay_dirty.get() {
10623            self.rebuild_overlay();
10624        }
10625    }
10626
10627    /// §7.1: local reads see outbox state applied optimistically — rebuild
10628    /// every visible table as (base server state) + (pending outbox replay
10629    /// on top). Optimistic rows carry version `-1`.
10630    fn rebuild_overlay(&mut self) {
10631        #[cfg(feature = "bench-internals")]
10632        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
10633        #[cfg(test)]
10634        self.overlay_rebuild_count
10635            .set(self.overlay_rebuild_count.get() + 1);
10636        self.exec("SAVEPOINT syncular_overlay");
10637        for table in self.schema.tables.clone() {
10638            for index in &table.fts_indexes {
10639                let _ = self.drop_fts_triggers(index);
10640            }
10641            let visible = visible_table(&table.name);
10642            let base = base_table(&table.name);
10643            self.exec(&format!("DELETE FROM {visible}"));
10644            self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
10645        }
10646        for commit in &self.outbox {
10647            self.apply_outbox_ops(&commit.ops);
10648        }
10649        for table in self.schema.tables.clone() {
10650            for index in &table.fts_indexes {
10651                let _ = self.rebuild_fts_projection(&table, index);
10652                let _ = self.create_fts_triggers(&table, index);
10653            }
10654        }
10655        self.exec("RELEASE syncular_overlay");
10656        self.overlay_dirty.set(false);
10657    }
10658
10659    fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
10660        #[cfg(feature = "bench-internals")]
10661        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
10662        for op in ops {
10663            #[cfg(feature = "bench-internals")]
10664            if let Some(phase) = &mut phase {
10665                phase.unit();
10666            }
10667            let Some(table) = self.schema.table(&op.table) else {
10668                continue;
10669            };
10670            if op.upsert {
10671                let Some(values) = op.values.as_ref() else {
10672                    continue;
10673                };
10674                let mut row: Row = Vec::with_capacity(table.columns.len());
10675                let mut ok = true;
10676                for column in &table.columns {
10677                    match json_to_column_value(column, values.get(&column.name)) {
10678                        Ok(v) => row.push(v),
10679                        Err(_) => {
10680                            ok = false;
10681                            break;
10682                        }
10683                    }
10684                }
10685                if ok {
10686                    let _ = self.write_row(&visible_table(&table.name), &table.name, &row, -1);
10687                }
10688            } else {
10689                let sql = format!(
10690                    "DELETE FROM {} WHERE {}",
10691                    visible_table(&table.name),
10692                    row_id_predicate(table)
10693                );
10694                let _ = self
10695                    .conn
10696                    .prepare_cached(&sql)
10697                    .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]));
10698            }
10699        }
10700    }
10701
10702    fn exec(&self, sql: &str) {
10703        let _ = self.conn.execute_batch(sql);
10704    }
10705
10706    // -- realtime (§8) ---------------------------------------------------------------
10707
10708    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
10709        if self.realtime_connected {
10710            return Ok(());
10711        }
10712        transport
10713            .realtime_connect_for_client(&self.client_id)
10714            .map_err(|e| format!("{}: {}", e.code, e.message))?;
10715        self.realtime_connected = true;
10716        Ok(())
10717    }
10718
10719    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
10720        if !self.realtime_connected {
10721            return;
10722        }
10723        let _ = transport.realtime_close();
10724        self.realtime_connected = false;
10725        self.presence.clear(); // §8.6.1: presence is per-connection
10726    }
10727
10728    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
10729    /// document for `scope_key`. Requires a live socket; the document is
10730    /// ephemeral (lost on disconnect). Authorization is the connection's
10731    /// registration (§8.6.3) — an unheld key is rejected loudly by the
10732    /// server with `presence.forbidden`.
10733    pub fn set_presence(
10734        &mut self,
10735        transport: &mut dyn Transport,
10736        scope_key: &str,
10737        doc: Option<&Value>,
10738    ) -> Result<(), String> {
10739        if !self.realtime_connected {
10740            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
10741        }
10742        let text = encode_presence_publish(scope_key, doc);
10743        transport
10744            .realtime_send(&text)
10745            .map_err(|e| format!("{}: {}", e.code, e.message))
10746    }
10747
10748    /// §8.6: the peers currently present on a scope key (ephemeral).
10749    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
10750        self.presence
10751            .get(scope_key)
10752            .map(|peers| peers.values().cloned().collect())
10753            .unwrap_or_default()
10754    }
10755
10756    /// §8.6 apply an inbound presence fanout to the local map.
10757    fn apply_presence(
10758        &mut self,
10759        scope_key: String,
10760        kind: Option<PresenceKind>,
10761        actor_id: Option<String>,
10762        client_id: Option<String>,
10763        doc: Option<Value>,
10764        error: Option<String>,
10765    ) {
10766        // The publisher-directed error variant is out-of-band; nothing to
10767        // record in the peer map.
10768        if error.is_some() {
10769            return;
10770        }
10771        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
10772            return;
10773        };
10774        let peer_key = format!("{actor_id} {client_id}");
10775        match kind {
10776            PresenceKind::Leave => {
10777                if let Some(peers) = self.presence.get_mut(&scope_key) {
10778                    peers.remove(&peer_key);
10779                    if peers.is_empty() {
10780                        self.presence.remove(&scope_key);
10781                    }
10782                }
10783            }
10784            _ => {
10785                let doc = match doc {
10786                    Some(Value::Object(_)) => doc.unwrap(),
10787                    _ => return,
10788                };
10789                self.presence.entry(scope_key).or_default().insert(
10790                    peer_key,
10791                    PresencePeer {
10792                        actor_id,
10793                        client_id,
10794                        doc,
10795                    },
10796                );
10797            }
10798        }
10799    }
10800
10801    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
10802    pub fn on_realtime_text(&mut self, text: &str) {
10803        match parse_control(text) {
10804            Ok(ControlMessage::Hello { requires_sync, .. }) => {
10805                if requires_sync {
10806                    // §8.1: pull before trusting the socket for continuity.
10807                    self.set_sync_needed(true, true);
10808                }
10809            }
10810            Ok(ControlMessage::Presence {
10811                scope_key,
10812                kind,
10813                actor_id,
10814                client_id,
10815                doc,
10816                error,
10817                ..
10818            }) => {
10819                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
10820            }
10821            Ok(ControlMessage::Wake { .. }) => {
10822                // §8.3: any wake-up means "run a pull soon", never data.
10823                self.set_sync_needed(true, true);
10824            }
10825            _ => {}
10826        }
10827    }
10828
10829    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
10830    /// a pull response per section; an unapplied delta is a wake-up.
10831    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
10832        if self.stopped {
10833            return;
10834        }
10835        #[cfg(feature = "bench-internals")]
10836        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
10837        let message = match decode_message(bytes) {
10838            Ok(m) if m.msg_kind == MsgKind::Response => m,
10839            _ => {
10840                self.set_sync_needed(true, true);
10841                return;
10842            }
10843        };
10844        #[cfg(feature = "bench-internals")]
10845        drop(decode_phase);
10846        let mut frames = message.frames.into_iter();
10847        let mut applied_cursor: Option<i64> = None;
10848        let mut any_covered = false;
10849        let mut dropped = false;
10850        while let Some(frame) = frames.next() {
10851            let Frame::SubStart {
10852                id,
10853                status,
10854                effective_scopes,
10855                ..
10856            } = frame
10857            else {
10858                continue;
10859            };
10860            let mut body = Vec::new();
10861            let mut next_cursor: Option<i64> = None;
10862            for inner in frames.by_ref() {
10863                match inner {
10864                    Frame::SubEnd {
10865                        next_cursor: nc, ..
10866                    } => {
10867                        next_cursor = Some(nc);
10868                        break;
10869                    }
10870                    Frame::Unknown { .. } => {}
10871                    other => body.push(other),
10872                }
10873            }
10874            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
10875                dropped = true;
10876                continue;
10877            };
10878            let sub = &self.subs[sub_index];
10879            // §8.2: only locally active, not mid-bootstrap subscriptions
10880            // apply; skipped sections are repaired by the next pull.
10881            if status != SubStatus::Active
10882                || sub.state != SubState::Active
10883                || sub.bootstrap_state.is_some()
10884                || !sub.synced_once
10885            {
10886                dropped = true;
10887                continue;
10888            }
10889            if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
10890                // Idempotent redelivery of an already-covered window.
10891                any_covered = true;
10892                continue;
10893            }
10894            let previous_effective = self.subs[sub_index].effective.clone();
10895            let previous_cursor = self.subs[sub_index].cursor;
10896            self.subs[sub_index].effective = Some(effective_scopes);
10897            let mut failed = false;
10898            for inner in body {
10899                match inner {
10900                    Frame::Commit {
10901                        tables, changes, ..
10902                    } => {
10903                        if self.apply_commit_frame(&tables, &changes).is_err() {
10904                            failed = true;
10905                            break;
10906                        }
10907                    }
10908                    _ => {
10909                        failed = true;
10910                        break;
10911                    }
10912                }
10913            }
10914            let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
10915                self.apply_sub_end(sub_index, cursor, None)
10916                    .ok()
10917                    .map(|()| cursor)
10918            });
10919            let Some(next_cursor) = committed else {
10920                self.subs[sub_index].effective = previous_effective;
10921                self.subs[sub_index].cursor = previous_cursor;
10922                dropped = true;
10923                continue;
10924            };
10925            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
10926        }
10927        if let Some(cursor) = applied_cursor {
10928            // §8.2 ack point: the highest applied SUB_END.nextCursor.
10929            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
10930            let _ = transport.realtime_send(&ack);
10931        } else if !any_covered || dropped {
10932            // §8.2: a delta not applied at all is treated as a wake-up.
10933            self.set_sync_needed(true, true);
10934        }
10935    }
10936
10937    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
10938    /// cursor across active, non-bootstrapping subscriptions that have
10939    /// synced at least once. No such subscription, no ack.
10940    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
10941        if !self.realtime_connected {
10942            return;
10943        }
10944        let floor = self
10945            .subs
10946            .iter()
10947            .filter(|s| {
10948                s.state == SubState::Active
10949                    && s.bootstrap_state.is_none()
10950                    && s.synced_once
10951                    && s.cursor >= 0
10952            })
10953            .map(|s| s.cursor)
10954            .min();
10955        if let Some(cursor) = floor {
10956            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
10957            let _ = transport.realtime_send(&ack);
10958        }
10959    }
10960}