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