Skip to main content

syncular_client/
client.rs

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