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