Skip to main content

syncular_client/
client.rs

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