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