Skip to main content

syncular_client/
client.rs

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