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