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