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