Skip to main content

syncular_client/
client.rs

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