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