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 crate::{ProgressObserver, ProgressPhase, ProgressState};
11use std::cell::{Cell, RefCell};
12use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
13
14use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
15use rusqlite::{Connection, OpenFlags, OptionalExtension};
16use serde::{Deserialize, Serialize};
17use serde_json::{Map, Value};
18use sha2::{Digest, Sha256};
19use ssp2::decode::WIRE_VERSION;
20use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
21use ssp2::primitives::RawJson;
22use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
23use ssp2::{
24    decode_message, encode_message, encode_presence_publish, parse_control, ControlMessage,
25    PresenceKind,
26};
27
28use crate::api::{
29    ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
30    ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
31    ClientDiagnosticsStorage, ClientLimits, CommandEffects, CommitOperation,
32    CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery, CommitOutcomeResolution,
33    CommitOutcomeStatus, ConflictRecord, CoverageSnapshot, DiagnosticLastChange,
34    DiagnosticLastRound, DiagnosticRoundCounters, DiagnosticSubscription, FetchedBlob, LeaseState,
35    LocalDataPurgeInput, LocalDataPurgeResult, LocalDataPurgeTarget, LocalDataRebootstrapInput,
36    LocalDataRebootstrapResult, Mutation, PresencePeer, QueryRow, QuerySnapshot, QueryValue,
37    RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RowState, SchemaFloor,
38    SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport, SyncStatusSnapshot, TableChange,
39    WindowBase, WindowChange, WindowCoverage, WindowState, WindowUnitRef,
40    CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
41};
42#[cfg(feature = "bench-internals")]
43use crate::bench::{Phase, Recorder};
44use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
45use crate::transport::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
46use crate::values::{
47    bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes, encode_row_json,
48    json_to_column_value, json_to_scope_map, normalize_values_casing, render_row_id_json,
49    scope_map_to_json, sort_scope_map,
50};
51
52/// §4.2 default: the rows baseline plus sqlite images (§5.3) — rusqlite
53/// can always import an image, so the premier path is advertised unless
54/// the host overrides `limits.accept`. Bit 3 (signed URLs, §5.4) is
55/// added per transport capability at request-build time.
56const DEFAULT_ACCEPT: u8 = 0b0111;
57const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
58const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
59const ACCEPT_SQLITE: u8 = 1 << 2;
60const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
61const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
62
63/// §7.4.1 persisted local schema-version marker (`_syncular_meta` key).
64const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
65const LOCAL_REVISION_KEY: &str = "localRevision";
66const CLIENT_ID_KEY: &str = "clientId";
67const LEASE_STATE_KEY: &str = "leaseState";
68const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
69const LOG_EPOCH_KEY: &str = "logEpoch";
70/// Persisted fail-closed quarantine marker: present while a security preflight
71/// is pending and `activateSecurity` has yet to run. Storing it in the replica
72/// keeps the gate with the data it protects, so a rebuilt host handle reopening
73/// the same file returns in preflight even though its in-memory state is fresh.
74const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
75const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
76const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
77/// §7.4.4 client-local code: a pending outbox commit cannot re-encode under
78/// the new schema after a bump. Never a wire code (§10.3).
79const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
80
81#[derive(Debug, Serialize, Deserialize)]
82#[serde(rename_all = "camelCase", deny_unknown_fields)]
83struct PersistedLocalDataRebootstrapReceipt {
84    version: u8,
85    retained_commits: u64,
86    reset_subscriptions: u64,
87}
88
89fn invalid_local_rebootstrap_receipt() -> String {
90    "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
91}
92
93fn encode_local_rebootstrap_receipt(
94    retained_commits: usize,
95    reset_subscriptions: usize,
96) -> Result<String, String> {
97    let retained_commits =
98        u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
99    let reset_subscriptions =
100        u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
101    if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
102        return Err(invalid_local_rebootstrap_receipt());
103    }
104    serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
105        version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
106        retained_commits,
107        reset_subscriptions,
108    })
109    .map_err(|_| invalid_local_rebootstrap_receipt())
110}
111
112fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
113    // Pre-0.15.36 markers proved application but did not retain the receipt.
114    if value == "v1" {
115        return Ok((0, 0));
116    }
117    let receipt: PersistedLocalDataRebootstrapReceipt =
118        serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
119    if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
120        || receipt.retained_commits > MAX_JS_SAFE_INTEGER
121        || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
122    {
123        return Err(invalid_local_rebootstrap_receipt());
124    }
125    Ok((
126        usize::try_from(receipt.retained_commits)
127            .map_err(|_| invalid_local_rebootstrap_receipt())?,
128        usize::try_from(receipt.reset_subscriptions)
129            .map_err(|_| invalid_local_rebootstrap_receipt())?,
130    ))
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134enum SubState {
135    Active,
136    Revoked,
137    Failed,
138}
139
140#[cfg(test)]
141mod observation_tests {
142    use super::*;
143    use crate::native_transport::HostTransport;
144    use serde_json::json;
145
146    fn client() -> SyncClient {
147        SyncClient::new(
148            "retry-test".to_owned(),
149            &json!({
150                "version": 1,
151                "tables": [{
152                    "name": "tasks",
153                    "primaryKey": "id",
154                    "columns": [
155                        { "name": "id", "type": "string", "nullable": false },
156                        { "name": "project_id", "type": "string", "nullable": false }
157                    ],
158                    "scopes": [{ "pattern": "project:{project_id}" }]
159                }]
160            }),
161            ClientLimits::default(),
162        )
163        .expect("test client")
164    }
165
166    #[test]
167    fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
168        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
169            {"name":"id","type":"string","nullable":false},
170            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
171        for state in ["absent", "cached", "pinned"] {
172            let path = std::env::temp_dir().join(format!(
173                "syncular-blob-stage-{}.sqlite",
174                uuid::Uuid::new_v4()
175            ));
176            let mut client = SyncClient::open_path(
177                "stage-test".into(),
178                &schema,
179                Default::default(),
180                path.to_str().unwrap(),
181            )
182            .unwrap();
183            let bytes = b"atomic stage";
184            if state != "absent" {
185                client
186                    .upload_blob(bytes, Some("text/plain".into()), None)
187                    .unwrap();
188                if state == "cached" {
189                    client
190                        .conn
191                        .execute("DELETE FROM _syncular_blob_uploads", [])
192                        .unwrap();
193                }
194            }
195            let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
196            client
197                .conn
198                .execute_batch(
199                    "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
200                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
201                 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
202                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
203                )
204                .unwrap();
205            let error = client
206                .upload_blob(bytes, Some("application/octet-stream".into()), None)
207                .unwrap_err();
208            assert!(error.contains("injected stage failure"), "{state}: {error}");
209            assert!(client.conn.is_autocommit());
210            let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
211            drop(client);
212            let mut reopened = SyncClient::open_path(
213                "stage-test".into(),
214                &schema,
215                Default::default(),
216                path.to_str().unwrap(),
217            )
218            .unwrap();
219            let durable = reopened
220                .query("SELECT * FROM _syncular_blobs", &[])
221                .unwrap();
222            reopened
223                .conn
224                .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
225                .unwrap();
226            let retried = reopened.upload_blob(bytes, None, None).unwrap();
227            assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
228            assert_eq!(
229                reopened
230                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
231                    .unwrap(),
232                vec![Map::from_iter([(
233                    "blob_id".into(),
234                    Value::from(blob_id_for(bytes)),
235                )])]
236            );
237            drop(reopened);
238            std::fs::remove_file(&path).unwrap();
239            assert_eq!(durable, bodies, "{state}: reopened body state");
240            assert_eq!(immediate, bodies, "{state}: immediate body state");
241        }
242    }
243
244    #[test]
245    fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
246        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
247            {"name":"id","type":"string","nullable":false},
248            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
249        for fault in [
250            "missing",
251            "body-type",
252            "length",
253            "hash",
254            "pin-metadata",
255            "read",
256            "pin-delete",
257        ] {
258            let path = std::env::temp_dir().join(format!(
259                "syncular-blob-upload-{}.sqlite",
260                uuid::Uuid::new_v4()
261            ));
262            let mut client = SyncClient::open_path(
263                "upload-test".into(),
264                &schema,
265                Default::default(),
266                path.to_str().unwrap(),
267            )
268            .unwrap();
269            let bytes = b"durable upload";
270            let ref_value = client
271                .upload_blob(bytes, Some("text/plain".into()), None)
272                .unwrap();
273            let commit = client
274                .mutate(vec![Mutation::Upsert {
275                    table: "attachments".into(),
276                    values: Map::from_iter([
277                        ("id".into(), Value::from("pending")),
278                        (
279                            "file".into(),
280                            Value::from(format!(
281                                "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
282                                blob_id_for(bytes),
283                                bytes.len()
284                            )),
285                        ),
286                    ]),
287                    base_version: None,
288                }])
289                .unwrap();
290            client
291                .conn
292                .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
293                .unwrap();
294            client.conn.execute_batch(match fault {
295                "missing" => "DELETE FROM _syncular_blobs",
296                "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
297                "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
298                "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
299                "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
300                "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
301                _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
302            }).unwrap();
303            let state = if fault == "read" {
304                Vec::new()
305            } else {
306                client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
307            };
308            let mut transport = CountingRealtimeTransport::default();
309            let error = client.flush_blob_uploads(&mut transport).unwrap_err();
310            assert_eq!(
311                error.code,
312                if matches!(fault, "read" | "pin-delete") {
313                    "client.failed"
314                } else {
315                    "sync.local_corrupt"
316                },
317                "{fault}"
318            );
319            assert_eq!(
320                transport.blob_uploads.len(),
321                usize::from(fault == "pin-delete"),
322                "{fault}: corrupt bytes must never be uploaded"
323            );
324            assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
325            if fault != "read" {
326                assert_eq!(
327                    client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
328                    state
329                );
330            } else {
331                client
332                    .conn
333                    .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
334                    .unwrap();
335            }
336            drop(client);
337            let mut reopened = SyncClient::open_path(
338                "upload-test".into(),
339                &schema,
340                Default::default(),
341                path.to_str().unwrap(),
342            )
343            .unwrap();
344            assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
345            if fault != "read" {
346                assert_eq!(
347                    reopened
348                        .query("SELECT * FROM _syncular_blobs", &[])
349                        .unwrap(),
350                    state
351                );
352            }
353            if fault == "pin-delete" {
354                reopened
355                    .conn
356                    .execute_batch("DROP TRIGGER fail_pin_delete")
357                    .unwrap();
358            }
359            if fault == "pin-metadata" {
360                reopened
361                    .conn
362                    .execute(
363                        "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
364                        [],
365                    )
366                    .unwrap();
367            }
368            reopened
369                .conn
370                .execute_batch(
371                    "DELETE FROM _syncular_blobs;
372                     INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
373                )
374                .unwrap();
375            transport.blob_uploads.clear();
376            reopened.flush_blob_uploads(&mut transport).unwrap();
377            assert_eq!(
378                transport.blob_uploads,
379                [(blob_id_for(bytes), bytes.to_vec())]
380            );
381            assert_eq!(
382                reopened
383                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
384                    .unwrap(),
385                Vec::<Map<String, Value>>::new()
386            );
387            assert_eq!(reopened.pending_commit_ids(), [commit]);
388            assert_eq!(
389                reopened
390                    .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
391                    .unwrap()
392                    .unwrap()
393                    .bytes,
394                bytes
395            );
396            drop(reopened);
397            std::fs::remove_file(path).unwrap();
398        }
399    }
400
401    #[test]
402    fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
403        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
404            {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
405            "scopes":[{"pattern":"project:{project_id}"}]}]});
406        for realtime in [false, true] {
407            for fault in [
408                "none",
409                "row",
410                "revision",
411                "commit",
412                "cursor",
413                "error",
414                "missing-end",
415            ] {
416                if realtime && fault == "missing-end" {
417                    continue;
418                }
419                let path = std::env::temp_dir()
420                    .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
421                let mut client = SyncClient::open_path(
422                    "frame-test".into(),
423                    &schema,
424                    Default::default(),
425                    path.to_str().unwrap(),
426                )
427                .unwrap();
428                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
429                client
430                    .subscribe(
431                        "tasks".into(),
432                        "tasks".into(),
433                        vec![("project_id".into(), vec!["p1".into()])],
434                        None,
435                    )
436                    .unwrap();
437                client.subs[0].cursor = 0;
438                client.subs[0].synced_once = true;
439                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
440                client.persist_sub(&client.subs[0]).unwrap();
441                client.realtime_connected = true;
442                client.drain_change_batches();
443                let mut transport = CountingRealtimeTransport::default();
444                let (_, meta) = client.build_request(false);
445                let mut response = Message {
446                    wire_version: WIRE_VERSION,
447                    msg_kind: MsgKind::Response,
448                    frames: vec![
449                        Frame::RespHeader {
450                            required_schema_version: None,
451                            latest_schema_version: None,
452                            log_epoch: Some("epoch-1".into()),
453                            reset_required: Some(false),
454                        },
455                        Frame::SubStart {
456                            id: "tasks".into(),
457                            status: SubStatus::Active,
458                            reason_code: "".into(),
459                            effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
460                            bootstrap: false,
461                        },
462                    ],
463                };
464                for (index, id) in ["first", "second"].iter().enumerate() {
465                    let values = Map::from_iter([
466                        ("id".into(), Value::from(*id)),
467                        ("project_id".into(), Value::from("p1")),
468                    ]);
469                    response.frames.push(Frame::Commit {
470                        commit_seq: index as i64 + 1,
471                        actor_id: "writer".into(),
472                        created_at_ms: 1,
473                        tables: vec!["tasks".into()],
474                        changes: vec![ssp2::model::Change {
475                            table_index: 0,
476                            row_id: (*id).into(),
477                            op: Op::Upsert,
478                            row_version: Some(index as i64 + 1),
479                            scopes: vec![("project_id".into(), "p1".into())],
480                            row: Some(
481                                encode_row_json(
482                                    client.schema.table("tasks").unwrap(),
483                                    id,
484                                    &values,
485                                    &client.encryption,
486                                )
487                                .unwrap(),
488                            ),
489                        }],
490                    });
491                }
492                response.frames.push(Frame::SubEnd {
493                    next_cursor: 2,
494                    bootstrap_state: None,
495                });
496                let valid = response.clone();
497                match fault {
498                    "row" => {
499                        let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
500                        let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
501                    },
502                    "revision" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' AND NEW.value = '2' AND EXISTS (SELECT 1 FROM _syncular_base_tasks WHERE id = 'second') BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END").unwrap(),
503                    "commit" => client.conn.execute_batch("PRAGMA foreign_keys = ON; CREATE TABLE frame_parent(id INTEGER PRIMARY KEY); CREATE TABLE frame_child(id INTEGER REFERENCES frame_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_frame AFTER INSERT ON _syncular_base_tasks WHEN NEW.id = 'second' BEGIN INSERT INTO frame_child VALUES(1); END").unwrap(),
504                    "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
505                    "error" => {response.frames.truncate(3);response.frames.push(Frame::Error {code:"sync.invalid_request".into(),message:"later error".into(),category:"protocol".into(),retryable:false,recommended_action:"retry".into(),details:None});},
506                    "missing-end" => {response.frames.truncate(3);},
507                    _ => {}
508                }
509                if realtime {
510                    client.on_realtime_binary(&mut transport, &encode_message(&response));
511                } else {
512                    assert_eq!(
513                        matches!(
514                            client.process_response(&mut transport, response, &meta),
515                            SyncOutcome::Ok(_)
516                        ),
517                        fault == "none",
518                        "{fault}"
519                    );
520                }
521                assert!(
522                    client.conn.is_autocommit(),
523                    "no open transaction after {realtime}/{fault}"
524                );
525                let count = if matches!(fault, "none" | "cursor") {
526                    2
527                } else {
528                    1
529                };
530                let expected_revision = count + u64::from(realtime && fault != "none");
531                assert_eq!(
532                    client.local_revision(),
533                    expected_revision,
534                    "{realtime}/{fault}"
535                );
536                let batches = client.drain_change_batches();
537                assert_eq!(
538                    batches.len(),
539                    expected_revision as usize,
540                    "{realtime}/{fault}"
541                );
542                let row_batches: Vec<_> = batches
543                    .iter()
544                    .filter(|batch| !batch.tables.is_empty())
545                    .collect();
546                assert_eq!(row_batches.len(), count as usize);
547                assert_eq!(
548                    row_batches
549                        .iter()
550                        .map(|b| b.revision.clone())
551                        .collect::<Vec<_>>(),
552                    (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
553                );
554                for table in ["tasks", "_syncular_base_tasks"] {
555                    assert_eq!(
556                        client
557                            .conn
558                            .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
559                                .get::<_, u64>(0))
560                            .unwrap(),
561                        count,
562                        "{realtime}/{fault}/{table}"
563                    );
564                }
565                assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
566                assert_eq!(
567                    transport
568                        .messages
569                        .iter()
570                        .filter(|m| m.contains("\"ack\""))
571                        .count(),
572                    usize::from(fault == "none")
573                );
574                if matches!(fault, "revision" | "commit" | "cursor") {
575                    client
576                        .conn
577                        .execute_batch("DROP TRIGGER fail_frame")
578                        .unwrap();
579                }
580                drop(client);
581                let mut reopened = SyncClient::open_path(
582                    "frame-test".into(),
583                    &schema,
584                    Default::default(),
585                    path.to_str().unwrap(),
586                )
587                .unwrap();
588                assert_eq!(reopened.local_revision(), expected_revision);
589                assert_eq!(
590                    reopened
591                        .query("SELECT id FROM tasks ORDER BY id", &[])
592                        .unwrap()
593                        .len(),
594                    count as usize
595                );
596                assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
597                let (_, meta) = reopened.build_request(false);
598                assert!(matches!(
599                    reopened.process_response(&mut transport, valid, &meta),
600                    SyncOutcome::Ok(_)
601                ));
602                assert_eq!(
603                    reopened
604                        .query("SELECT id FROM tasks ORDER BY id", &[])
605                        .unwrap()
606                        .len(),
607                    2
608                );
609                assert_eq!(reopened.subs[0].cursor, 2);
610                drop(reopened);
611                std::fs::remove_file(path).unwrap();
612            }
613        }
614    }
615
616    #[test]
617    fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
618        for status in [SubStatus::Reset, SubStatus::Revoked] {
619            for fault in ["subscription", "revision"] {
620                let mut client = client();
621                client
622                    .subscribe(
623                        "tasks".into(),
624                        "tasks".into(),
625                        vec![("project_id".into(), vec!["p1".into()])],
626                        None,
627                    )
628                    .unwrap();
629                client.subs[0].cursor = 41;
630                client.subs[0].synced_once = true;
631                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
632                client.persist_sub(&client.subs[0]).unwrap();
633                client
634                    .mutate(vec![Mutation::Upsert {
635                        table: "tasks".into(),
636                        values: Map::from_iter([
637                            ("id".into(), Value::from("pending")),
638                            ("project_id".into(), Value::from("p1")),
639                        ]),
640                        base_version: None,
641                    }])
642                    .unwrap();
643                let ids = client.pending_commit_ids();
644                let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
645                let revision = client.local_revision();
646                client.drain_change_batches();
647                let (_, meta) = client.build_request(false);
648                client.conn.execute_batch(if fault == "subscription" {
649                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
650                } else {
651                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
652                }).unwrap();
653                let mut transport = CountingRealtimeTransport::default();
654                let mut report = SyncReport::default();
655                // Reset changes window completeness and therefore needs a revision.
656                if status == SubStatus::Reset {
657                    client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
658                }
659                let result = client.process_section(
660                    &mut transport,
661                    "tasks",
662                    status,
663                    "sync.scope_revoked",
664                    vec![],
665                    vec![],
666                    Some((42, None)),
667                    &meta,
668                    &mut report,
669                );
670                assert!(result.is_err(), "{status:?}/{fault}");
671                assert!(client.conn.is_autocommit());
672                assert_eq!(client.subs[0].cursor, 41);
673                assert_eq!(client.subs[0].state, SubState::Active);
674                assert_eq!(client.pending_commit_ids(), ids);
675                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
676                assert!(client.rejections.is_empty());
677                assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
678                assert_eq!(client.local_revision(), revision);
679                assert!(client.drain_change_batches().is_empty());
680                assert!(report.revoked.is_empty() && report.resets.is_empty());
681            }
682        }
683    }
684
685    #[test]
686    fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
687        let mut client = client();
688        client
689            .subscribe(
690                "tasks".into(),
691                "tasks".into(),
692                vec![("project_id".into(), vec!["p1".into()])],
693                None,
694            )
695            .unwrap();
696        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
697        client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
698            CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
699        let row = |id: &str| ssp2::segment::SegmentRow {
700            server_version: 1,
701            values: vec![
702                Some(ColumnValue::String(id.into())),
703                Some(ColumnValue::String("p1".into())),
704            ],
705        };
706        let segment = RowsSegment {
707            table: "tasks".into(),
708            schema_version: 1,
709            columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
710            blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
711        };
712        assert!(client.apply_segment(0, &segment, true).is_err());
713        assert!(client.conn.is_autocommit());
714        assert_eq!(
715            client
716                .query("SELECT id FROM tasks ORDER BY id", &[])
717                .unwrap(),
718            vec![Map::from_iter([("id".into(), Value::from("first"))])]
719        );
720        assert_eq!(client.local_revision(), 1);
721        assert_eq!(client.drain_change_batches().len(), 1);
722        assert_eq!(client.subs[0].cursor, -1);
723        client
724            .conn
725            .execute_batch("DROP TRIGGER fail_block")
726            .unwrap();
727        assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
728        assert_eq!(client.local_revision(), 3);
729        assert_eq!(client.drain_change_batches().len(), 2);
730    }
731
732    #[test]
733    fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
734        assert_eq!(
735            client().diagnostics_storage().blob_cache_bytes_approx,
736            Some(0)
737        );
738        let client = SyncClient::new(
739            "diagnostic-storage".to_owned(),
740            &json!({
741                "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
742                    "columns": [{"name": "id", "type": "string", "nullable": false},
743                        {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
744            }),
745            ClientLimits {
746                blob_cache_max_bytes: Some(5),
747                ..Default::default()
748            },
749        )
750        .unwrap();
751        let empty = client.diagnostics_storage();
752        assert_eq!(empty.status, "healthy");
753        assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
754        assert_eq!(empty.retained_outcome_entries, Some(0));
755        assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
756        assert_eq!(empty.blob_cache_bytes_approx, Some(0));
757        client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
758            INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
759                VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
760            INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
761                VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
762        let written = client.diagnostics_storage();
763        assert_eq!(written.status, "pressure");
764        assert_eq!(written.pending_outbox_bytes_approx, Some(2));
765        assert_eq!(written.retained_outcome_entries, Some(2));
766        assert_eq!(written.retained_outcome_bytes_approx, Some(6));
767        assert_eq!(written.blob_cache_bytes_approx, Some(8));
768        assert_eq!(
769            written.pressure_reason_code.as_deref(),
770            Some("client.blob_cache_over_limit")
771        );
772        client
773            .conn
774            .execute_batch(
775                "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
776            DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
777            )
778            .unwrap();
779        let staged = client.diagnostics_storage();
780        assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
781        assert_eq!(staged.retained_outcome_entries, Some(0));
782        assert_eq!(staged.blob_cache_bytes_approx, Some(0));
783        assert_eq!(staged.status, "healthy");
784        client.conn.execute_batch("ROLLBACK").unwrap();
785        assert_eq!(client.diagnostics_storage(), written);
786        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
787        let unreadable = client.diagnostics_storage();
788        assert_eq!(unreadable.status, "unreadable");
789        assert!(unreadable.database_bytes_approx.is_none());
790        assert!(unreadable.pending_outbox_bytes_approx.is_none());
791        assert!(unreadable.retained_outcome_entries.is_none());
792        assert!(unreadable.retained_outcome_bytes_approx.is_none());
793        assert!(unreadable.blob_cache_bytes_approx.is_none());
794        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
795        let restored = client.diagnostics_storage();
796        assert_eq!(restored.status, written.status);
797        assert_eq!(
798            restored.retained_outcome_bytes_approx,
799            written.retained_outcome_bytes_approx
800        );
801        client.conn.set_prepared_statement_cache_capacity(0);
802        assert_eq!(client.diagnostics_storage(), restored);
803    }
804
805    #[test]
806    fn background_retry_deadlines_back_off_and_reset() {
807        let mut client = client();
808        client.schedule_background_retry();
809        assert!(matches!(
810            client.drain_sync_intents().as_slice(),
811            [SyncIntent::Background { delay_ms: 250 }]
812        ));
813        client.schedule_background_retry();
814        assert!(matches!(
815            client.drain_sync_intents().as_slice(),
816            [SyncIntent::Background { delay_ms: 500 }]
817        ));
818        client.reset_background_retry();
819        client.schedule_background_retry();
820        assert!(matches!(
821            client.drain_sync_intents().as_slice(),
822            [SyncIntent::Background { delay_ms: 250 }]
823        ));
824    }
825
826    #[test]
827    fn log_epoch_reset_requests_an_immediate_follow_up_round() {
828        let mut client = client();
829        client
830            .subscribe(
831                "epoch-tasks".to_owned(),
832                "tasks".to_owned(),
833                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
834                None,
835            )
836            .expect("subscribe");
837        client.drain_sync_intents();
838
839        client
840            .run_log_epoch_reset("epoch-2")
841            .expect("reset partition log epoch");
842
843        assert!(matches!(
844            client.drain_sync_intents().as_slice(),
845            [SyncIntent::Interactive]
846        ));
847    }
848
849    #[derive(Default)]
850    struct CountingRealtimeTransport {
851        connects: usize,
852        closes: usize,
853        messages: Vec<String>,
854        blob_uploads: Vec<(String, Vec<u8>)>,
855        blob_download: Option<Vec<u8>>,
856    }
857
858    impl Transport for CountingRealtimeTransport {
859        fn blob_upload(
860            &mut self,
861            blob_id: &str,
862            bytes: &[u8],
863            _media_type: Option<&str>,
864        ) -> Result<(), TransportError> {
865            self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
866            Ok(())
867        }
868
869        fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
870            self.blob_download
871                .clone()
872                .map(BlobDownload::Bytes)
873                .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
874        }
875
876        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
877            Err(TransportError::new("sync.transport_failed", "offline"))
878        }
879
880        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
881            Err(TransportError::new("sync.transport_failed", "offline"))
882        }
883
884        fn download_segment(
885            &mut self,
886            _request: &SegmentRequest,
887            _on_progress: &mut dyn FnMut(u64),
888        ) -> Result<Vec<u8>, TransportError> {
889            Err(TransportError::new("sync.transport_failed", "offline"))
890        }
891
892        fn realtime_connect(&mut self) -> Result<(), TransportError> {
893            self.connects += 1;
894            Ok(())
895        }
896
897        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
898            self.messages.push(text.to_owned());
899            Ok(())
900        }
901
902        fn realtime_close(&mut self) -> Result<(), TransportError> {
903            self.closes += 1;
904            Ok(())
905        }
906    }
907
908    #[test]
909    fn blob_results_own_downloaded_bytes() {
910        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
911            {"name":"id","type":"string","nullable":false},
912            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
913        let bytes = b"owned typed result".to_vec();
914        let blob_id = blob_id_for(&bytes);
915        let mut transport = CountingRealtimeTransport {
916            blob_download: Some(bytes.clone()),
917            ..Default::default()
918        };
919        let result = {
920            let mut client =
921                SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
922                    .unwrap();
923            let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
924            assert_eq!(result.blob_id, blob_id);
925            assert_eq!(result.byte_length, bytes.len() as i64);
926            assert_eq!(result.bytes, bytes);
927            assert_eq!(result.media_type, None);
928            assert_eq!(
929                client
930                    .conn
931                    .query_row(
932                        "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
933                        rusqlite::params![blob_id],
934                        |row| row.get::<_, i64>(0),
935                    )
936                    .unwrap(),
937                0
938            );
939            result
940        };
941        assert_eq!(result.bytes, bytes);
942
943        let mut client =
944            SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
945        assert_eq!(
946            client
947                .fetch_blob_bytes(&mut transport, "not json")
948                .unwrap_err(),
949            (
950                "client.failed".to_owned(),
951                "blob ref is not JSON".to_owned()
952            )
953        );
954    }
955
956    #[test]
957    fn visible_blob_references_survive_cache_trimming() {
958        let schema = json!({"version":1,"tables":[
959            {"name":"attachments","primaryKey":"id","columns":[
960                {"name":"id","type":"string","nullable":false},
961                {"name":"body","type":"blob_ref","nullable":true},
962                {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
963            {"name":"avatars","primaryKey":"id","columns":[
964                {"name":"id","type":"string","nullable":false},
965                {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
966        ]});
967        let bytes = b"referenced above cap".to_vec();
968        let blob_id = blob_id_for(&bytes);
969        let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
970        let mut client = SyncClient::new(
971            "visible-blob-refs".to_owned(),
972            &schema,
973            ClientLimits {
974                blob_cache_max_bytes: Some(1),
975                ..Default::default()
976            },
977        )
978        .unwrap();
979        client.conn.execute(
980            "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
981            rusqlite::params![reference, reference, reference, reference],
982        ).unwrap();
983        client
984            .conn
985            .execute(
986                "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
987                rusqlite::params![reference],
988            )
989            .unwrap();
990        client.conn.execute(
991            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
992            [],
993        ).unwrap();
994        let mut transport = CountingRealtimeTransport {
995            blob_download: Some(bytes.clone()),
996            ..Default::default()
997        };
998
999        assert_eq!(
1000            client
1001                .fetch_blob_bytes(&mut transport, &blob_id)
1002                .unwrap()
1003                .bytes,
1004            bytes
1005        );
1006        let rows = client
1007            .conn
1008            .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1009            .unwrap()
1010            .query_map([], |row| row.get::<_, String>(0))
1011            .unwrap()
1012            .collect::<Result<Vec<_>, _>>()
1013            .unwrap();
1014        assert_eq!(rows, vec![blob_id]);
1015    }
1016
1017    #[test]
1018    fn realtime_connection_ownership_is_idempotent() {
1019        let mut client = client();
1020        let mut transport = CountingRealtimeTransport {
1021            connects: 0,
1022            closes: 0,
1023            ..Default::default()
1024        };
1025        client
1026            .connect_realtime(&mut transport)
1027            .expect("first connect");
1028        client
1029            .connect_realtime(&mut transport)
1030            .expect("idempotent connect");
1031        assert_eq!(transport.connects, 1);
1032        client.disconnect_realtime(&mut transport);
1033        client.disconnect_realtime(&mut transport);
1034        assert_eq!(transport.closes, 1);
1035        client
1036            .connect_realtime(&mut transport)
1037            .expect("deliberate reconnect");
1038        assert_eq!(transport.connects, 2);
1039    }
1040
1041    #[test]
1042    fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1043        let mut client = client();
1044        client
1045            .subscribe(
1046                "repair-tasks".to_owned(),
1047                "tasks".to_owned(),
1048                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1049                None,
1050            )
1051            .expect("subscribe");
1052        {
1053            let sub = client
1054                .subs
1055                .iter_mut()
1056                .find(|sub| sub.id == "repair-tasks")
1057                .expect("subscription");
1058            sub.cursor = 42;
1059            sub.synced_once = true;
1060            let persisted = sub.clone();
1061            client.persist_sub(&persisted).unwrap();
1062        }
1063        for table in ["_syncular_base_tasks", "tasks"] {
1064            client
1065                .conn
1066                .execute(
1067                    &format!(
1068                        "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1069                    ),
1070                    rusqlite::params!["server-row", "p1"],
1071                )
1072                .expect("seed server row");
1073        }
1074        let pending = client
1075            .mutate(vec![Mutation::Upsert {
1076                table: "tasks".to_owned(),
1077                values: Map::from_iter([
1078                    ("id".to_owned(), Value::from("offline-row")),
1079                    ("project_id".to_owned(), Value::from("p1")),
1080                ]),
1081                base_version: None,
1082            }])
1083            .expect("queue offline work");
1084        client.drain_change_batches();
1085        client.drain_sync_intents();
1086
1087        assert_eq!(
1088            client
1089                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1090                    rebootstrap_id: "support-case-001".to_owned(),
1091                })
1092                .expect("rebootstrap"),
1093            LocalDataRebootstrapResult {
1094                already_applied: false,
1095                retained_commits: 1,
1096                reset_subscriptions: 1,
1097            }
1098        );
1099        let visible_ids = client
1100            .conn
1101            .prepare("SELECT id FROM tasks ORDER BY id")
1102            .expect("prepare visible ids")
1103            .query_map([], |row| row.get::<_, String>(0))
1104            .expect("query visible ids")
1105            .collect::<Result<Vec<_>, _>>()
1106            .expect("collect visible ids");
1107        assert_eq!(visible_ids, vec!["offline-row"]);
1108        assert_eq!(client.pending_commit_ids(), vec![pending]);
1109        assert_eq!(
1110            client
1111                .subscription_state("repair-tasks")
1112                .expect("subscription")
1113                .cursor,
1114            -1
1115        );
1116        assert!(client.upgrading());
1117        assert!(client.sync_needed());
1118        assert!(matches!(
1119            client.drain_sync_intents().as_slice(),
1120            [SyncIntent::Interactive]
1121        ));
1122        assert_eq!(client.drain_change_batches().len(), 1);
1123
1124        assert_eq!(
1125            client
1126                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1127                    rebootstrap_id: "support-case-001".to_owned(),
1128                })
1129                .expect("idempotent retry"),
1130            LocalDataRebootstrapResult {
1131                already_applied: true,
1132                retained_commits: 1,
1133                reset_subscriptions: 1,
1134            }
1135        );
1136        assert!(client.drain_change_batches().is_empty());
1137    }
1138
1139    #[test]
1140    fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1141        let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1142        assert_eq!(
1143            decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1144            (3, 4)
1145        );
1146        assert_eq!(
1147            decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1148            (0, 0)
1149        );
1150        for malformed in [
1151            "",
1152            "{}",
1153            r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1154            r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1155            r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1156        ] {
1157            assert_eq!(
1158                decode_local_rebootstrap_receipt(malformed)
1159                    .expect_err("malformed receipt must fail"),
1160                "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1161            );
1162        }
1163    }
1164
1165    #[test]
1166    fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1167        let path = std::env::temp_dir().join(format!(
1168            "syncular-rebootstrap-receipt-{}.db",
1169            uuid::Uuid::new_v4()
1170        ));
1171        let schema = json!({
1172            "version": 1,
1173            "tables": [{
1174                "name": "tasks",
1175                "primaryKey": "id",
1176                "columns": [
1177                    { "name": "id", "type": "string", "nullable": false },
1178                    { "name": "project_id", "type": "string", "nullable": false }
1179                ],
1180                "scopes": [{ "pattern": "project:{project_id}" }]
1181            }]
1182        });
1183        let path_string = path.to_str().expect("UTF-8 temp path");
1184
1185        {
1186            let mut first = SyncClient::open_path(
1187                "repair-restart-client".to_owned(),
1188                &schema,
1189                ClientLimits::default(),
1190                path_string,
1191            )
1192            .expect("first open");
1193            first
1194                .subscribe(
1195                    "repair-tasks".to_owned(),
1196                    "tasks".to_owned(),
1197                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1198                    None,
1199                )
1200                .expect("subscribe");
1201            first
1202                .mutate(vec![Mutation::Upsert {
1203                    table: "tasks".to_owned(),
1204                    values: Map::from_iter([
1205                        ("id".to_owned(), Value::from("offline-row")),
1206                        ("project_id".to_owned(), Value::from("p1")),
1207                    ]),
1208                    base_version: None,
1209                }])
1210                .expect("queue offline work");
1211            assert_eq!(
1212                first
1213                    .rebootstrap_local_data(&LocalDataRebootstrapInput {
1214                        rebootstrap_id: "restart-receipt".to_owned(),
1215                    })
1216                    .expect("first rebootstrap"),
1217                LocalDataRebootstrapResult {
1218                    already_applied: false,
1219                    retained_commits: 1,
1220                    reset_subscriptions: 1,
1221                }
1222            );
1223        }
1224
1225        let mut reopened = SyncClient::open_path(
1226            "repair-restart-client".to_owned(),
1227            &schema,
1228            ClientLimits::default(),
1229            path_string,
1230        )
1231        .expect("reopen");
1232        reopened.drain_change_batches();
1233        reopened.drain_sync_intents();
1234        assert_eq!(
1235            reopened
1236                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1237                    rebootstrap_id: "restart-receipt".to_owned(),
1238                })
1239                .expect("receipt replay"),
1240            LocalDataRebootstrapResult {
1241                already_applied: true,
1242                retained_commits: 1,
1243                reset_subscriptions: 1,
1244            }
1245        );
1246        assert!(reopened.drain_change_batches().is_empty());
1247        assert!(reopened.drain_sync_intents().is_empty());
1248        drop(reopened);
1249        std::fs::remove_file(path).expect("remove temp database");
1250    }
1251
1252    #[test]
1253    fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1254        let mut malformed = client();
1255        malformed
1256            .subscribe(
1257                "repair-tasks".to_owned(),
1258                "tasks".to_owned(),
1259                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1260                None,
1261            )
1262            .expect("subscribe");
1263        malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1264        malformed.drain_change_batches();
1265        malformed.drain_sync_intents();
1266        let malformed_error = malformed
1267            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1268                rebootstrap_id: "malformed".to_owned(),
1269            })
1270            .expect_err("malformed receipt must fail closed");
1271        assert_eq!(
1272            malformed_error,
1273            "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1274        );
1275        assert!(!malformed.upgrading());
1276        assert_eq!(
1277            malformed
1278                .subscription_state("repair-tasks")
1279                .expect("unchanged subscription")
1280                .cursor,
1281            -1
1282        );
1283        assert!(malformed.drain_change_batches().is_empty());
1284        assert!(malformed.drain_sync_intents().is_empty());
1285
1286        let mut unreadable = client();
1287        unreadable
1288            .conn
1289            .execute(
1290                "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1291                rusqlite::params!["server-row", "p1"],
1292            )
1293            .expect("seed visible row");
1294        unreadable
1295            .conn
1296            .execute("DROP TABLE _syncular_meta", [])
1297            .expect("break marker storage");
1298        let unreadable_error = unreadable
1299            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1300                rebootstrap_id: "unreadable".to_owned(),
1301            })
1302            .expect_err("unreadable marker storage must fail closed");
1303        assert_eq!(
1304            unreadable_error,
1305            "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1306        );
1307        let visible_rows = unreadable
1308            .conn
1309            .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1310            .expect("visible projection remains");
1311        assert_eq!(visible_rows, 1);
1312        assert!(!unreadable.upgrading());
1313        assert!(unreadable.drain_change_batches().is_empty());
1314        assert!(unreadable.drain_sync_intents().is_empty());
1315    }
1316
1317    #[test]
1318    fn local_rebootstrap_cannot_bypass_schema_floor() {
1319        let mut client = client();
1320        client.set_schema_floor(Some(SchemaFloor {
1321            required_schema_version: Some(2),
1322            latest_schema_version: Some(2),
1323        }));
1324        let error = client
1325            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1326                rebootstrap_id: "blocked-floor".to_owned(),
1327            })
1328            .expect_err("schema floor must block repair");
1329        assert!(error.contains("cannot bypass an active schema-floor stop"));
1330    }
1331
1332    #[test]
1333    fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
1334        let mut client = client();
1335        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1336        const COMMIT_COUNT: usize = 32;
1337
1338        for index in 0..COMMIT_COUNT {
1339            client
1340                .mutate(vec![Mutation::Upsert {
1341                    table: "tasks".to_owned(),
1342                    values: Map::from_iter([
1343                        ("id".to_owned(), Value::from(format!("task-{index}"))),
1344                        ("project_id".to_owned(), Value::from("project-1")),
1345                    ]),
1346                    base_version: None,
1347                }])
1348                .expect("queue commit");
1349        }
1350
1351        let (_, request_meta) = client.build_request(false);
1352        assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
1353        let mut frames = vec![Frame::RespHeader {
1354            required_schema_version: None,
1355            latest_schema_version: None,
1356            log_epoch: Some("epoch-1".to_owned()),
1357            reset_required: Some(false),
1358        }];
1359        frames.extend(request_meta.pushed_ids.iter().enumerate().map(
1360            |(index, client_commit_id)| Frame::PushResult {
1361                client_commit_id: client_commit_id.clone(),
1362                status: PushStatus::Applied,
1363                commit_seq: Some(index as i64 + 1),
1364                results: vec![OpResult::Applied { op_index: 0 }],
1365            },
1366        ));
1367        let response = Message {
1368            wire_version: WIRE_VERSION,
1369            msg_kind: MsgKind::Response,
1370            frames,
1371        };
1372        let mut transport =
1373            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
1374
1375        client.overlay_rebuild_count.set(0);
1376        client.outcome_prune_count.set(0);
1377        let outcome = client.process_response(&mut transport, response, &request_meta);
1378        assert!(matches!(outcome, SyncOutcome::Ok(_)));
1379        assert!(client.pending_commit_ids().is_empty());
1380        assert_eq!(
1381            client.overlay_rebuild_count.get(),
1382            1,
1383            "one response must reconcile its acknowledged commits with one overlay rebuild"
1384        );
1385        assert_eq!(
1386            client.outcome_prune_count.get(),
1387            1,
1388            "one response must enforce outcome retention once"
1389        );
1390    }
1391
1392    #[test]
1393    fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
1394        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
1395            "columns": [
1396                {"name":"id", "type":"string", "nullable":false},
1397                {"name":"project_id", "type":"string", "nullable":false},
1398                {"name":"title", "type":"string", "nullable":false}
1399            ], "scopes": [{"pattern":"project:{project_id}"}],
1400            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1401            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
1402        }] });
1403        let mut incremental =
1404            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1405        let mut reference =
1406            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1407        let tables = vec!["tasks".to_owned()];
1408        for index in 0..32 {
1409            let id = format!("task-{}", index % 8);
1410            let values = Map::from_iter([
1411                ("id".to_owned(), Value::from(id.clone())),
1412                (
1413                    "project_id".to_owned(),
1414                    Value::from(format!("p{}", index % 2)),
1415                ),
1416                ("title".to_owned(), Value::from(format!("needle{index}"))),
1417            ]);
1418            let payload = encode_row_json(
1419                incremental.schema.table("tasks").unwrap(),
1420                &id,
1421                &values,
1422                &incremental.encryption,
1423            )
1424            .unwrap();
1425            let change = ssp2::model::Change {
1426                table_index: 0,
1427                row_id: id,
1428                op: if index % 5 == 0 {
1429                    Op::Delete
1430                } else {
1431                    Op::Upsert
1432                },
1433                row_version: Some(index + 1),
1434                scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
1435                row: Some(payload),
1436            };
1437            reference.overlay_dirty.set(true);
1438            for instance in [&mut incremental, &mut reference] {
1439                instance.begin_observation("mirror_test").unwrap();
1440                let mut batch = ChangeAccumulator::default();
1441                instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
1442                instance
1443                    .apply_commit_changes(&tables, std::slice::from_ref(&change))
1444                    .unwrap();
1445                instance.rebuild_overlay_if_dirty();
1446                instance.finish_observation("mirror_test", batch).unwrap();
1447            }
1448            for sql in [
1449                "SELECT * FROM tasks ORDER BY id",
1450                "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
1451            ] {
1452                assert_eq!(
1453                    incremental.query(sql, &[]).unwrap(),
1454                    reference.query(sql, &[]).unwrap()
1455                );
1456            }
1457            assert_eq!(incremental.local_revision(), reference.local_revision());
1458            assert_eq!(
1459                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1460                serde_json::to_value(reference.drain_change_batches()).unwrap()
1461            );
1462        }
1463        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
1464        assert_eq!(reference.overlay_rebuild_count.get(), 32);
1465    }
1466
1467    #[test]
1468    fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
1469        let cases = [
1470            (
1471                "string",
1472                vec![
1473                    json!("1"),
1474                    json!("01"),
1475                    json!(""),
1476                    json!("nul\u{0}id"),
1477                    json!("é"),
1478                ],
1479            ),
1480            (
1481                "integer",
1482                vec![
1483                    json!(i64::MIN),
1484                    json!(-1),
1485                    json!(0),
1486                    json!(1),
1487                    json!(i64::MAX),
1488                ],
1489            ),
1490            ("boolean", vec![json!(false), json!(true)]),
1491            ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
1492            (
1493                "float",
1494                vec![
1495                    json!(1.0),
1496                    json!(1.0000000000000002),
1497                    json!(-1.0),
1498                    json!(1e20),
1499                ],
1500            ),
1501        ];
1502        for (kind, ids) in cases {
1503            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1504                "columns":[{"name":"id","type":kind,"nullable":false},
1505                    {"name":"project_id","type":"string","nullable":false}],
1506                "scopes":[{"pattern":"project:{project_id}"}]}]});
1507            let mut client =
1508                SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
1509            for (index, id) in ids.iter().enumerate() {
1510                let table = client.schema.table("tasks").unwrap();
1511                client
1512                    .write_base_row(
1513                        "tasks",
1514                        &vec![
1515                            json_to_column_value(&table.columns[0], Some(id)).unwrap(),
1516                            Some(ColumnValue::String(format!("p{index}"))),
1517                        ],
1518                        1,
1519                    )
1520                    .unwrap();
1521            }
1522            client.rebuild_overlay_if_dirty();
1523            for base in [true, false] {
1524                let full = if base {
1525                    base_table("tasks")
1526                } else {
1527                    visible_table("tasks")
1528                };
1529                let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
1530                for row_id in [
1531                    "1",
1532                    "01",
1533                    "1.0",
1534                    "1.0000000000000002",
1535                    "+1",
1536                    "1e0",
1537                    " 1",
1538                    "-1",
1539                    "0",
1540                    "9223372036854775807",
1541                    "-9223372036854775808",
1542                    "true",
1543                    "false",
1544                    "",
1545                    "nul\u{0}id",
1546                    "é",
1547                    "null",
1548                    "{\"a\":1}",
1549                ] {
1550                    let old_sql =
1551                        format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
1552                    let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
1553                    let old = client
1554                        .conn
1555                        .prepare(&old_sql)
1556                        .unwrap()
1557                        .query_map([row_id], |row| row.get::<_, String>(0))
1558                        .unwrap()
1559                        .collect::<Result<Vec<_>, _>>()
1560                        .unwrap();
1561                    let new = client
1562                        .conn
1563                        .prepare_cached(&new_sql)
1564                        .unwrap()
1565                        .query_map([row_id], |row| row.get::<_, String>(0))
1566                        .unwrap()
1567                        .collect::<Result<Vec<_>, _>>()
1568                        .unwrap();
1569                    assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
1570                    // The floating-point text collision is deliberate: an
1571                    // indexed numeric equality must not silently lose a match.
1572                    if kind == "float" && row_id == "1.0" {
1573                        assert_eq!(old.len(), 2);
1574                    }
1575                    let mut batch = ChangeAccumulator::default();
1576                    assert_eq!(
1577                        client.record_row_scopes(&mut batch, "tasks", row_id, base),
1578                        !old.is_empty()
1579                    );
1580                    let expected = old.first().map(|scope| {
1581                        BTreeMap::from([(
1582                            "tasks".to_owned(),
1583                            Some(BTreeSet::from([format!("project:{scope}")])),
1584                        )])
1585                    });
1586                    assert_eq!(batch.tables, expected.unwrap_or_default());
1587                    client
1588                        .conn
1589                        .execute_batch("SAVEPOINT lookup_delete")
1590                        .unwrap();
1591                    if base {
1592                        client.delete_base_row("tasks", row_id).unwrap();
1593                    } else {
1594                        client.apply_outbox_ops(&[OutboxOp {
1595                            upsert: false,
1596                            table: "tasks".into(),
1597                            row_id: row_id.into(),
1598                            base_version: None,
1599                            values: None,
1600                            changed_fields: None,
1601                        }]);
1602                    }
1603                    let remaining: i64 = client
1604                        .conn
1605                        .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
1606                            row.get(0)
1607                        })
1608                        .unwrap();
1609                    assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
1610                    client
1611                        .conn
1612                        .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
1613                        .unwrap();
1614                    client.overlay_dirty.set(false);
1615                }
1616                for (predicate, expected) in [
1617                    ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
1618                    (predicate, if kind == "float" { "SCAN" } else { "SEARCH" }),
1619                ] {
1620                    let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
1621                    let details = client
1622                        .conn
1623                        .prepare(&sql)
1624                        .unwrap()
1625                        .query_map(["1"], |row| row.get::<_, String>(3))
1626                        .unwrap()
1627                        .collect::<Result<Vec<_>, _>>()
1628                        .unwrap();
1629                    assert!(
1630                        details.iter().any(|detail| detail.contains(expected)),
1631                        "{kind}: {details:?}"
1632                    );
1633                }
1634            }
1635        }
1636    }
1637
1638    #[test]
1639    fn pending_row_reconciliation_matches_full_fifo_replay() {
1640        for unique in [false, true] {
1641            let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
1642                "name": name, "primaryKey": "id", "columns": [
1643                    {"name":"id", "type":"string", "nullable":false},
1644                    {"name":"project_id", "type":"string", "nullable":false},
1645                    {"name":"title", "type":"string", "nullable":false}],
1646                "scopes": [{"pattern":"project:{project_id}"}],
1647                "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
1648                "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
1649            })))});
1650            let mut incremental =
1651                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
1652            let mut reference =
1653                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
1654            let tables = vec!["tasks".to_owned(), "notes".to_owned()];
1655            for instance in [&mut incremental, &mut reference] {
1656                for table in &tables {
1657                    for index in 0..12 {
1658                        instance
1659                            .write_base_row(
1660                                table,
1661                                &vec![
1662                                    Some(ColumnValue::String(format!("row{index}"))),
1663                                    Some(ColumnValue::String("p0".into())),
1664                                    Some(ColumnValue::String(format!("base{index}"))),
1665                                ],
1666                                1,
1667                            )
1668                            .unwrap();
1669                    }
1670                }
1671                instance.rebuild_overlay_if_dirty();
1672            }
1673            for index in 0..80 {
1674                let mutations = (0..3)
1675                    .map(|offset| {
1676                        let table = tables[(index + offset) % 2].clone();
1677                        let id = format!("row{}", (index + offset) % 16);
1678                        if (index + offset) % 7 == 0 {
1679                            Mutation::Delete {
1680                                table,
1681                                row_id: id,
1682                                base_version: None,
1683                            }
1684                        } else {
1685                            Mutation::Upsert {
1686                                table,
1687                                values: Map::from_iter([
1688                                    ("id".into(), Value::from(id)),
1689                                    ("project_id".into(), Value::from(format!("p{}", index % 3))),
1690                                    (
1691                                        "title".into(),
1692                                        Value::from(format!("needle{}", (index + offset) % 5)),
1693                                    ),
1694                                ]),
1695                                base_version: None,
1696                            }
1697                        }
1698                    })
1699                    .collect::<Vec<_>>();
1700                for instance in [&mut incremental, &mut reference] {
1701                    instance.mutate(mutations.clone()).unwrap();
1702                }
1703                // Most frames touch only the table without unique constraints.
1704                // Mixed frames must retain full replay for both tables.
1705                let changes = (0..3)
1706                    .map(|offset| {
1707                        let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
1708                        let id = format!("row{}", (index + offset) % 16);
1709                        let values = Map::from_iter([
1710                            ("id".into(), Value::from(id.clone())),
1711                            (
1712                                "project_id".into(),
1713                                Value::from(format!("p{}", (index + 1) % 3)),
1714                            ),
1715                            (
1716                                "title".into(),
1717                                Value::from(format!("remote{index}_{offset}")),
1718                            ),
1719                        ]);
1720                        ssp2::model::Change {
1721                            table_index: table_index as u16,
1722                            row_id: id.clone(),
1723                            op: if (index + offset) % 5 == 0 {
1724                                Op::Delete
1725                            } else {
1726                                Op::Upsert
1727                            },
1728                            row_version: Some(index as i64 + 2),
1729                            scopes: vec![],
1730                            row: Some(
1731                                encode_row_json(
1732                                    incremental.schema.table(&tables[table_index]).unwrap(),
1733                                    &id,
1734                                    &values,
1735                                    &incremental.encryption,
1736                                )
1737                                .unwrap(),
1738                            ),
1739                        }
1740                    })
1741                    .collect::<Vec<_>>();
1742                reference.overlay_dirty.set(true);
1743                incremental.apply_commit_frame(&tables, &changes).unwrap();
1744                reference.apply_commit_frame(&tables, &changes).unwrap();
1745                for table in &tables {
1746                    for sql in [format!("SELECT * FROM {table} ORDER BY id"),
1747                        format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
1748                        format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
1749                        format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
1750                        assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
1751                    }
1752                }
1753                assert_eq!(incremental.local_revision(), reference.local_revision());
1754                assert_eq!(
1755                    serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1756                    serde_json::to_value(reference.drain_change_batches()).unwrap()
1757                );
1758            }
1759            assert_eq!(
1760                incremental.overlay_rebuild_count.get(),
1761                if unique { 81 } else { 21 }
1762            );
1763            assert_eq!(reference.overlay_rebuild_count.get(), 81);
1764        }
1765    }
1766
1767    #[test]
1768    fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
1769        let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
1770            "columns":[{"name":"id", "type":"string", "nullable":false},
1771                {"name":"title", "type":"string", "nullable":false}],
1772            "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
1773        let mut client =
1774            SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
1775        for (id, title) in [("a", "original"), ("b", "occupied")] {
1776            client
1777                .write_base_row(
1778                    "tasks",
1779                    &vec![
1780                        Some(ColumnValue::String(id.into())),
1781                        Some(ColumnValue::String(title.into())),
1782                    ],
1783                    1,
1784                )
1785                .unwrap();
1786        }
1787        client.rebuild_overlay_if_dirty();
1788        let pending = client
1789            .mutate(vec![Mutation::Upsert {
1790                table: "tasks".into(),
1791                values: Map::from_iter([
1792                    ("id".into(), Value::from("a")),
1793                    ("title".into(), Value::from("occupied")),
1794                ]),
1795                base_version: None,
1796            }])
1797            .unwrap();
1798        assert_eq!(
1799            client
1800                .conn
1801                .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
1802                    .get::<_, String>(
1803                    0
1804                ))
1805                .unwrap(),
1806            "original"
1807        );
1808        for (title, expected) in [("available", "occupied"), ("occupied", "original")] {
1809            let change = ssp2::model::Change {
1810                table_index: 0,
1811                row_id: "b".into(),
1812                op: Op::Upsert,
1813                row_version: Some(2),
1814                scopes: vec![],
1815                row: Some(
1816                    encode_row_json(
1817                        client.schema.table("tasks").unwrap(),
1818                        "b",
1819                        &Map::from_iter([
1820                            ("id".into(), Value::from("b")),
1821                            ("title".into(), Value::from(title)),
1822                        ]),
1823                        &client.encryption,
1824                    )
1825                    .unwrap(),
1826                ),
1827            };
1828            client
1829                .apply_commit_frame(&["tasks".into()], &[change])
1830                .unwrap();
1831            assert_eq!(
1832                client
1833                    .conn
1834                    .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
1835                        .get::<_, String>(
1836                        0
1837                    ))
1838                    .unwrap(),
1839                expected
1840            );
1841            assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
1842        }
1843        assert_eq!(client.overlay_rebuild_count.get(), 3);
1844    }
1845
1846    #[test]
1847    fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
1848        for failure in ["visible", "revision", "commit"] {
1849            let mut client = client();
1850            let id = client
1851                .mutate(vec![Mutation::Upsert {
1852                    table: "tasks".into(),
1853                    values: Map::from_iter([
1854                        ("id".into(), Value::from("t1")),
1855                        ("project_id".into(), Value::from("pending")),
1856                    ]),
1857                    base_version: None,
1858                }])
1859                .unwrap();
1860            client.drain_change_batches();
1861            let revision = client.local_revision();
1862            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
1863            let trigger = match failure {
1864                "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
1865                "revision" => format!("CREATE TRIGGER fail_pending BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(ABORT, 'injected revision failure'); END"),
1866                _ => "PRAGMA foreign_keys = ON; CREATE TABLE pending_parent(id INTEGER PRIMARY KEY); CREATE TABLE pending_child(id INTEGER REFERENCES pending_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_pending AFTER INSERT ON _syncular_base_tasks BEGIN INSERT INTO pending_child VALUES(1); END".to_owned(),
1867            };
1868            client.conn.execute_batch(&trigger).unwrap();
1869            let change = ssp2::model::Change {
1870                table_index: 0,
1871                row_id: "t1".into(),
1872                op: Op::Upsert,
1873                row_version: Some(2),
1874                scopes: vec![],
1875                row: Some(
1876                    encode_row_json(
1877                        client.schema.table("tasks").unwrap(),
1878                        "t1",
1879                        &Map::from_iter([
1880                            ("id".into(), Value::from("t1")),
1881                            ("project_id".into(), Value::from("remote")),
1882                        ]),
1883                        &client.encryption,
1884                    )
1885                    .unwrap(),
1886                ),
1887            };
1888            assert!(client
1889                .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change))
1890                .is_err());
1891            assert!(client.conn.is_autocommit());
1892            assert!(!client.overlay_dirty.get());
1893            assert_eq!(client.local_revision(), revision);
1894            assert!(client.drain_change_batches().is_empty());
1895            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
1896            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
1897            assert!(client
1898                .query("SELECT * FROM _syncular_base_tasks", &[])
1899                .unwrap()
1900                .is_empty());
1901            client
1902                .conn
1903                .execute_batch("DROP TRIGGER fail_pending")
1904                .unwrap();
1905            client
1906                .apply_commit_frame(&["tasks".into()], &[change])
1907                .unwrap();
1908            assert_eq!(client.local_revision(), revision + 1);
1909            assert_eq!(client.pending_commit_ids(), vec![id]);
1910            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
1911            assert_eq!(
1912                client
1913                    .conn
1914                    .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
1915                        .get::<_, String>(0))
1916                    .unwrap(),
1917                "remote"
1918            );
1919        }
1920    }
1921
1922    #[test]
1923    fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
1924        let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
1925            "columns": [
1926                {"name":"id", "type":"string", "nullable":false},
1927                {"name":"project_id", "type":"string", "nullable":false},
1928                {"name":"title", "type":"string", "nullable":false}],
1929            "scopes": [{"pattern":"project:{project_id}"}],
1930            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1931            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
1932        let mut incremental =
1933            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
1934        let mut reference =
1935            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
1936        for instance in [&mut incremental, &mut reference] {
1937            for index in 0..16 {
1938                instance
1939                    .write_base_row(
1940                        "tasks",
1941                        &vec![
1942                            Some(ColumnValue::String(format!("task{index}"))),
1943                            Some(ColumnValue::String("p0".to_owned())),
1944                            Some(ColumnValue::String(format!("base{index}"))),
1945                        ],
1946                        1,
1947                    )
1948                    .unwrap();
1949            }
1950            instance.rebuild_overlay_if_dirty();
1951            instance.overlay_rebuild_count.set(0);
1952        }
1953        for index in 0..100 {
1954            let mutations = (0..1 + index % 4)
1955                .map(|offset| {
1956                    let id = format!("task{}", (index + offset) % 24);
1957                    if (index + offset) % 7 == 0 {
1958                        Mutation::Delete {
1959                            table: "tasks".to_owned(),
1960                            row_id: id,
1961                            base_version: None,
1962                        }
1963                    } else {
1964                        Mutation::Upsert {
1965                            table: "tasks".to_owned(),
1966                            values: Map::from_iter([
1967                                ("id".to_owned(), Value::from(id)),
1968                                (
1969                                    "project_id".to_owned(),
1970                                    Value::from(format!("p{}", index % 2)),
1971                                ),
1972                                (
1973                                    "title".to_owned(),
1974                                    Value::from(format!("needle{}", (index + offset) % 5)),
1975                                ),
1976                            ]),
1977                            base_version: None,
1978                        }
1979                    }
1980                })
1981                .collect::<Vec<_>>();
1982            reference.overlay_dirty.set(true);
1983            incremental.mutate(mutations.clone()).unwrap();
1984            reference.mutate(mutations).unwrap();
1985            for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
1986                "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
1987                assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
1988            }
1989            assert_eq!(incremental.local_revision(), reference.local_revision());
1990            assert_eq!(
1991                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1992                serde_json::to_value(reference.drain_change_batches()).unwrap()
1993            );
1994        }
1995        assert_eq!(incremental.outbox.len(), 100);
1996        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
1997        assert_eq!(reference.overlay_rebuild_count.get(), 100);
1998    }
1999
2000    #[test]
2001    fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
2002        for fail_at in ["outbox", "revision"] {
2003            let mut client = client();
2004            let values = Map::from_iter([
2005                ("id".to_owned(), Value::from("t1")),
2006                ("project_id".to_owned(), Value::from("p1")),
2007            ]);
2008            let mutation = Mutation::Upsert {
2009                table: "tasks".to_owned(),
2010                values,
2011                base_version: None,
2012            };
2013            let trigger = if fail_at == "outbox" {
2014                "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
2015            } else {
2016                format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
2017            };
2018            client.conn.execute_batch(&trigger).unwrap();
2019            let revision = client.local_revision();
2020            assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
2021            assert_eq!(client.local_revision(), revision);
2022            assert!(client.outbox.is_empty());
2023            assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2024            assert_eq!(
2025                client
2026                    .conn
2027                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
2028                        .get::<_, i64>(0))
2029                    .unwrap(),
2030                0
2031            );
2032            assert!(client.drain_change_batches().is_empty());
2033            assert!(!client.overlay_dirty.get());
2034            client
2035                .conn
2036                .execute_batch("DROP TRIGGER fail_append")
2037                .unwrap();
2038            client.mutate(vec![mutation]).unwrap();
2039            assert_eq!(client.outbox.len(), 1);
2040            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
2041        }
2042    }
2043
2044    #[test]
2045    fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
2046        let mut client = client();
2047        let tables = vec!["tasks".to_owned(), "missing".to_owned()];
2048        let values = Map::from_iter([
2049            ("id".to_owned(), Value::from("t1")),
2050            ("project_id".to_owned(), Value::from("p1")),
2051        ]);
2052        let payload = encode_row_json(
2053            client.schema.table("tasks").unwrap(),
2054            "t1",
2055            &values,
2056            &client.encryption,
2057        )
2058        .unwrap();
2059        let first = ssp2::model::Change {
2060            table_index: 0,
2061            row_id: "t1".to_owned(),
2062            op: Op::Upsert,
2063            row_version: Some(1),
2064            scopes: vec![("project_id".to_owned(), "p1".to_owned())],
2065            row: Some(payload),
2066        };
2067        let mut invalid = first.clone();
2068        invalid.table_index = 1;
2069        let revision = client.local_revision();
2070        client.begin_observation("failed_mirror").unwrap();
2071        assert!(client
2072            .apply_commit_changes(&tables, &[first, invalid])
2073            .is_err());
2074        client.rollback_observation("failed_mirror");
2075        client.rebuild_overlay_if_dirty();
2076        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2077        assert_eq!(
2078            client
2079                .conn
2080                .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
2081                    .get::<_, i64>(
2082                    0
2083                ))
2084                .unwrap(),
2085            0
2086        );
2087        assert_eq!(client.local_revision(), revision);
2088        assert!(client.drain_change_batches().is_empty());
2089    }
2090
2091    #[test]
2092    fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
2093        for failure in ["journal", "revision", "commit", "later-frame"] {
2094            let mut client = client();
2095            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2096            for id in ["first", "second"] {
2097                client
2098                    .mutate(vec![Mutation::Upsert {
2099                        table: "tasks".to_owned(),
2100                        values: Map::from_iter([
2101                            ("id".to_owned(), Value::from(id)),
2102                            ("project_id".to_owned(), Value::from("p1")),
2103                        ]),
2104                        base_version: None,
2105                    }])
2106                    .unwrap();
2107            }
2108            let (_, meta) = client.build_request(false);
2109            // A malicious or stale response must not drain an unsent local commit.
2110            client
2111                .mutate(vec![Mutation::Upsert {
2112                    table: "tasks".to_owned(),
2113                    values: Map::from_iter([
2114                        ("id".to_owned(), Value::from("later")),
2115                        ("project_id".to_owned(), Value::from("p1")),
2116                    ]),
2117                    base_version: None,
2118                }])
2119                .unwrap();
2120            let ids = client.pending_commit_ids();
2121            let rows = client
2122                .query("SELECT * FROM tasks ORDER BY id", &[])
2123                .unwrap();
2124            let revision = client.local_revision();
2125            client.drain_change_batches();
2126            if failure != "later-frame" {
2127                client.conn.execute_batch(match failure {
2128                    "journal" => "CREATE TRIGGER fail_run BEFORE INSERT ON _syncular_commit_outcomes WHEN (SELECT count(*) FROM _syncular_commit_outcomes) = 1 BEGIN SELECT RAISE(FAIL, 'injected second journal failure'); END",
2129                    "revision" => "CREATE TRIGGER fail_run BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' AND (SELECT count(*) FROM _syncular_commit_outcomes) = 2 BEGIN SELECT RAISE(FAIL, 'injected run revision failure'); END",
2130                    _ => "PRAGMA foreign_keys = ON; CREATE TABLE ack_parent(id INTEGER PRIMARY KEY); CREATE TABLE ack_child(id INTEGER REFERENCES ack_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_run AFTER INSERT ON _syncular_commit_outcomes WHEN (SELECT count(*) FROM _syncular_commit_outcomes) = 2 BEGIN INSERT INTO ack_child VALUES (1); END",
2131                }).unwrap();
2132            }
2133            let mut response = Message {
2134                wire_version: WIRE_VERSION,
2135                msg_kind: MsgKind::Response,
2136                frames: vec![Frame::RespHeader {
2137                    required_schema_version: None,
2138                    latest_schema_version: None,
2139                    log_epoch: Some("epoch-1".to_owned()),
2140                    reset_required: Some(false),
2141                }],
2142            };
2143            for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
2144                response.frames.push(Frame::PushResult {
2145                    client_commit_id: id.to_owned(),
2146                    status: PushStatus::Applied,
2147                    commit_seq: Some(1),
2148                    results: vec![OpResult::Applied { op_index: 0 }],
2149                });
2150            }
2151            if failure == "later-frame" {
2152                response.frames.push(Frame::Error {
2153                    code: "sync.invalid_request".to_owned(),
2154                    message: "later error".to_owned(),
2155                    category: "protocol".to_owned(),
2156                    retryable: false,
2157                    recommended_action: "retry".to_owned(),
2158                    details: None,
2159                });
2160            }
2161            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2162            let outcome = client.process_response(&mut transport, response.clone(), &meta);
2163            assert!(
2164                matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
2165            );
2166            if failure == "later-frame" {
2167                assert_eq!(client.pending_commit_ids(), ids[2..]);
2168                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2169                assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
2170                let batches = client.drain_change_batches();
2171                assert_eq!(batches.len(), 1);
2172                assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
2173                assert_eq!(client.local_revision(), revision + 1);
2174                response.frames.pop();
2175            } else {
2176                assert_eq!(client.pending_commit_ids(), ids);
2177                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2178                assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
2179                assert_eq!(client.local_revision(), revision);
2180                assert!(client.drain_change_batches().is_empty());
2181                assert_eq!(
2182                    client
2183                        .query("SELECT * FROM tasks ORDER BY id", &[])
2184                        .unwrap(),
2185                    rows
2186                );
2187                client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
2188            }
2189            for frame in &mut response.frames {
2190                if let Frame::PushResult { status, .. } = frame {
2191                    *status = PushStatus::Cached;
2192                }
2193            }
2194            let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
2195            else {
2196                panic!("retry failed");
2197            };
2198            assert_eq!(
2199                report.applied,
2200                if failure == "later-frame" {
2201                    vec![]
2202                } else {
2203                    ids[..2].to_vec()
2204                }
2205            );
2206            assert_eq!(client.pending_commit_ids(), ids[2..]);
2207            let batches = client.drain_change_batches();
2208            assert_eq!(batches.len(), usize::from(failure != "later-frame"));
2209            if let Some(batch) = batches.first() {
2210                assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
2211            }
2212            assert_eq!(client.local_revision(), revision + 1);
2213        }
2214    }
2215
2216    #[test]
2217    fn failed_push_result_preserves_memory_disk_and_retry_identity() {
2218        for failure in ["revision", "journal", "commit"] {
2219            for status in ["applied", "cached", "rejected", "conflict"] {
2220                let mut client = client();
2221                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2222                for id in ["first", "later"] {
2223                    client
2224                        .mutate(vec![Mutation::Upsert {
2225                            table: "tasks".to_owned(),
2226                            values: Map::from_iter([
2227                                ("id".to_owned(), Value::from(id)),
2228                                ("project_id".to_owned(), Value::from("p1")),
2229                            ]),
2230                            base_version: None,
2231                        }])
2232                        .unwrap();
2233                }
2234                let (_, meta) = client.build_request(false);
2235                let ids = client.pending_commit_ids();
2236                let rows = client
2237                    .query("SELECT * FROM tasks ORDER BY id", &[])
2238                    .unwrap();
2239                let revision = client.local_revision();
2240                client.drain_change_batches();
2241                let (push_status, results) = match status {
2242                    "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
2243                    "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
2244                    "conflict" => (
2245                        PushStatus::Rejected,
2246                        vec![OpResult::Conflict {
2247                            op_index: 0,
2248                            code: "sync.version_conflict".to_owned(),
2249                            message: "conflict".to_owned(),
2250                            server_version: 1,
2251                            server_row: encode_row_json(
2252                                client.schema.table("tasks").unwrap(),
2253                                "first",
2254                                &Map::from_iter([
2255                                    ("id".to_owned(), Value::from("first")),
2256                                    ("project_id".to_owned(), Value::from("p1")),
2257                                ]),
2258                                &client.encryption,
2259                            )
2260                            .unwrap(),
2261                        }],
2262                    ),
2263                    _ => (
2264                        PushStatus::Rejected,
2265                        vec![OpResult::Error {
2266                            op_index: 0,
2267                            code: "sync.validation_failed".to_owned(),
2268                            message: "rejected".to_owned(),
2269                            retryable: false,
2270                        }],
2271                    ),
2272                };
2273                let response = Message {
2274                    wire_version: WIRE_VERSION,
2275                    msg_kind: MsgKind::Response,
2276                    frames: vec![
2277                        Frame::RespHeader {
2278                            required_schema_version: None,
2279                            latest_schema_version: None,
2280                            log_epoch: Some("epoch-1".to_owned()),
2281                            reset_required: Some(false),
2282                        },
2283                        Frame::PushResult {
2284                            client_commit_id: ids[0].clone(),
2285                            status: push_status,
2286                            commit_seq: Some(1),
2287                            results,
2288                        },
2289                    ],
2290                };
2291                client.conn.execute_batch(match failure {
2292                    "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
2293                    "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
2294                    _ => "PRAGMA foreign_keys = ON; CREATE TABLE ack_parent(id INTEGER PRIMARY KEY); CREATE TABLE ack_child(id INTEGER REFERENCES ack_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_ack AFTER INSERT ON _syncular_commit_outcomes BEGIN INSERT INTO ack_child VALUES (1); END",
2295                }).unwrap();
2296                let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2297                let outcome = client.process_response(&mut transport, response.clone(), &meta);
2298                assert_eq!(
2299                    client.pending_commit_ids(),
2300                    ids,
2301                    "{failure}/{status}: pending identity"
2302                );
2303                assert_eq!(
2304                    client.local_revision(),
2305                    revision,
2306                    "{failure}/{status}: revision"
2307                );
2308                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2309                assert!(client.conflicts().is_empty());
2310                assert!(client.rejections().is_empty());
2311                assert!(client.drain_change_batches().is_empty());
2312                assert_eq!(
2313                    client
2314                        .query("SELECT * FROM tasks ORDER BY id", &[])
2315                        .unwrap(),
2316                    rows
2317                );
2318                assert!(
2319                    matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
2320                    "{failure}/{status}: failed round"
2321                );
2322                client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
2323                assert!(matches!(
2324                    client.process_response(&mut transport, response, &meta),
2325                    SyncOutcome::Ok(_)
2326                ));
2327                assert_eq!(client.pending_commit_ids(), ids[1..]);
2328                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2329                assert_eq!(client.drain_change_batches().len(), 1);
2330            }
2331        }
2332    }
2333
2334    #[test]
2335    fn mixed_push_results_reconcile_and_prune_once_per_response() {
2336        let mut client = client();
2337        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2338        for index in 0..4 {
2339            client
2340                .mutate(vec![Mutation::Upsert {
2341                    table: "tasks".to_owned(),
2342                    values: Map::from_iter([
2343                        ("id".to_owned(), Value::from(format!("task-{index}"))),
2344                        ("project_id".to_owned(), Value::from("project-1")),
2345                    ]),
2346                    base_version: None,
2347                }])
2348                .expect("queue commit");
2349        }
2350
2351        let (_, request_meta) = client.build_request(false);
2352        let ids = &request_meta.pushed_ids;
2353        assert_eq!(ids.len(), 4);
2354        let response = Message {
2355            wire_version: WIRE_VERSION,
2356            msg_kind: MsgKind::Response,
2357            frames: vec![
2358                Frame::RespHeader {
2359                    required_schema_version: None,
2360                    latest_schema_version: None,
2361                    log_epoch: Some("epoch-1".to_owned()),
2362                    reset_required: Some(false),
2363                },
2364                Frame::PushResult {
2365                    client_commit_id: ids[0].clone(),
2366                    status: PushStatus::Applied,
2367                    commit_seq: Some(1),
2368                    results: vec![OpResult::Applied { op_index: 0 }],
2369                },
2370                Frame::PushResult {
2371                    client_commit_id: ids[1].clone(),
2372                    status: PushStatus::Cached,
2373                    commit_seq: Some(2),
2374                    results: vec![OpResult::Applied { op_index: 0 }],
2375                },
2376                Frame::PushResult {
2377                    client_commit_id: ids[2].clone(),
2378                    status: PushStatus::Rejected,
2379                    commit_seq: None,
2380                    results: vec![OpResult::Error {
2381                        op_index: 0,
2382                        code: "sync.validation_failed".to_owned(),
2383                        message: "rejected".to_owned(),
2384                        retryable: false,
2385                    }],
2386                },
2387                Frame::PushResult {
2388                    client_commit_id: ids[3].clone(),
2389                    status: PushStatus::Rejected,
2390                    commit_seq: None,
2391                    results: vec![OpResult::Error {
2392                        op_index: 0,
2393                        code: "sync.idempotency_cache_miss".to_owned(),
2394                        message: "retry".to_owned(),
2395                        retryable: true,
2396                    }],
2397                },
2398            ],
2399        };
2400        let mut transport =
2401            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
2402
2403        client.overlay_rebuild_count.set(0);
2404        client.outcome_prune_count.set(0);
2405        let outcome = client.process_response(&mut transport, response, &request_meta);
2406        let SyncOutcome::Ok(report) = outcome else {
2407            panic!("mixed push-result response failed");
2408        };
2409
2410        assert_eq!(report.applied, ids[..2]);
2411        assert_eq!(report.rejected, ids[2..3]);
2412        assert_eq!(report.retryable, ids[3..4]);
2413        assert_eq!(client.pending_commit_ids(), ids[3..4]);
2414        assert_eq!(
2415            client
2416                .query("SELECT id FROM tasks ORDER BY id", &[])
2417                .expect("query visible overlay"),
2418            vec![Map::from_iter([("id".to_owned(), Value::from("task-3"))])]
2419        );
2420        assert_eq!(client.overlay_rebuild_count.get(), 1);
2421        assert_eq!(client.outcome_prune_count.get(), 1);
2422    }
2423
2424    #[test]
2425    fn secondary_unique_collision_preserves_existing_synced_row() {
2426        let client = SyncClient::new(
2427            "unique-upsert-test".to_owned(),
2428            &json!({
2429                "version": 1,
2430                "tables": [{
2431                    "name": "tasks",
2432                    "primaryKey": "id",
2433                    "columns": [
2434                        { "name": "id", "type": "string", "nullable": false },
2435                        { "name": "project_id", "type": "string", "nullable": false },
2436                        { "name": "title", "type": "string", "nullable": false }
2437                    ],
2438                    "scopes": [{ "pattern": "project:{project_id}" }],
2439                    "indexes": [{
2440                        "name": "tasks_by_project_title",
2441                        "columns": ["project_id", "title"],
2442                        "unique": true
2443                    }]
2444                }]
2445            }),
2446            ClientLimits::default(),
2447        )
2448        .expect("test client");
2449        let table = client.schema.table("tasks").expect("tasks table");
2450        let sql = client.insert_row_sql(&base_table("tasks"), table);
2451
2452        client
2453            .conn
2454            .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
2455            .expect("insert first row");
2456        client
2457            .conn
2458            .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
2459            .expect("update same primary key");
2460        client
2461            .conn
2462            .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
2463            .expect("insert second row");
2464        assert!(client
2465            .conn
2466            .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
2467            .is_err());
2468
2469        let rows = client
2470            .conn
2471            .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
2472            .expect("prepare rows")
2473            .query_map([], |row| {
2474                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2475            })
2476            .expect("query rows")
2477            .collect::<Result<Vec<_>, _>>()
2478            .expect("collect rows");
2479        assert_eq!(
2480            rows,
2481            vec![
2482                ("t1".to_owned(), "updated".to_owned()),
2483                ("t2".to_owned(), "original".to_owned())
2484            ]
2485        );
2486    }
2487
2488    #[test]
2489    fn reopening_active_subscriptions_emits_a_catch_up_intent() {
2490        let path = std::env::temp_dir().join(format!(
2491            "syncular-startup-intent-{}.db",
2492            uuid::Uuid::new_v4()
2493        ));
2494        let schema = json!({
2495            "version": 1,
2496            "tables": [{
2497                "name": "tasks",
2498                "primaryKey": "id",
2499                "columns": [
2500                    { "name": "id", "type": "string", "nullable": false },
2501                    { "name": "project_id", "type": "string", "nullable": false }
2502                ],
2503                "scopes": [{ "pattern": "project:{project_id}" }]
2504            }]
2505        });
2506
2507        {
2508            let mut first = SyncClient::open_path_with_identity(
2509                None,
2510                &schema,
2511                ClientLimits::default(),
2512                path.to_str().expect("UTF-8 temp path"),
2513            )
2514            .expect("first open");
2515            first
2516                .set_window(
2517                    &WindowBase {
2518                        table: "tasks".to_owned(),
2519                        variable: "project_id".to_owned(),
2520                        fixed_scopes: Vec::new(),
2521                        params: None,
2522                    },
2523                    &["persisted".to_owned()],
2524                )
2525                .expect("persist window");
2526        }
2527
2528        let mut reopened = SyncClient::open_path_with_identity(
2529            None,
2530            &schema,
2531            ClientLimits::default(),
2532            path.to_str().expect("UTF-8 temp path"),
2533        )
2534        .expect("reopen");
2535        assert!(reopened.sync_needed());
2536        assert!(matches!(
2537            reopened.drain_sync_intents().as_slice(),
2538            [SyncIntent::Interactive]
2539        ));
2540        drop(reopened);
2541        std::fs::remove_file(path).expect("remove temp database");
2542    }
2543
2544    #[test]
2545    fn reopening_preserves_immutable_subscription_identity_and_progress() {
2546        let path = std::env::temp_dir().join(format!(
2547            "syncular-subscription-identity-{}.db",
2548            uuid::Uuid::new_v4()
2549        ));
2550        let schema = json!({
2551            "version": 1,
2552            "tables": [
2553                {
2554                    "name": "tasks",
2555                    "primaryKey": "id",
2556                    "columns": [
2557                        { "name": "id", "type": "string", "nullable": false },
2558                        { "name": "project_id", "type": "string", "nullable": false }
2559                    ],
2560                    "scopes": [{ "pattern": "project:{project_id}" }]
2561                },
2562                {
2563                    "name": "docs",
2564                    "primaryKey": "id",
2565                    "columns": [
2566                        { "name": "id", "type": "string", "nullable": false },
2567                        { "name": "org_id", "type": "string", "nullable": false },
2568                        { "name": "project_id", "type": "string", "nullable": false }
2569                    ],
2570                    "scopes": [
2571                        { "pattern": "org:{org_id}" },
2572                        { "pattern": "project:{projectId}", "column": "project_id" }
2573                    ]
2574                }
2575            ]
2576        });
2577
2578        {
2579            let mut first = SyncClient::open_path_with_identity(
2580                None,
2581                &schema,
2582                ClientLimits::default(),
2583                path.to_str().expect("UTF-8 temp path"),
2584            )
2585            .expect("first open");
2586            first
2587                .subscribe(
2588                    "stable-subscription".to_owned(),
2589                    "tasks".to_owned(),
2590                    vec![(
2591                        "project_id".to_owned(),
2592                        vec!["p2".to_owned(), "p1".to_owned()],
2593                    )],
2594                    Some(r#"{"view":"v1"}"#.to_owned()),
2595                )
2596                .expect("persist subscription");
2597            let persisted = {
2598                let subscription = first
2599                    .subs
2600                    .iter_mut()
2601                    .find(|subscription| subscription.id == "stable-subscription")
2602                    .expect("subscription");
2603                subscription.cursor = 41;
2604                subscription.bootstrap_state = Some("resume-token".to_owned());
2605                subscription.effective = Some(vec![(
2606                    "project_id".to_owned(),
2607                    vec!["p1".to_owned(), "p2".to_owned()],
2608                )]);
2609                subscription.synced_once = true;
2610                subscription.clone()
2611            };
2612            first.persist_sub(&persisted).unwrap();
2613        }
2614
2615        let mut reopened = SyncClient::open_path_with_identity(
2616            None,
2617            &schema,
2618            ClientLimits::default(),
2619            path.to_str().expect("UTF-8 temp path"),
2620        )
2621        .expect("reopen");
2622        let progress = reopened
2623            .subscription_state("stable-subscription")
2624            .expect("persisted state");
2625        assert_eq!(progress.cursor, 41);
2626        assert!(progress.has_resume_token);
2627
2628        reopened
2629            .subscribe(
2630                "stable-subscription".to_owned(),
2631                "tasks".to_owned(),
2632                vec![(
2633                    "project_id".to_owned(),
2634                    vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
2635                )],
2636                Some(r#"{"view":"v1"}"#.to_owned()),
2637            )
2638            .expect("canonical intent is idempotent");
2639        assert_eq!(
2640            reopened
2641                .subscription_state("stable-subscription")
2642                .expect("unchanged state")
2643                .cursor,
2644            progress.cursor
2645        );
2646
2647        for (table, scopes, params) in [
2648            (
2649                "tasks",
2650                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2651                Some(r#"{"view":"v1"}"#.to_owned()),
2652            ),
2653            (
2654                "tasks",
2655                vec![(
2656                    "project_id".to_owned(),
2657                    vec!["p2".to_owned(), "p1".to_owned()],
2658                )],
2659                Some(r#"{"view":"v2"}"#.to_owned()),
2660            ),
2661            (
2662                "docs",
2663                vec![
2664                    ("org_id".to_owned(), vec!["o1".to_owned()]),
2665                    ("projectId".to_owned(), vec!["p1".to_owned()]),
2666                ],
2667                Some(r#"{"view":"v1"}"#.to_owned()),
2668            ),
2669        ] {
2670            let error = reopened
2671                .subscribe(
2672                    "stable-subscription".to_owned(),
2673                    table.to_owned(),
2674                    scopes,
2675                    params,
2676                )
2677                .expect_err("identity rebind must fail");
2678            assert!(error.starts_with("client.subscription_intent_mismatch:"));
2679            assert_eq!(
2680                reopened
2681                    .subscription_state("stable-subscription")
2682                    .expect("unchanged state")
2683                    .cursor,
2684                progress.cursor
2685            );
2686        }
2687
2688        drop(reopened);
2689        std::fs::remove_file(path).expect("remove temp database");
2690    }
2691
2692    #[test]
2693    fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
2694        let path = std::env::temp_dir().join(format!(
2695            "syncular-satisfied-schema-floor-{}.db",
2696            uuid::Uuid::new_v4()
2697        ));
2698        let schema = json!({
2699            "version": 23,
2700            "tables": [{
2701                "name": "tasks",
2702                "primaryKey": "id",
2703                "columns": [
2704                    { "name": "id", "type": "string", "nullable": false },
2705                    { "name": "project_id", "type": "string", "nullable": false }
2706                ],
2707                "scopes": [{ "pattern": "project:{project_id}" }]
2708            }]
2709        });
2710
2711        {
2712            let mut first = SyncClient::open_path_with_identity(
2713                None,
2714                &schema,
2715                ClientLimits::default(),
2716                path.to_str().expect("UTF-8 temp path"),
2717            )
2718            .expect("first open");
2719            first
2720                .subscribe(
2721                    "tasks".to_owned(),
2722                    "tasks".to_owned(),
2723                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2724                    None,
2725                )
2726                .expect("persist subscription");
2727            first.set_schema_floor(Some(SchemaFloor {
2728                required_schema_version: Some(22),
2729                latest_schema_version: Some(22),
2730            }));
2731        }
2732
2733        let mut reopened = SyncClient::open_path_with_identity(
2734            None,
2735            &schema,
2736            ClientLimits::default(),
2737            path.to_str().expect("UTF-8 temp path"),
2738        )
2739        .expect("reopen");
2740        assert!(reopened.schema_floor().is_none());
2741        assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
2742        assert!(reopened.sync_needed());
2743        assert!(matches!(
2744            reopened.drain_sync_intents().as_slice(),
2745            [SyncIntent::Interactive]
2746        ));
2747        drop(reopened);
2748        std::fs::remove_file(path).expect("remove temp database");
2749    }
2750
2751    #[test]
2752    fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
2753        let path = std::env::temp_dir().join(format!(
2754            "syncular-unsatisfied-schema-floor-{}.db",
2755            uuid::Uuid::new_v4()
2756        ));
2757        let schema = json!({
2758            "version": 1,
2759            "tables": [{
2760                "name": "tasks",
2761                "primaryKey": "id",
2762                "columns": [
2763                    { "name": "id", "type": "string", "nullable": false },
2764                    { "name": "project_id", "type": "string", "nullable": false }
2765                ],
2766                "scopes": [{ "pattern": "project:{project_id}" }]
2767            }]
2768        });
2769
2770        {
2771            let mut first = SyncClient::open_path_with_identity(
2772                None,
2773                &schema,
2774                ClientLimits::default(),
2775                path.to_str().expect("UTF-8 temp path"),
2776            )
2777            .expect("first open");
2778            first
2779                .subscribe(
2780                    "tasks".to_owned(),
2781                    "tasks".to_owned(),
2782                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2783                    None,
2784                )
2785                .expect("persist subscription");
2786            first.set_schema_floor(Some(SchemaFloor {
2787                required_schema_version: Some(2),
2788                latest_schema_version: Some(2),
2789            }));
2790        }
2791
2792        let reopened = SyncClient::open_path_with_identity(
2793            None,
2794            &schema,
2795            ClientLimits::default(),
2796            path.to_str().expect("UTF-8 temp path"),
2797        )
2798        .expect("reopen");
2799        assert_eq!(
2800            reopened.schema_floor(),
2801            Some(&SchemaFloor {
2802                required_schema_version: Some(2),
2803                latest_schema_version: Some(2),
2804            })
2805        );
2806        assert!(!reopened.sync_needed());
2807        drop(reopened);
2808        std::fs::remove_file(path).expect("remove temp database");
2809    }
2810
2811    #[test]
2812    fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
2813        let path = std::env::temp_dir().join(format!(
2814            "syncular-indexed-column-bump-{}.db",
2815            uuid::Uuid::new_v4()
2816        ));
2817        let old_schema = json!({
2818            "version": 1,
2819            "tables": [
2820                {
2821                    "name": "tasks",
2822                    "primaryKey": "id",
2823                    "columns": [
2824                        { "name": "id", "type": "string", "nullable": false },
2825                        { "name": "project_id", "type": "string", "nullable": false }
2826                    ],
2827                    "scopes": [{ "pattern": "project:{project_id}" }]
2828                },
2829                {
2830                    "name": "legacy",
2831                    "primaryKey": "id",
2832                    "columns": [
2833                        { "name": "id", "type": "string", "nullable": false },
2834                        { "name": "project_id", "type": "string", "nullable": false }
2835                    ],
2836                    "scopes": [{ "pattern": "project:{project_id}" }]
2837                }
2838            ]
2839        });
2840        {
2841            let mut first = SyncClient::open_path_with_identity(
2842                None,
2843                &old_schema,
2844                ClientLimits::default(),
2845                path.to_str().expect("UTF-8 temp path"),
2846            )
2847            .expect("open old schema");
2848            first
2849                .subscribe(
2850                    "legacy-sub".to_owned(),
2851                    "legacy".to_owned(),
2852                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2853                    None,
2854                )
2855                .expect("persist legacy subscription");
2856        }
2857
2858        let new_schema = json!({
2859            "version": 2,
2860            "tables": [{
2861                "name": "tasks",
2862                "primaryKey": "id",
2863                "columns": [
2864                    { "name": "id", "type": "string", "nullable": false },
2865                    { "name": "project_id", "type": "string", "nullable": false },
2866                    { "name": "facility_membership_id", "type": "string", "nullable": true }
2867                ],
2868                "scopes": [{ "pattern": "project:{project_id}" }],
2869                "indexes": [{
2870                    "name": "tasks_by_membership",
2871                    "columns": ["project_id", "facility_membership_id"],
2872                    "unique": false
2873                }]
2874            }]
2875        });
2876        let upgraded = SyncClient::open_path_with_identity(
2877            None,
2878            &new_schema,
2879            ClientLimits::default(),
2880            path.to_str().expect("UTF-8 temp path"),
2881        )
2882        .expect("open upgraded schema");
2883        let columns = upgraded
2884            .conn
2885            .prepare("PRAGMA table_info(tasks)")
2886            .expect("prepare columns")
2887            .query_map([], |row| row.get::<_, String>(1))
2888            .expect("query columns")
2889            .collect::<Result<Vec<_>, _>>()
2890            .expect("collect columns");
2891        assert!(columns.contains(&"facility_membership_id".to_owned()));
2892        let index_count: i64 = upgraded
2893            .conn
2894            .query_row(
2895                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
2896                [],
2897                |row| row.get(0),
2898            )
2899            .expect("query index");
2900        assert_eq!(index_count, 1);
2901        assert!(upgraded.subscription_state("legacy-sub").is_none());
2902        drop(upgraded);
2903        std::fs::remove_file(path).expect("remove temp database");
2904    }
2905
2906    #[test]
2907    fn migrates_pre_envelope_outcome_journal_additively() {
2908        let path = std::env::temp_dir().join(format!(
2909            "syncular-outcome-migration-{}.db",
2910            uuid::Uuid::new_v4()
2911        ));
2912        let conn = Connection::open(&path).expect("open legacy database");
2913        conn.execute_batch(
2914            "CREATE TABLE _syncular_commit_outcomes (
2915               seq INTEGER PRIMARY KEY AUTOINCREMENT,
2916               client_commit_id TEXT NOT NULL UNIQUE,
2917               status TEXT NOT NULL,
2918               recorded_at_ms INTEGER NOT NULL,
2919               results_json TEXT NOT NULL,
2920               resolution TEXT NOT NULL DEFAULT 'active',
2921               resolved_at_ms INTEGER,
2922               replacement_client_commit_id TEXT);",
2923        )
2924        .expect("create legacy outcome journal");
2925        drop(conn);
2926        let schema = json!({
2927            "version": 1,
2928            "tables": [{
2929                "name": "tasks",
2930                "primaryKey": "id",
2931                "columns": [
2932                    { "name": "id", "type": "string", "nullable": false },
2933                    { "name": "project_id", "type": "string", "nullable": false }
2934                ],
2935                "scopes": [{ "pattern": "project:{project_id}" }]
2936            }]
2937        });
2938        let client = SyncClient::open_path(
2939            "migration-native".to_owned(),
2940            &schema,
2941            ClientLimits::default(),
2942            path.to_str().expect("UTF-8 temp path"),
2943        )
2944        .expect("migrate database");
2945        let has_operations = client
2946            .conn
2947            .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
2948            .expect("prepare table info")
2949            .query_map([], |row| row.get::<_, String>(1))
2950            .expect("query table info")
2951            .filter_map(Result::ok)
2952            .any(|column| column == "operations_json");
2953        assert!(has_operations);
2954        drop(client);
2955        std::fs::remove_file(path).expect("remove temp database");
2956    }
2957
2958    #[test]
2959    fn durable_conflict_outcome_and_resolution_survive_reopen() {
2960        let path = std::env::temp_dir().join(format!(
2961            "syncular-durable-outcome-{}.db",
2962            uuid::Uuid::new_v4()
2963        ));
2964        let schema = json!({
2965            "version": 1,
2966            "tables": [{
2967                "name": "tasks",
2968                "primaryKey": "id",
2969                "columns": [
2970                    { "name": "id", "type": "string", "nullable": false },
2971                    { "name": "project_id", "type": "string", "nullable": false }
2972                ],
2973                "scopes": [{ "pattern": "project:{project_id}" }]
2974            }]
2975        });
2976        let conflict = ConflictRecord {
2977            client_commit_id: "losing-commit".to_owned(),
2978            op_index: 0,
2979            table: "tasks".to_owned(),
2980            row_id: "t1".to_owned(),
2981            code: "sync.version_conflict".to_owned(),
2982            message: "stale base version".to_owned(),
2983            server_version: 2,
2984            server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
2985            operation: Some(CommitOperation {
2986                table: "tasks".to_owned(),
2987                row_id: "t1".to_owned(),
2988                op: "upsert".to_owned(),
2989                base_version: Some(1),
2990                values: None,
2991                changed_fields: None,
2992            }),
2993        };
2994        let failed_operations = vec![
2995            OutboxOp {
2996                upsert: true,
2997                table: "tasks".to_owned(),
2998                row_id: "t1".to_owned(),
2999                base_version: Some(1),
3000                values: None,
3001                changed_fields: None,
3002            },
3003            OutboxOp {
3004                upsert: true,
3005                table: "tasks".to_owned(),
3006                row_id: "status-event-1".to_owned(),
3007                base_version: Some(0),
3008                values: None,
3009                changed_fields: None,
3010            },
3011        ];
3012
3013        {
3014            let mut first = SyncClient::open_path(
3015                "durable-native".to_owned(),
3016                &schema,
3017                ClientLimits::default(),
3018                path.to_str().expect("UTF-8 temp path"),
3019            )
3020            .expect("first open");
3021            first
3022                .begin_observation("test_outcome")
3023                .expect("begin outcome");
3024            first
3025                .persist_commit_outcome(
3026                    "losing-commit",
3027                    CommitOutcomeStatus::Conflict,
3028                    &[CommitOperationOutcome::Conflict {
3029                        conflict: conflict.clone(),
3030                    }],
3031                    Some(&failed_operations),
3032                )
3033                .expect("persist outcome");
3034            first.conflicts.push(conflict);
3035            first
3036                .finish_observation(
3037                    "test_outcome",
3038                    ChangeAccumulator {
3039                        conflicts: true,
3040                        outcomes: true,
3041                        ..ChangeAccumulator::default()
3042                    },
3043                )
3044                .expect("commit outcome");
3045        }
3046
3047        {
3048            let mut reopened = SyncClient::open_path(
3049                "durable-native".to_owned(),
3050                &schema,
3051                ClientLimits::default(),
3052                path.to_str().expect("UTF-8 temp path"),
3053            )
3054            .expect("reopen");
3055            assert_eq!(reopened.conflicts().len(), 1);
3056            let outcome = reopened
3057                .commit_outcome("losing-commit")
3058                .expect("read outcome")
3059                .expect("outcome");
3060            assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
3061            let operations = outcome.operations.expect("aggregate envelope");
3062            assert_eq!(operations.len(), 2);
3063            assert_eq!(operations[1].row_id, "status-event-1");
3064            let resolved = reopened
3065                .resolve_commit_outcome(ResolveCommitOutcomeInput {
3066                    client_commit_id: "losing-commit".to_owned(),
3067                    resolution: CommitOutcomeResolution::ResolvedKeepServer,
3068                    replacement_client_commit_id: None,
3069                })
3070                .expect("resolve");
3071            assert_eq!(
3072                resolved.resolution,
3073                CommitOutcomeResolution::ResolvedKeepServer
3074            );
3075            assert!(reopened.conflicts().is_empty());
3076        }
3077
3078        let reopened = SyncClient::open_path(
3079            "durable-native".to_owned(),
3080            &schema,
3081            ClientLimits::default(),
3082            path.to_str().expect("UTF-8 temp path"),
3083        )
3084        .expect("second reopen");
3085        assert!(reopened.conflicts().is_empty());
3086        assert_eq!(
3087            reopened
3088                .commit_outcome("losing-commit")
3089                .expect("read outcome")
3090                .expect("outcome")
3091                .resolution,
3092            CommitOutcomeResolution::ResolvedKeepServer
3093        );
3094        drop(reopened);
3095        std::fs::remove_file(path).expect("remove temp database");
3096    }
3097
3098    #[test]
3099    fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
3100        let path =
3101            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
3102        let schema = json!({
3103            "version": 1,
3104            "tables": [{
3105                "name": "tasks",
3106                "primaryKey": "id",
3107                "columns": [
3108                    { "name": "id", "type": "string", "nullable": false },
3109                    { "name": "project_id", "type": "string", "nullable": false }
3110                ],
3111                "scopes": [{ "pattern": "project:{project_id}" }]
3112            }]
3113        });
3114        let mut client = SyncClient::open_path_with_identity(
3115            Some("sidecar-client".to_owned()),
3116            &schema,
3117            ClientLimits::default(),
3118            path.to_str().expect("UTF-8 temp path"),
3119        )
3120        .expect("open owner");
3121        let base = WindowBase {
3122            table: "tasks".to_owned(),
3123            variable: "project_id".to_owned(),
3124            fixed_scopes: Vec::new(),
3125            params: None,
3126        };
3127        client
3128            .set_window(&base, &["one".to_owned()])
3129            .expect("set window");
3130        client
3131            .mutate(vec![Mutation::Upsert {
3132                table: "tasks".to_owned(),
3133                values: Map::from_iter([
3134                    ("id".to_owned(), Value::from("t1")),
3135                    ("project_id".to_owned(), Value::from("one")),
3136                ]),
3137                base_version: None,
3138            }])
3139            .expect("local mutate");
3140
3141        let coverage = [WindowCoverage {
3142            base,
3143            units: vec!["one".to_owned(), "missing".to_owned()],
3144        }];
3145        let owner = client
3146            .query_snapshot(
3147                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3148                &[],
3149                &coverage,
3150            )
3151            .expect("owner snapshot");
3152        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
3153        let sidecar = reader
3154            .query_snapshot(
3155                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3156                &[],
3157                &coverage,
3158            )
3159            .expect("sidecar snapshot");
3160
3161        assert_eq!(sidecar.revision, owner.revision);
3162        assert_eq!(sidecar.rows, owner.rows);
3163        assert_eq!(
3164            serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
3165            serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
3166        );
3167        assert_eq!(sidecar.revision, "2");
3168        assert_eq!(sidecar.rows[0]["id"], "t1");
3169        assert_eq!(sidecar.rows[0]["server_version"], -1);
3170        assert!(!sidecar.coverage.complete);
3171        assert_eq!(sidecar.coverage.pending.len(), 1);
3172        assert_eq!(sidecar.coverage.missing.len(), 1);
3173
3174        drop(reader);
3175        drop(client);
3176        std::fs::remove_file(path).expect("remove temp database");
3177    }
3178
3179    #[test]
3180    fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
3181        let schema = json!({
3182            "version": 1,
3183            "tables": [{
3184                "name": "catalogue_codes",
3185                "primaryKey": "id",
3186                "columns": [
3187                    { "name": "id", "type": "string", "nullable": false },
3188                    { "name": "release_id", "type": "string", "nullable": false },
3189                    { "name": "code", "type": "string", "nullable": false },
3190                    { "name": "title", "type": "string", "nullable": false }
3191                ],
3192                "scopes": [{ "pattern": "release:{release_id}" }],
3193                "ftsIndexes": [{
3194                    "name": "catalogue_codes_fts",
3195                    "columns": ["code", "title"],
3196                    "tokenize": "unicode61 remove_diacritics 2"
3197                }]
3198            }]
3199        });
3200        let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
3201            .expect("FTS5 client");
3202        let insert_trigger: String = client
3203            .conn
3204            .query_row(
3205                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
3206                [],
3207                |row| row.get(0),
3208            )
3209            .expect("insert trigger");
3210        let replace_guard: String = client
3211            .conn
3212            .query_row(
3213                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
3214                [],
3215                |row| row.get(0),
3216            )
3217            .expect("replace guard");
3218        assert!(!insert_trigger.contains("DELETE FROM"));
3219        assert!(replace_guard.contains("BEFORE INSERT"));
3220        assert!(replace_guard.contains("WHEN EXISTS"));
3221        let search = |client: &SyncClient, query: &str| {
3222            client
3223                .query(
3224                    "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",
3225                    &[Value::from(query)],
3226                )
3227                .expect("FTS query")
3228        };
3229
3230        client
3231            .mutate(vec![Mutation::Upsert {
3232                table: "catalogue_codes".to_owned(),
3233                values: Map::from_iter([
3234                    ("id".to_owned(), Value::from("c1")),
3235                    ("release_id".to_owned(), Value::from("r1")),
3236                    ("code".to_owned(), Value::from("A01")),
3237                    ("title".to_owned(), Value::from("Cholera")),
3238                ]),
3239                base_version: None,
3240            }])
3241            .expect("insert code");
3242        assert_eq!(search(&client, "cholera").len(), 1);
3243
3244        client
3245            .mutate(vec![Mutation::Upsert {
3246                table: "catalogue_codes".to_owned(),
3247                values: Map::from_iter([
3248                    ("id".to_owned(), Value::from("c1")),
3249                    ("release_id".to_owned(), Value::from("r1")),
3250                    ("code".to_owned(), Value::from("A01")),
3251                    ("title".to_owned(), Value::from("Enteric infection")),
3252                ]),
3253                base_version: None,
3254            }])
3255            .expect("update code");
3256        assert!(search(&client, "cholera").is_empty());
3257        assert_eq!(search(&client, "enteric").len(), 1);
3258
3259        client
3260            .mutate(vec![Mutation::Delete {
3261                table: "catalogue_codes".to_owned(),
3262                row_id: "c1".to_owned(),
3263                base_version: None,
3264            }])
3265            .expect("delete code");
3266        assert!(search(&client, "enteric").is_empty());
3267    }
3268
3269    #[test]
3270    fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
3271        let schema = json!({
3272            "version": 1,
3273            "tables": [{
3274                "name": "patient_notes",
3275                "primaryKey": "id",
3276                "columns": [
3277                    { "name": "id", "type": "string", "nullable": false },
3278                    { "name": "practice_id", "type": "string", "nullable": false },
3279                    { "name": "encryption_key_id", "type": "string", "nullable": false },
3280                    { "name": "title", "type": "string", "nullable": false }
3281                ],
3282                "scopes": [{ "pattern": "practice:{practice_id}" }],
3283                "ftsIndexes": [{
3284                    "name": "patient_notes_fts",
3285                    "columns": ["title"],
3286                    "tokenize": "unicode61 remove_diacritics 2"
3287                }]
3288            }]
3289        });
3290        let mut client = SyncClient::new(
3291            "local-purge-test".to_owned(),
3292            &schema,
3293            ClientLimits::default(),
3294        )
3295        .expect("local purge client");
3296        client
3297            .conn
3298            .execute(
3299                "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
3300                 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
3301                 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
3302                [],
3303            )
3304            .expect("seed base rows");
3305        client.overlay_dirty.set(true);
3306        client.rebuild_overlay_if_dirty();
3307
3308        let note = |id: &str, key_id: &str, title: &str| {
3309            Map::from_iter([
3310                ("id".to_owned(), Value::from(id)),
3311                ("practice_id".to_owned(), Value::from("practice-1")),
3312                ("encryption_key_id".to_owned(), Value::from(key_id)),
3313                ("title".to_owned(), Value::from(title)),
3314            ])
3315        };
3316        let doomed = client
3317            .mutate(vec![
3318                Mutation::Upsert {
3319                    table: "patient_notes".to_owned(),
3320                    values: note("target", "key-revoked", "Target changed"),
3321                    base_version: None,
3322                },
3323                Mutation::Upsert {
3324                    table: "patient_notes".to_owned(),
3325                    values: note("unrelated", "key-held", "Unrelated changed"),
3326                    base_version: None,
3327                },
3328            ])
3329            .expect("doomed commit");
3330        let kept = client
3331            .mutate(vec![Mutation::Upsert {
3332                table: "patient_notes".to_owned(),
3333                values: note("kept", "key-held", "Kept optimistic"),
3334                base_version: None,
3335            }])
3336            .expect("kept commit");
3337        client.drain_change_batches();
3338
3339        let input = LocalDataPurgeInput {
3340            purge_id: "purge-001".to_owned(),
3341            targets: vec![LocalDataPurgeTarget {
3342                table: "patient_notes".to_owned(),
3343                selectors: BTreeMap::from([(
3344                    "encryption_key_id".to_owned(),
3345                    vec!["key-revoked".to_owned()],
3346                )]),
3347            }],
3348        };
3349        assert_eq!(
3350            client.purge_local_data(&input).expect("apply purge"),
3351            LocalDataPurgeResult {
3352                already_applied: false,
3353                purged_rows: 1,
3354                dropped_commits: 1,
3355            }
3356        );
3357        let rows = client
3358            .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
3359            .expect("visible rows");
3360        assert_eq!(rows.len(), 2);
3361        assert_eq!(rows[0]["id"], "kept");
3362        assert_eq!(rows[1]["id"], "unrelated");
3363        assert_eq!(rows[1]["title"], "Unrelated original");
3364        let fts = client
3365            .query(
3366                "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'",
3367                &[],
3368            )
3369            .expect("fts query");
3370        assert!(fts.is_empty());
3371        assert_eq!(client.outbox.len(), 1);
3372        assert_eq!(client.outbox[0].client_commit_id, kept);
3373        let outcome = client
3374            .commit_outcome(&doomed)
3375            .expect("read doomed outcome")
3376            .expect("doomed outcome");
3377        assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
3378        match &outcome.results[0] {
3379            CommitOperationOutcome::Error { rejection } => {
3380                assert_eq!(rejection.code, "client.local_data_purged");
3381                assert_eq!(rejection.client_commit_id, doomed);
3382            }
3383            other => panic!("expected local purge rejection, got {other:?}"),
3384        }
3385        assert_eq!(client.drain_change_batches().len(), 1);
3386        assert_eq!(
3387            client.purge_local_data(&input).expect("retry purge"),
3388            LocalDataPurgeResult {
3389                already_applied: true,
3390                purged_rows: 0,
3391                dropped_commits: 0,
3392            }
3393        );
3394        let conflicting = LocalDataPurgeInput {
3395            purge_id: input.purge_id.clone(),
3396            targets: vec![LocalDataPurgeTarget {
3397                table: "patient_notes".to_owned(),
3398                selectors: BTreeMap::from([(
3399                    "encryption_key_id".to_owned(),
3400                    vec!["key-held".to_owned()],
3401                )]),
3402            }],
3403        };
3404        assert!(client
3405            .purge_local_data(&conflicting)
3406            .expect_err("id collision must fail")
3407            .contains("already used with a different plan"));
3408    }
3409}
3410
3411impl SubState {
3412    fn name(self) -> &'static str {
3413        match self {
3414            SubState::Active => "active",
3415            SubState::Revoked => "revoked",
3416            SubState::Failed => "failed",
3417        }
3418    }
3419
3420    fn parse(value: &str) -> Self {
3421        match value {
3422            "revoked" => Self::Revoked,
3423            "failed" => Self::Failed,
3424            _ => Self::Active,
3425        }
3426    }
3427}
3428
3429#[derive(Debug, Clone)]
3430struct Subscription {
3431    id: String,
3432    table: String,
3433    requested: Vec<(String, Vec<String>)>,
3434    params: Option<String>,
3435    cursor: i64,
3436    /// §4.7 resume token, round-tripped opaquely.
3437    bootstrap_state: Option<String>,
3438    state: SubState,
3439    reason_code: Option<String>,
3440    /// Last effective scopes echoed while active (§3.3: persisted for the
3441    /// purge contract; each active echo replaces it).
3442    effective: Option<Vec<(String, Vec<String>)>>,
3443    synced_once: bool,
3444}
3445
3446#[derive(Debug, Clone)]
3447struct OutboxOp {
3448    upsert: bool,
3449    table: String,
3450    row_id: String,
3451    base_version: Option<i64>,
3452    /// Schema-agnostic local form (§0): driver JSON values, encoded with
3453    /// the current codec at send time.
3454    values: Option<Map<String, Value>>,
3455    /// Local-only patch intent; never encoded into SSP2 PUSH_COMMIT.
3456    changed_fields: Option<Vec<String>>,
3457}
3458
3459impl From<&OutboxOp> for CommitOperation {
3460    fn from(operation: &OutboxOp) -> Self {
3461        Self {
3462            table: operation.table.clone(),
3463            row_id: operation.row_id.clone(),
3464            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
3465            base_version: operation.base_version,
3466            values: operation.values.clone(),
3467            changed_fields: operation.changed_fields.clone(),
3468        }
3469    }
3470}
3471
3472#[derive(Debug, Clone)]
3473struct OutboxCommit {
3474    client_commit_id: String,
3475    ops: Vec<OutboxOp>,
3476}
3477
3478#[derive(Debug, Clone)]
3479struct CompiledLocalDataPurgeTarget {
3480    table: String,
3481    selectors: Vec<(String, Vec<String>)>,
3482}
3483
3484struct StoredCommitOutcomeRow {
3485    sequence: i64,
3486    client_commit_id: String,
3487    status: String,
3488    recorded_at_ms: i64,
3489    results_json: String,
3490    operations_json: Option<String>,
3491    resolution: String,
3492    resolved_at_ms: Option<i64>,
3493    replacement_client_commit_id: Option<String>,
3494}
3495
3496/// Section outcome distinguishing the §5.6 subscription-local fail-closed
3497/// path from a round-aborting failure (§1.4 rule 5).
3498enum SectionError {
3499    FailClosed,
3500    Abort(String, String),
3501}
3502
3503struct RequestMeta {
3504    pushed_ids: Vec<String>,
3505    /// Subscription id → the request carried `cursor < 0` and no resume
3506    /// token (§5.6 first-page detection: a *fresh* bootstrap).
3507    fresh: Vec<(String, bool)>,
3508    accept: u8,
3509    /// §6.1 splitBatch: outbox commits held back from THIS request because
3510    /// the running operation count reached the push cap — the next round
3511    /// pushes them (`sync_needed` stays set while any remain).
3512    deferred_commits: usize,
3513}
3514
3515#[derive(Default)]
3516struct ChangeAccumulator {
3517    /// None means table-wide; Some is the exact scoped domain.
3518    tables: BTreeMap<String, Option<BTreeSet<String>>>,
3519    windows: BTreeMap<(String, String), BTreeSet<String>>,
3520    status: bool,
3521    conflicts: bool,
3522    rejections: bool,
3523    outcomes: bool,
3524}
3525
3526impl ChangeAccumulator {
3527    fn table(&mut self, table: &str) {
3528        self.tables.insert(table.to_owned(), None);
3529    }
3530
3531    fn scope(&mut self, table: &str, key: String) {
3532        match self.tables.get_mut(table) {
3533            Some(None) => {}
3534            Some(Some(keys)) => {
3535                keys.insert(key);
3536            }
3537            None => {
3538                self.tables
3539                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
3540            }
3541        }
3542    }
3543
3544    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
3545        self.windows
3546            .entry((base_key.to_owned(), table.to_owned()))
3547            .or_default()
3548            .insert(unit.to_owned());
3549    }
3550
3551    fn touched(&self) -> bool {
3552        !self.tables.is_empty()
3553            || !self.windows.is_empty()
3554            || self.status
3555            || self.conflicts
3556            || self.rejections
3557            || self.outcomes
3558    }
3559}
3560
3561/// §6.1: the server caps total operations per request (reference default
3562/// 500) and rejects the whole batch with `sync.too_many_operations`; the
3563/// client "splits and retries". Splitting happens at build time: commits are
3564/// included IN ORDER until the operation budget is spent, the rest wait for
3565/// the next round.
3566const PUSH_OPS_PER_REQUEST: usize = 500;
3567const MAX_LOCAL_PURGE_TARGETS: usize = 64;
3568const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
3569const MAX_LOCAL_PURGE_VALUES: usize = 128;
3570const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
3571pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
3572
3573pub struct SyncClient {
3574    #[cfg(feature = "bench-internals")]
3575    benchmark_phases: Recorder,
3576    conn: Connection,
3577    schema: ClientSchema,
3578    client_id: String,
3579    limits: ClientLimits,
3580    subs: Vec<Subscription>,
3581    outbox: Vec<OutboxCommit>,
3582    conflicts: Vec<ConflictRecord>,
3583    rejections: Vec<RejectionRecord>,
3584    schema_floor: Option<SchemaFloor>,
3585    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
3586    lease_state: Option<LeaseState>,
3587    /// §1.6: the schema-floor response stops syncing until an upgrade.
3588    stopped: bool,
3589    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
3590    upgrading: bool,
3591    /// §8.4 coalesced sync-needed signal.
3592    sync_needed: bool,
3593    realtime_connected: bool,
3594    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
3595    presence: HashMap<String, HashMap<String, PresencePeer>>,
3596    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
3597    /// host may pin it (conformance runs on a virtual clock).
3598    now_ms: Option<i64>,
3599    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
3600    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
3601    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
3602    encryption: crate::values::EncryptionConfig,
3603    /// Fail-closed host bootstrap gate. While set, command hosts permit only
3604    /// status/lifecycle inspection and an exact authorized local purge.
3605    security_preflight: bool,
3606    /// Per-table primary-key upsert SQL, built once per (full table name) —
3607    /// the row write path runs per row during bootstrap (§5.6), so the SQL
3608    /// string (and, via `prepare_cached`, its compiled statement) is reused
3609    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
3610    /// schema reset (the column lists may have changed).
3611    insert_sql: RefCell<HashMap<String, String>>,
3612    /// §7.1 rebuild gate: true whenever the base tables or the outbox have
3613    /// diverged from the visible overlay since the last rebuild. Lets a
3614    /// no-op sync round skip the full base→visible copy.
3615    overlay_dirty: Cell<bool>,
3616    /// Test-only structural performance signal: response processing must not
3617    /// turn a batch of acknowledgements into one full overlay rebuild each.
3618    #[cfg(test)]
3619    overlay_rebuild_count: Cell<usize>,
3620    /// Test-only structural performance signal: outcome retention is enforced
3621    /// once per response rather than once per acknowledgement.
3622    #[cfg(test)]
3623    outcome_prune_count: Cell<usize>,
3624    /// Exact observer-transaction output drained by command/FFI hosts.
3625    progress: ProgressObserver,
3626    change_queue: VecDeque<ClientChangeBatch>,
3627    sync_intent_queue: VecDeque<SyncIntent>,
3628    /// Explicit exponential retry policy for transient transport failures.
3629    retry_delay_ms: u64,
3630    last_round: Option<DiagnosticLastRound>,
3631    last_change: Option<DiagnosticLastChange>,
3632}
3633
3634fn quote_ident(name: &str) -> String {
3635    format!("\"{}\"", name.replace('"', "\"\""))
3636}
3637
3638/// Seek by the existing key while retaining the exact legacy text match.
3639fn row_id_predicate(table: &TableSchema) -> String {
3640    let key = quote_ident(&table.primary_key);
3641    let text_match = format!("CAST({key} AS TEXT) = ?1");
3642    match table.columns[table.pk_index].ty {
3643        ColumnType::String | ColumnType::Json => {
3644            format!("{key} = ?1 AND {text_match}")
3645        }
3646        // Native columns have no declared SQLite affinity. Match the stored
3647        // integer bind type explicitly. Unary + removes expression affinity so
3648        // SQLite can seek the typeless key; the text check preserves exact IDs.
3649        ColumnType::Integer | ColumnType::Boolean => {
3650            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
3651        }
3652        // REAL-to-text conversion can round distinct values to the same text.
3653        // Narrowing those matches by numeric equality would change behavior.
3654        _ => text_match,
3655    }
3656}
3657
3658fn is_local_operation_code_like(value: &str) -> bool {
3659    let bytes = value.as_bytes();
3660    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
3661        && bytes
3662            .iter()
3663            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
3664}
3665
3666fn base_table(name: &str) -> String {
3667    quote_ident(&format!("_syncular_base_{name}"))
3668}
3669
3670/// §4.8: a stable, server-opaque key for a window base — table + variable +
3671/// canonical fixed scopes. Two `set_window` calls with the same base
3672/// address the same registry rows.
3673/// §4.8 deferred eviction record: (sub id, table, effective scope map).
3674type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
3675
3676fn window_base_key(base: &WindowBase) -> String {
3677    format!(
3678        "{}\0{}\0{}",
3679        base.table,
3680        base.variable,
3681        canonical_scope_json(&base.fixed_scopes)
3682    )
3683}
3684
3685/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
3686fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
3687    let mut scopes = base.fixed_scopes.clone();
3688    scopes.retain(|(k, _)| k != &base.variable);
3689    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
3690    scopes
3691}
3692
3693/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
3694/// echoed not interpreted by the server, so the exact hash is client
3695/// convention; SHA-256 matches the SPEC's worked example.
3696fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
3697    let canonical = canonical_scope_json(&unit_scopes(base, unit));
3698    let digest = Sha256::digest(canonical.as_bytes());
3699    let hex = bytes_to_hex(&digest);
3700    format!("w:{}:{}", base.table, &hex[..16])
3701}
3702
3703fn visible_table(name: &str) -> String {
3704    quote_ident(name)
3705}
3706
3707const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
3708
3709/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
3710/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
3711fn is_synced_table_name(name: &str) -> bool {
3712    if name.starts_with("sqlite_") {
3713        return false;
3714    }
3715    if name.starts_with("_syncular_base_") {
3716        return true; // the base half of a synced table pair
3717    }
3718    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
3719    !name.starts_with("_syncular_")
3720}
3721
3722/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
3723fn blob_id_for(bytes: &[u8]) -> String {
3724    let digest = Sha256::digest(bytes);
3725    format!("sha256:{}", bytes_to_hex(&digest))
3726}
3727
3728/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
3729/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
3730/// per row on the §5.6 bootstrap path), scalars bind owned.
3731enum RowParam<'a> {
3732    Cell(&'a Option<ColumnValue>),
3733    Version(i64),
3734}
3735
3736impl rusqlite::ToSql for RowParam<'_> {
3737    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
3738        Ok(match self {
3739            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
3740            RowParam::Cell(cell) => match cell {
3741                None => ToSqlOutput::Owned(SqlValue::Null),
3742                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
3743                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
3744                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
3745                Some(ColumnValue::Boolean(b)) => {
3746                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
3747                }
3748                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
3749                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
3750                }
3751                // §5.10: crdt bytes store as BLOB, like bytes.
3752                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
3753                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
3754                }
3755            },
3756        })
3757    }
3758}
3759
3760/// §5.3 image cell → bind parameter, strict per the declared column type
3761/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
3762/// nullable). Mismatches are image-producer violations. Returns the cell
3763/// borrowed when the stored representation already matches what the row
3764/// codec would write, or the normalized scalar (boolean → 0/1, float from
3765/// INTEGER → REAL) otherwise — the local write is byte-identical to the
3766/// old convert-then-insert path without allocating per cell.
3767fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
3768    use ssp2::segment::ColumnType;
3769    let mismatch = || {
3770        Err(format!(
3771            "image column {:?} holds a value of the wrong type",
3772            column.name
3773        ))
3774    };
3775    match value {
3776        ValueRef::Null => {
3777            if !column.nullable {
3778                return Err(format!(
3779                    "image column {:?} is NULL but not nullable",
3780                    column.name
3781                ));
3782            }
3783            Ok(ToSqlOutput::Owned(SqlValue::Null))
3784        }
3785        ValueRef::Integer(i) => match column.ty {
3786            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
3787            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
3788            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
3789            _ => mismatch(),
3790        },
3791        ValueRef::Real(_) => match column.ty {
3792            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
3793            _ => mismatch(),
3794        },
3795        ValueRef::Text(t) => {
3796            std::str::from_utf8(t)
3797                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
3798            match column.ty {
3799                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
3800                    Ok(ToSqlOutput::Borrowed(value))
3801                }
3802                _ => mismatch(),
3803            }
3804        }
3805        ValueRef::Blob(_) => match column.ty {
3806            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
3807            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
3808            _ => mismatch(),
3809        },
3810    }
3811}
3812
3813fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
3814    use rusqlite::types::ValueRef;
3815    match value {
3816        ValueRef::Null => Value::Null,
3817        ValueRef::Integer(i) => match column.ty {
3818            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
3819            ssp2::segment::ColumnType::Float => {
3820                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
3821            }
3822            _ => Value::from(i),
3823        },
3824        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
3825        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
3826        ValueRef::Blob(b) => {
3827            let mut map = Map::new();
3828            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
3829            Value::Object(map)
3830        }
3831    }
3832}
3833
3834/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
3835/// Objects are accepted in lossless `{"$bytes": hex}` and
3836/// `{"$bigint": decimal}` envelope forms.
3837fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
3838    Ok(match value {
3839        Value::Null => SqlValue::Null,
3840        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
3841        Value::Number(n) => {
3842            if let Some(i) = n.as_i64() {
3843                SqlValue::Integer(i)
3844            } else if let Some(f) = n.as_f64() {
3845                SqlValue::Real(f)
3846            } else {
3847                return Err(format!("query param number {n} is out of range"));
3848            }
3849        }
3850        Value::String(s) => SqlValue::Text(s.clone()),
3851        Value::Object(_) => {
3852            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
3853                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
3854            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
3855                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
3856                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
3857                })?)
3858            } else {
3859                return Err(
3860                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
3861                        .to_owned(),
3862                );
3863            }
3864        }
3865        Value::Array(_) => return Err("query array params are not supported".to_owned()),
3866    })
3867}
3868
3869/// Map a rusqlite value with no schema column to consult (arbitrary query
3870/// output): integers/reals/text pass through by stored affinity, blobs ride
3871/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
3872/// schema column type to recover booleans/floats/json.
3873fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
3874    use rusqlite::types::ValueRef;
3875    match value {
3876        ValueRef::Null => Value::Null,
3877        ValueRef::Integer(i) => {
3878            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
3879            // ordinary UI-sized integers ergonomic and envelope only values
3880            // beyond JavaScript's safe range.
3881            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
3882            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
3883                Value::from(i)
3884            } else {
3885                let mut map = Map::new();
3886                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
3887                Value::Object(map)
3888            }
3889        }
3890        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
3891        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
3892        ValueRef::Blob(b) => {
3893            let mut map = Map::new();
3894            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
3895            Value::Object(map)
3896        }
3897    }
3898}
3899
3900fn query_connection(
3901    conn: &Connection,
3902    sql: &str,
3903    params: &[QueryValue],
3904) -> Result<Vec<QueryRow>, String> {
3905    crate::query_guard::assert_read_only_query(sql)?;
3906    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
3907    let bound: Vec<SqlValue> = params
3908        .iter()
3909        .map(json_param_to_sql)
3910        .collect::<Result<_, _>>()?;
3911    let mut stmt = conn.prepare(&lowered_sql).map_err(|e| e.to_string())?;
3912    let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
3913    let bound_refs: Vec<&dyn rusqlite::ToSql> =
3914        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
3915    let mut sql_rows = stmt
3916        .query(bound_refs.as_slice())
3917        .map_err(|e| e.to_string())?;
3918    let mut out = Vec::new();
3919    while let Some(row) = sql_rows.next().map_err(|e| e.to_string())? {
3920        let mut record = Map::new();
3921        for (i, name) in column_names.iter().enumerate() {
3922            let value = row.get_ref(i).map_err(|e| e.to_string())?;
3923            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
3924        }
3925        out.push(record);
3926    }
3927    Ok(out)
3928}
3929
3930fn persisted_window_state(conn: &Connection, base: &WindowBase) -> Result<WindowState, String> {
3931    let mut stmt = conn
3932        .prepare(
3933            "SELECT windows.unit, subscriptions.state_json
3934               FROM _syncular_windows AS windows
3935               JOIN _syncular_subscriptions AS subscriptions
3936                 ON subscriptions.id = windows.sub_id
3937              WHERE windows.base = ?1
3938              ORDER BY windows.unit ASC",
3939        )
3940        .map_err(|error| error.to_string())?;
3941    let rows = stmt
3942        .query_map(rusqlite::params![window_base_key(base)], |row| {
3943            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3944        })
3945        .map_err(|error| error.to_string())?;
3946    let mut units = Vec::new();
3947    let mut pending = Vec::new();
3948    for row in rows {
3949        let (unit, raw) = row.map_err(|error| error.to_string())?;
3950        let state: Value = serde_json::from_str(&raw)
3951            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
3952        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
3953            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
3954            || state
3955                .get("bootstrapState")
3956                .is_some_and(|value| !value.is_null());
3957        if is_pending {
3958            pending.push(unit.clone());
3959        }
3960        units.push(unit);
3961    }
3962    Ok(WindowState { units, pending })
3963}
3964
3965fn snapshot_connection(
3966    conn: &Connection,
3967    sql: &str,
3968    params: &[Value],
3969    coverage: &[WindowCoverage],
3970) -> Result<QuerySnapshot, String> {
3971    conn.execute_batch("SAVEPOINT syncular_snapshot_read")
3972        .map_err(|error| error.to_string())?;
3973    let result = (|| {
3974        let revision = conn
3975            .query_row(
3976                "SELECT value FROM _syncular_meta WHERE key = ?1",
3977                rusqlite::params![LOCAL_REVISION_KEY],
3978                |row| row.get::<_, String>(0),
3979            )
3980            .ok()
3981            .and_then(|value| value.parse::<u64>().ok())
3982            .unwrap_or(0);
3983        let rows = query_connection(conn, sql, params)?;
3984        let mut pending = Vec::new();
3985        let mut missing = Vec::new();
3986        for requested in coverage {
3987            let base_key = window_base_key(&requested.base);
3988            let state = persisted_window_state(conn, &requested.base)?;
3989            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
3990                let reference = WindowUnitRef {
3991                    base_key: base_key.clone(),
3992                    unit: unit.clone(),
3993                };
3994                if !state.units.iter().any(|held| held == &unit) {
3995                    missing.push(reference);
3996                } else if state.pending.iter().any(|held| held == &unit) {
3997                    pending.push(reference);
3998                }
3999            }
4000        }
4001        Ok(QuerySnapshot {
4002            revision: revision.to_string(),
4003            rows,
4004            coverage: CoverageSnapshot {
4005                complete: pending.is_empty() && missing.is_empty(),
4006                pending,
4007                missing,
4008            },
4009        })
4010    })();
4011    match result {
4012        Ok(snapshot) => {
4013            conn.execute_batch("RELEASE syncular_snapshot_read")
4014                .map_err(|error| error.to_string())?;
4015            Ok(snapshot)
4016        }
4017        Err(error) => {
4018            let _ = conn.execute_batch(
4019                "ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read",
4020            );
4021            Err(error)
4022        }
4023    }
4024}
4025
4026/// A long-lived read-only SQLite sidecar for latency-critical native views.
4027/// Network rounds stay serialized on the mutable core owner, while atomic
4028/// query snapshots use this independent connection and therefore never queue
4029/// behind HTTP/WebSocket latency.
4030pub struct FileQuerySnapshotReader {
4031    path: String,
4032    conn: Option<Connection>,
4033}
4034
4035impl FileQuerySnapshotReader {
4036    #[must_use]
4037    pub fn new(path: impl Into<String>) -> Self {
4038        Self {
4039            path: path.into(),
4040            conn: None,
4041        }
4042    }
4043
4044    fn connection(&mut self) -> Result<&Connection, String> {
4045        if self.conn.is_none() {
4046            let conn = Connection::open_with_flags(
4047                &self.path,
4048                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
4049            )
4050            .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
4051            conn.busy_timeout(std::time::Duration::from_millis(250))
4052                .map_err(|error| error.to_string())?;
4053            self.conn = Some(conn);
4054        }
4055        self.conn
4056            .as_ref()
4057            .ok_or_else(|| "read sidecar connection missing".to_owned())
4058    }
4059
4060    pub fn query_snapshot(
4061        &mut self,
4062        sql: &str,
4063        params: &[Value],
4064        coverage: &[WindowCoverage],
4065    ) -> Result<QuerySnapshot, String> {
4066        snapshot_connection(self.connection()?, sql, params, coverage)
4067    }
4068}
4069
4070impl SyncClient {
4071    pub fn new_with_identity(
4072        client_id: Option<String>,
4073        schema_json: &Value,
4074        limits: ClientLimits,
4075    ) -> Result<Self, String> {
4076        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
4077        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
4078        Self::with_connection(resolved, schema_json, limits, conn)
4079    }
4080
4081    pub fn new(
4082        client_id: String,
4083        schema_json: &Value,
4084        limits: ClientLimits,
4085    ) -> Result<Self, String> {
4086        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
4087        Self::with_connection(client_id, schema_json, limits, conn)
4088    }
4089
4090    /// Build a client backed by an on-disk SQLite database at `path` — the
4091    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
4092    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
4093    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
4094    /// of the command router's dependency set (the router only holds a path).
4095    pub fn open_path(
4096        client_id: String,
4097        schema_json: &Value,
4098        limits: ClientLimits,
4099        path: &str,
4100    ) -> Result<Self, String> {
4101        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
4102        Self::with_connection(client_id, schema_json, limits, conn)
4103    }
4104
4105    pub fn open_path_with_identity(
4106        client_id: Option<String>,
4107        schema_json: &Value,
4108        limits: ClientLimits,
4109        path: &str,
4110    ) -> Result<Self, String> {
4111        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
4112        // File-backed native clients use an independent read connection for
4113        // latency-critical snapshots. WAL is SQLite's intended reader/writer
4114        // concurrency mode: a view read never holds a rollback-journal lock
4115        // that delays the mutable client's next commit, and a short busy
4116        // timeout absorbs the tiny checkpoint/schema-lock windows.
4117        conn.busy_timeout(std::time::Duration::from_millis(250))
4118            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
4119        conn.pragma_update(None, "journal_mode", "WAL")
4120            .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
4121        let persisted = conn
4122            .query_row(
4123                "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
4124                [],
4125                |row| row.get::<_, String>(0),
4126            )
4127            .ok();
4128        let resolved = persisted
4129            .clone()
4130            .or(client_id.clone())
4131            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
4132        if let (Some(existing), Some(requested)) = (persisted, client_id) {
4133            if existing != requested {
4134                return Err(format!(
4135                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
4136                ));
4137            }
4138        }
4139        Self::with_connection(resolved, schema_json, limits, conn)
4140    }
4141
4142    #[must_use]
4143    pub fn client_id(&self) -> &str {
4144        &self.client_id
4145    }
4146
4147    /// Build a client over a caller-supplied rusqlite connection — the seam a
4148    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
4149    /// with an on-disk database (`Connection::open(path)`) rather than the
4150    /// default `:memory:`. The connection MUST be fresh (no pre-existing
4151    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
4152    /// the same file across process restarts reuses the persisted rows.
4153    pub fn with_connection(
4154        client_id: String,
4155        schema_json: &Value,
4156        limits: ClientLimits,
4157        conn: Connection,
4158    ) -> Result<Self, String> {
4159        if limits.outcome_retention_max_entries == Some(0) {
4160            return Err(
4161                "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
4162            );
4163        }
4164        let schema = parse_schema_json(schema_json)?;
4165        let mut client = SyncClient {
4166            #[cfg(feature = "bench-internals")]
4167            benchmark_phases: Recorder::default(),
4168            conn,
4169            schema,
4170            client_id,
4171            limits,
4172            subs: Vec::new(),
4173            outbox: Vec::new(),
4174            conflicts: Vec::new(),
4175            rejections: Vec::new(),
4176            schema_floor: None,
4177            lease_state: None,
4178            stopped: false,
4179            upgrading: false,
4180            sync_needed: false,
4181            realtime_connected: false,
4182            presence: HashMap::new(),
4183            now_ms: None,
4184            encryption: crate::values::EncryptionConfig::default(),
4185            security_preflight: false,
4186            insert_sql: RefCell::new(HashMap::new()),
4187            overlay_dirty: Cell::new(false),
4188            #[cfg(test)]
4189            overlay_rebuild_count: Cell::new(0),
4190            #[cfg(test)]
4191            outcome_prune_count: Cell::new(0),
4192            progress: ProgressObserver::default(),
4193            change_queue: VecDeque::new(),
4194            sync_intent_queue: VecDeque::new(),
4195            retry_delay_ms: 250,
4196            last_round: None,
4197            last_change: None,
4198        };
4199        // The row write path leans on the prepared-statement cache (two
4200        // insert statements per synced table, plus the bookkeeping
4201        // statements); size it so a multi-table schema never thrashes.
4202        client
4203            .conn
4204            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
4205        // Protected bookkeeping must exist before inspecting the persisted
4206        // schema marker. New app indexes may reference columns that only
4207        // exist after a version-bump reset.
4208        client.create_bookkeeping_tables()?;
4209        match client.get_meta(CLIENT_ID_KEY) {
4210            Some(existing) if existing != client.client_id => {
4211                return Err(format!(
4212                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
4213                    client.client_id
4214                ));
4215            }
4216            None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
4217            _ => {}
4218        }
4219        client.restore_persisted_state()?;
4220        let marker = client
4221            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
4222            .and_then(|value| value.parse::<i32>().ok());
4223        match marker {
4224            None => {
4225                client.create_synced_tables()?;
4226                client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
4227            }
4228            Some(version) if version == client.schema.version => {
4229                client.create_synced_tables()?;
4230            }
4231            Some(_) => client.run_schema_reset()?,
4232        }
4233        client.clear_satisfied_persisted_schema_floor();
4234        client.prune_unknown_subscriptions()?;
4235        if marker == Some(client.schema.version) && !client.outbox.is_empty() {
4236            // Reconstruct the visible optimistic overlay from the durable base
4237            // plus outbox instead of trusting a process-interrupted mirror.
4238            client.overlay_dirty.set(true);
4239            client.rebuild_overlay();
4240        }
4241        // Every persisted active subscription needs one catch-up pull on open:
4242        // realtime only covers changes after connection, while an idempotent
4243        // setWindow correctly creates no fresh command effect. Pending outbox
4244        // work has the same restart requirement. The core owns this intent so
4245        // native hosts never poll or require an application-issued sync().
4246        client.enqueue_startup_sync_if_needed();
4247        Ok(client)
4248    }
4249
4250    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
4251    /// against this instead of system time (conformance virtual clock).
4252    pub fn set_now_ms(&mut self, now_ms: i64) {
4253        self.now_ms = Some(now_ms);
4254    }
4255
4256    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
4257    /// The command router parses these from the `create` command's
4258    /// `encryption` config (keys as `{$bytes: hex}`).
4259    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
4260        self.encryption = encryption;
4261    }
4262
4263    #[must_use]
4264    pub fn security_lifecycle(&self) -> &'static str {
4265        if self.security_preflight {
4266            "preflight"
4267        } else {
4268            "active"
4269        }
4270    }
4271
4272    #[must_use]
4273    pub fn security_preflight(&self) -> bool {
4274        self.security_preflight
4275    }
4276
4277    /// Quarantine this replica: enter the fail-closed gate, release all
4278    /// core-owned key material, and record the gate in the database so
4279    /// reopening re-enters preflight until `activate_security` clears it.
4280    ///
4281    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
4282    /// which must NOT leave that durable mark.
4283    pub fn begin_security_preflight(&mut self) {
4284        self.seal_security_on_teardown();
4285        // Persist the quarantine so it survives handle teardown and restart:
4286        // reopening this replica re-enters preflight until activation clears it.
4287        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
4288    }
4289
4290    /// Release core-owned key material as a host tears the client down,
4291    /// WITHOUT recording a quarantine.
4292    ///
4293    /// Shutting an activated client down is not a quarantine event. Persisting
4294    /// the gate here marks every cleanly closed replica as pending, and the
4295    /// next plain `create` is then refused permanently: the reopen path
4296    /// restores the flag from the marker and the create guard rejects it. The
4297    /// in-memory flag still closes the gate for anything still holding this
4298    /// instance, which is all a teardown barrier needs.
4299    pub fn seal_security_on_teardown(&mut self) {
4300        self.security_preflight = true;
4301        self.encryption = crate::values::EncryptionConfig::default();
4302        self.sync_intent_queue.clear();
4303    }
4304
4305    /// Install the post-authentication keyring and release the host loop.
4306    pub fn activate_security(
4307        &mut self,
4308        encryption: crate::values::EncryptionConfig,
4309    ) -> Result<(), String> {
4310        if !self.security_preflight {
4311            return Err(
4312                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
4313            );
4314        }
4315        self.encryption = encryption;
4316        self.security_preflight = false;
4317        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
4318        self.enqueue_startup_sync_if_needed();
4319        Ok(())
4320    }
4321
4322    fn clock_now_ms(&self) -> i64 {
4323        self.now_ms.unwrap_or_else(|| {
4324            std::time::SystemTime::now()
4325                .duration_since(std::time::UNIX_EPOCH)
4326                .map(|d| d.as_millis() as i64)
4327                .unwrap_or(0)
4328        })
4329    }
4330
4331    fn create_bookkeeping_tables(&self) -> Result<(), String> {
4332        // Durable client bookkeeping (outbox + subscription + meta).
4333        self.conn
4334            .execute_batch(
4335                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
4336                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
4337                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
4338                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
4339                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
4340                   client_commit_id TEXT NOT NULL UNIQUE,
4341                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
4342                   recorded_at_ms INTEGER NOT NULL,
4343                   results_json TEXT NOT NULL,
4344                   operations_json TEXT,
4345                   resolution TEXT NOT NULL DEFAULT 'active'
4346                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
4347                   resolved_at_ms INTEGER,
4348                   replacement_client_commit_id TEXT);
4349                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
4350                   ON _syncular_commit_outcomes(resolution, seq);
4351                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
4352                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
4353                 CREATE TABLE IF NOT EXISTS _syncular_meta (
4354                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
4355                 CREATE TABLE IF NOT EXISTS _syncular_windows (
4356                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
4357                   PRIMARY KEY (base, unit));
4358                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
4359                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
4360                   effective_scopes TEXT NOT NULL);
4361                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
4362                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
4363                   PRIMARY KEY(commit_id, blob_id));
4364                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
4365                   ON _syncular_blob_commit_refs(blob_id);",
4366            )
4367            .map_err(|e| e.to_string())?;
4368        // Migrate an outcome journal created before failed aggregate
4369        // envelopes were retained. Historical rows intentionally stay NULL.
4370        let _ = self
4371            .conn
4372            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
4373        if self.get_meta(LOCAL_REVISION_KEY).is_none() {
4374            self.set_meta(LOCAL_REVISION_KEY, "0");
4375        }
4376        // §5.9.7 blob cache. Mutable upload state stays off the body row so
4377        // completing a large upload does not rewrite its BLOB record.
4378        if self.schema_has_blobs() {
4379            let exists = self
4380                .conn
4381                .query_row(
4382                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
4383                    [],
4384                    |row| row.get::<_, bool>(0),
4385                )
4386                .map_err(|e| e.to_string())?;
4387            if exists {
4388                let mut statement = self
4389                    .conn
4390                    .prepare("PRAGMA table_info(_syncular_blobs)")
4391                    .map_err(|e| e.to_string())?;
4392                let mut columns = statement
4393                    .query_map([], |row| row.get::<_, String>(1))
4394                    .map_err(|e| e.to_string())?
4395                    .collect::<Result<Vec<_>, _>>()
4396                    .map_err(|e| e.to_string())?;
4397                columns.sort();
4398                let mut expected = vec![
4399                    "blob_id",
4400                    "byte_length",
4401                    "bytes",
4402                    "created_at_ms",
4403                    "media_type",
4404                ];
4405                expected.sort_unstable();
4406                if columns != expected {
4407                    return Err(
4408                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
4409                    );
4410                }
4411            } else {
4412                self.conn
4413                    .execute_batch(
4414                        "CREATE TABLE _syncular_blobs(
4415                           blob_id TEXT PRIMARY KEY,
4416                           bytes BLOB NOT NULL,
4417                           byte_length INTEGER NOT NULL,
4418                           media_type TEXT,
4419                           created_at_ms INTEGER NOT NULL);",
4420                    )
4421                    .map_err(|e| e.to_string())?;
4422            }
4423            self.conn
4424                .execute_batch(
4425                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
4426                       blob_id TEXT PRIMARY KEY,
4427                       media_type TEXT,
4428                       created_at_ms INTEGER NOT NULL);",
4429                )
4430                .map_err(|e| e.to_string())?;
4431        }
4432        Ok(())
4433    }
4434
4435    /// True iff any synced table declares a `blob_ref` column (§5.9).
4436    fn schema_has_blobs(&self) -> bool {
4437        self.schema
4438            .tables
4439            .iter()
4440            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
4441    }
4442
4443    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
4444
4445    fn get_meta(&self, key: &str) -> Option<String> {
4446        self.conn
4447            .query_row(
4448                "SELECT value FROM _syncular_meta WHERE key = ?1",
4449                rusqlite::params![key],
4450                |row| row.get::<_, String>(0),
4451            )
4452            .ok()
4453    }
4454
4455    fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
4456        self.conn
4457            .query_row(
4458                "SELECT value FROM _syncular_meta WHERE key = ?1",
4459                rusqlite::params![key],
4460                |row| row.get::<_, String>(0),
4461            )
4462            .optional()
4463            .map_err(|_| {
4464                "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
4465            })
4466    }
4467
4468    fn set_meta(&self, key: &str, value: &str) {
4469        let _ = self.conn.execute(
4470            "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
4471            rusqlite::params![key, value],
4472        );
4473    }
4474
4475    fn delete_meta(&self, key: &str) {
4476        let _ = self.conn.execute(
4477            "DELETE FROM _syncular_meta WHERE key = ?1",
4478            rusqlite::params![key],
4479        );
4480    }
4481
4482    fn restore_persisted_state(&mut self) -> Result<(), String> {
4483        self.subs = {
4484            let mut stmt = self
4485                .conn
4486                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
4487                .map_err(|error| error.to_string())?;
4488            let rows = stmt
4489                .query_map([], |row| {
4490                    Ok((
4491                        row.get::<_, String>(0)?,
4492                        row.get::<_, String>(1)?,
4493                        row.get::<_, String>(2)?,
4494                    ))
4495                })
4496                .map_err(|error| error.to_string())?;
4497            let mut subscriptions = Vec::new();
4498            for row in rows {
4499                let (id, table, raw) = row.map_err(|error| error.to_string())?;
4500                let state: Value = serde_json::from_str(&raw)
4501                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
4502                let requested = json_to_scope_map(
4503                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
4504                )?;
4505                let effective = state
4506                    .get("effectiveScopes")
4507                    .filter(|value| !value.is_null())
4508                    .map(json_to_scope_map)
4509                    .transpose()?;
4510                subscriptions.push(Subscription {
4511                    id,
4512                    table,
4513                    requested,
4514                    params: state
4515                        .get("params")
4516                        .and_then(Value::as_str)
4517                        .map(str::to_owned),
4518                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
4519                    bootstrap_state: state
4520                        .get("bootstrapState")
4521                        .and_then(Value::as_str)
4522                        .map(str::to_owned),
4523                    state: SubState::parse(
4524                        state
4525                            .get("status")
4526                            .and_then(Value::as_str)
4527                            .unwrap_or("active"),
4528                    ),
4529                    reason_code: state
4530                        .get("reasonCode")
4531                        .and_then(Value::as_str)
4532                        .map(str::to_owned),
4533                    effective,
4534                    synced_once: state
4535                        .get("syncedOnce")
4536                        .and_then(Value::as_bool)
4537                        .unwrap_or(false),
4538                });
4539            }
4540            subscriptions
4541        };
4542
4543        self.outbox = {
4544            let mut stmt = self
4545                .conn
4546                .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
4547                .map_err(|error| error.to_string())?;
4548            let rows = stmt
4549                .query_map([], |row| {
4550                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
4551                })
4552                .map_err(|error| error.to_string())?;
4553            let mut commits = Vec::new();
4554            for row in rows {
4555                let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
4556                let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
4557                    format!("invalid persisted outbox {client_commit_id:?}: {error}")
4558                })?;
4559                let mut ops = Vec::with_capacity(entries.len());
4560                for entry in entries {
4561                    let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
4562                    ops.push(OutboxOp {
4563                        upsert: op == "upsert",
4564                        table: entry
4565                            .get("table")
4566                            .and_then(Value::as_str)
4567                            .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
4568                            .to_owned(),
4569                        row_id: entry
4570                            .get("rowId")
4571                            .and_then(Value::as_str)
4572                            .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
4573                            .to_owned(),
4574                        base_version: entry.get("baseVersion").and_then(Value::as_i64),
4575                        values: entry.get("values").and_then(Value::as_object).cloned(),
4576                        changed_fields: entry.get("changedFields").and_then(Value::as_array).map(
4577                            |values| {
4578                                values
4579                                    .iter()
4580                                    .filter_map(Value::as_str)
4581                                    .map(str::to_owned)
4582                                    .collect()
4583                            },
4584                        ),
4585                    });
4586                }
4587                commits.push(OutboxCommit {
4588                    client_commit_id,
4589                    ops,
4590                });
4591            }
4592            commits
4593        };
4594
4595        self.prune_commit_outcomes()?;
4596        let active = self.commit_outcomes(CommitOutcomeQuery {
4597            active_only: true,
4598            ..CommitOutcomeQuery::default()
4599        })?;
4600        self.conflicts = active
4601            .iter()
4602            .flat_map(|outcome| outcome.results.iter())
4603            .filter_map(|result| match result {
4604                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
4605                _ => None,
4606            })
4607            .collect();
4608        self.rejections = active
4609            .iter()
4610            .flat_map(|outcome| outcome.results.iter())
4611            .filter_map(|result| match result {
4612                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
4613                _ => None,
4614            })
4615            .collect();
4616
4617        self.lease_state = self
4618            .get_meta(LEASE_STATE_KEY)
4619            .map(|raw| serde_json::from_str(&raw))
4620            .transpose()
4621            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
4622        self.schema_floor = self
4623            .get_meta(SCHEMA_FLOOR_KEY)
4624            .map(|raw| serde_json::from_str(&raw))
4625            .transpose()
4626            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
4627        self.stopped = self.schema_floor.is_some();
4628        // A replica left in unactivated preflight reopens gated: the quarantine
4629        // decision persists with the data across handle teardown and restart.
4630        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
4631            self.security_preflight = true;
4632        }
4633        Ok(())
4634    }
4635
4636    /// Remove registrations for tables the running schema no longer knows.
4637    /// Otherwise the server rejects every pull with `sync.unknown_table`.
4638    fn prune_unknown_subscriptions(&mut self) -> Result<(), String> {
4639        let valid_tables: BTreeSet<String> = self
4640            .schema
4641            .tables
4642            .iter()
4643            .map(|table| table.name.clone())
4644            .collect();
4645        let stale_ids: Vec<String> = self
4646            .subs
4647            .iter()
4648            .filter(|sub| !valid_tables.contains(&sub.table))
4649            .map(|sub| sub.id.clone())
4650            .collect();
4651        for id in &stale_ids {
4652            self.conn
4653                .execute(
4654                    "DELETE FROM _syncular_windows WHERE sub_id = ?1",
4655                    rusqlite::params![id],
4656                )
4657                .map_err(|error| error.to_string())?;
4658            self.conn
4659                .execute(
4660                    "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
4661                    rusqlite::params![id],
4662                )
4663                .map_err(|error| error.to_string())?;
4664            self.conn
4665                .execute(
4666                    "DELETE FROM _syncular_subscriptions WHERE id = ?1",
4667                    rusqlite::params![id],
4668                )
4669                .map_err(|error| error.to_string())?;
4670        }
4671        self.subs.retain(|sub| valid_tables.contains(&sub.table));
4672        Ok(())
4673    }
4674
4675    #[must_use]
4676    pub fn local_revision(&self) -> u64 {
4677        self.get_meta(LOCAL_REVISION_KEY)
4678            .and_then(|value| value.parse().ok())
4679            .unwrap_or(0)
4680    }
4681
4682    #[must_use]
4683    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
4684        SyncStatusSnapshot {
4685            current_schema_version: self.schema.version,
4686            outbox: self.outbox.len(),
4687            upgrading: self.upgrading,
4688            lease_state: self.lease_state.clone(),
4689            schema_floor: self.schema_floor.clone(),
4690            sync_needed: self.sync_needed,
4691        }
4692    }
4693
4694    pub fn diagnostics_snapshot(
4695        &self,
4696        request: &ClientDiagnosticsRequest,
4697    ) -> Result<ClientDiagnosticsSnapshot, String> {
4698        if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
4699            return Err(format!(
4700                "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
4701            ));
4702        }
4703        let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
4704        for sub in &self.subs {
4705            let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
4706            let complete =
4707                sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
4708            let state = match sub.state {
4709                SubState::Revoked => "revoked",
4710                SubState::Failed => "failed",
4711                SubState::Active if reset => "reset",
4712                SubState::Active if complete => "complete",
4713                SubState::Active => "bootstrapping",
4714            };
4715            subscriptions.insert(
4716                sub.id.clone(),
4717                DiagnosticSubscription {
4718                    id: sub.id.clone(),
4719                    table: sub.table.clone(),
4720                    state: state.to_owned(),
4721                    complete,
4722                    cursor: Some(sub.cursor),
4723                    reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
4724                },
4725            );
4726        }
4727        for expected in &request.expected_subscriptions {
4728            if expected.id.is_empty() || expected.table.is_empty() {
4729                return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
4730            }
4731            if subscriptions
4732                .get(&expected.id)
4733                .is_some_and(|registered| registered.table != expected.table)
4734            {
4735                subscriptions.insert(
4736                    expected.id.clone(),
4737                    DiagnosticSubscription {
4738                        id: expected.id.clone(),
4739                        table: expected.table.clone(),
4740                        state: "failed".to_owned(),
4741                        complete: false,
4742                        cursor: None,
4743                        reason_code: Some("client.subscription_intent_mismatch".to_owned()),
4744                    },
4745                );
4746            } else {
4747                subscriptions.entry(expected.id.clone()).or_insert_with(|| {
4748                    DiagnosticSubscription {
4749                        id: expected.id.clone(),
4750                        table: expected.table.clone(),
4751                        state: "unregistered".to_owned(),
4752                        complete: false,
4753                        cursor: None,
4754                        reason_code: None,
4755                    }
4756                });
4757            }
4758        }
4759        let mut ordered_subscriptions = Vec::new();
4760        let mut included = BTreeSet::new();
4761        for expected in &request.expected_subscriptions {
4762            if included.insert(expected.id.clone()) {
4763                if let Some(subscription) = subscriptions.get(&expected.id) {
4764                    ordered_subscriptions.push(subscription.clone());
4765                }
4766            }
4767        }
4768        for (id, subscription) in &subscriptions {
4769            if included.insert(id.clone()) {
4770                ordered_subscriptions.push(subscription.clone());
4771            }
4772        }
4773        let subscriptions_truncated =
4774            ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
4775        ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
4776        let captured_at_ms = self.clock_now_ms();
4777        let lease = if let Some(error_code) = self
4778            .lease_state
4779            .as_ref()
4780            .and_then(|state| state.error_code.clone())
4781        {
4782            ClientDiagnosticsLease {
4783                state: "stopped".to_owned(),
4784                expires_at_ms: self
4785                    .lease_state
4786                    .as_ref()
4787                    .and_then(|state| state.expires_at_ms),
4788                error_code: Some(Self::diagnostic_code(&error_code)),
4789            }
4790        } else if let Some(expires_at_ms) = self
4791            .lease_state
4792            .as_ref()
4793            .and_then(|state| state.expires_at_ms)
4794        {
4795            ClientDiagnosticsLease {
4796                state: if expires_at_ms <= captured_at_ms {
4797                    "expired".to_owned()
4798                } else {
4799                    "active".to_owned()
4800                },
4801                expires_at_ms: Some(expires_at_ms),
4802                error_code: None,
4803            }
4804        } else {
4805            ClientDiagnosticsLease {
4806                state: "none".to_owned(),
4807                expires_at_ms: None,
4808                error_code: None,
4809            }
4810        };
4811        let connectivity = match self.last_round.as_ref() {
4812            Some(round) if round.status == "succeeded" => "online",
4813            Some(round)
4814                if round.status == "failed"
4815                    && round
4816                        .error_code
4817                        .as_deref()
4818                        .is_some_and(Self::retryable_transport_code) =>
4819            {
4820                "offline"
4821            }
4822            _ => "unknown",
4823        };
4824        Ok(ClientDiagnosticsSnapshot {
4825            version: CLIENT_DIAGNOSTICS_VERSION,
4826            captured_at_ms,
4827            host: ClientDiagnosticsHost {
4828                kind: "direct".to_owned(),
4829                role: "single".to_owned(),
4830                connectivity: connectivity.to_owned(),
4831                realtime: if self.realtime_connected {
4832                    "connected".to_owned()
4833                } else {
4834                    "disconnected".to_owned()
4835                },
4836            },
4837            security_lifecycle: self.security_lifecycle().to_owned(),
4838            schema: ClientDiagnosticsSchema {
4839                current_version: self.schema.version,
4840                upgrading: self.upgrading,
4841                required_version: self
4842                    .schema_floor
4843                    .as_ref()
4844                    .and_then(|floor| floor.required_schema_version),
4845                latest_version: self
4846                    .schema_floor
4847                    .as_ref()
4848                    .and_then(|floor| floor.latest_schema_version),
4849            },
4850            replica: ClientDiagnosticsReplica {
4851                local_revision: self.local_revision().to_string(),
4852                sync_needed: self.sync_needed,
4853                pending_outbox: self.outbox.len(),
4854            },
4855            lease,
4856            subscriptions: ordered_subscriptions,
4857            subscriptions_truncated,
4858            last_round: self.last_round.clone(),
4859            last_change: self.last_change.clone(),
4860            storage: self.diagnostics_storage(),
4861        })
4862    }
4863
4864    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
4865        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
4866            let page_count: i64 = self
4867                .conn
4868                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
4869            let page_size: i64 = self
4870                .conn
4871                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
4872            // Reuse compiled aggregates; every invocation still reads current storage.
4873            let outbox_bytes: i64 = self
4874                .conn
4875                .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
4876                .query_row([], |row| row.get(0))?;
4877            let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
4878                "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
4879            )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
4880            let blob_bytes = if self.schema_has_blobs() {
4881                self.conn
4882                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
4883                    .query_row([], |row| row.get(0))?
4884            } else {
4885                0
4886            };
4887            let pressure = self
4888                .limits
4889                .blob_cache_max_bytes
4890                .is_some_and(|limit| blob_bytes > limit);
4891            Ok(ClientDiagnosticsStorage {
4892                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
4893                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
4894                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
4895                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
4896                retained_outcome_entries: Some(outcome_entries.max(0)),
4897                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
4898                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
4899            })
4900        };
4901        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
4902            status: "unreadable".to_owned(),
4903            database_bytes_approx: None,
4904            pending_outbox_bytes_approx: None,
4905            retained_outcome_bytes_approx: None,
4906            retained_outcome_entries: None,
4907            blob_cache_bytes_approx: None,
4908            pressure_reason_code: None,
4909        })
4910    }
4911
4912    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
4913        self.change_queue.drain(..).collect()
4914    }
4915
4916    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
4917        self.sync_intent_queue.drain(..).collect()
4918    }
4919
4920    fn schedule_background_retry(&mut self) {
4921        self.sync_intent_queue.push_back(SyncIntent::Background {
4922            delay_ms: self.retry_delay_ms,
4923        });
4924        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
4925    }
4926
4927    fn reset_background_retry(&mut self) {
4928        self.retry_delay_ms = 250;
4929    }
4930
4931    fn retryable_transport_code(code: &str) -> bool {
4932        code == "transport.failed"
4933            || code == "transport.unavailable"
4934            || code == "sync.transport_failed"
4935    }
4936
4937    fn diagnostic_code(code: &str) -> String {
4938        let valid = !code.is_empty()
4939            && code.len() <= 96
4940            && code.contains('.')
4941            && code
4942                .bytes()
4943                .next()
4944                .is_some_and(|byte| byte.is_ascii_lowercase())
4945            && code.bytes().all(|byte| {
4946                byte.is_ascii_lowercase()
4947                    || byte.is_ascii_digit()
4948                    || matches!(byte, b'.' | b'_' | b'-')
4949            });
4950        if valid {
4951            code.to_owned()
4952        } else {
4953            "client.unknown_failure".to_owned()
4954        }
4955    }
4956
4957    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
4958        if self.sync_needed != value {
4959            if self.begin_observation("syncular_status").is_ok() {
4960                self.sync_needed = value;
4961                let batch = ChangeAccumulator {
4962                    status: true,
4963                    ..ChangeAccumulator::default()
4964                };
4965                if self.finish_observation("syncular_status", batch).is_err() {
4966                    self.rollback_observation("syncular_status");
4967                }
4968            } else {
4969                self.sync_needed = value;
4970            }
4971        }
4972        if value && interactive {
4973            self.sync_intent_queue.push_back(SyncIntent::Interactive);
4974        }
4975    }
4976
4977    fn begin_observation(&self, name: &str) -> Result<(), String> {
4978        self.conn
4979            .execute_batch(&format!("SAVEPOINT {name}"))
4980            .map_err(|error| error.to_string())
4981    }
4982
4983    fn rollback_observation(&self, name: &str) {
4984        let _ = self
4985            .conn
4986            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"));
4987    }
4988
4989    fn finish_observation(&mut self, name: &str, batch: ChangeAccumulator) -> Result<(), String> {
4990        #[cfg(feature = "bench-internals")]
4991        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
4992        if !batch.touched() {
4993            self.conn
4994                .execute_batch(&format!("RELEASE {name}"))
4995                .map_err(|error| error.to_string())?;
4996            return Ok(());
4997        }
4998        let revision = self
4999            .local_revision()
5000            .checked_add(1)
5001            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
5002        self.conn
5003            .execute(
5004                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
5005                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
5006            )
5007            .map_err(|error| error.to_string())?;
5008        let status = batch.status.then(|| self.status_snapshot());
5009        let event = ClientChangeBatch {
5010            revision: revision.to_string(),
5011            tables: batch
5012                .tables
5013                .into_iter()
5014                .map(|(table, scope_keys)| TableChange {
5015                    table,
5016                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
5017                })
5018                .collect(),
5019            windows: batch
5020                .windows
5021                .into_iter()
5022                .map(|((base_key, table), units)| WindowChange {
5023                    base_key,
5024                    table,
5025                    units: units.into_iter().collect(),
5026                })
5027                .collect(),
5028            status,
5029            conflicts_changed: batch.conflicts,
5030            rejections_changed: batch.rejections,
5031            outcomes_changed: batch.outcomes,
5032        };
5033        self.conn
5034            .execute_batch(&format!("RELEASE {name}"))
5035            .map_err(|error| error.to_string())?;
5036        let mut diagnostic_tables = event
5037            .tables
5038            .iter()
5039            .map(|entry| entry.table.clone())
5040            .collect::<BTreeSet<_>>()
5041            .into_iter()
5042            .collect::<Vec<_>>();
5043        let mut diagnostic_windows = event
5044            .windows
5045            .iter()
5046            .map(|entry| entry.table.clone())
5047            .collect::<BTreeSet<_>>()
5048            .into_iter()
5049            .collect::<Vec<_>>();
5050        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
5051            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
5052        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
5053        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
5054        self.last_change = Some(DiagnosticLastChange {
5055            revision: event.revision.clone(),
5056            recorded_at_ms: self.clock_now_ms(),
5057            tables: diagnostic_tables,
5058            windows: diagnostic_windows,
5059            domains_truncated,
5060            status_changed: event.status.is_some(),
5061            conflicts_changed: event.conflicts_changed,
5062            rejections_changed: event.rejections_changed,
5063            outcomes_changed: event.outcomes_changed,
5064        });
5065        self.change_queue.push_back(event);
5066        Ok(())
5067    }
5068
5069    fn record_scope_map(
5070        &self,
5071        batch: &mut ChangeAccumulator,
5072        table_name: &str,
5073        scopes: &[(String, Vec<String>)],
5074    ) {
5075        let Some(table) = self.schema.table(table_name) else {
5076            return;
5077        };
5078        for (variable, values) in scopes {
5079            let Some(scope) = table
5080                .scope_variables
5081                .iter()
5082                .find(|scope| &scope.variable == variable)
5083            else {
5084                continue;
5085            };
5086            for value in values {
5087                batch.scope(table_name, format!("{}:{value}", scope.prefix));
5088            }
5089        }
5090    }
5091
5092    /// Record a row's current scope keys from the base or visible table.
5093    fn record_row_scopes(
5094        &self,
5095        batch: &mut ChangeAccumulator,
5096        table_name: &str,
5097        row_id: &str,
5098        base: bool,
5099    ) -> bool {
5100        let Some(table) = self.schema.table(table_name) else {
5101            return false;
5102        };
5103        if table.scope_variables.is_empty() {
5104            return false;
5105        }
5106        let columns = table
5107            .scope_variables
5108            .iter()
5109            .map(|scope| quote_ident(&scope.column))
5110            .collect::<Vec<_>>()
5111            .join(", ");
5112        let full_table = if base {
5113            base_table(table_name)
5114        } else {
5115            visible_table(table_name)
5116        };
5117        let sql = format!(
5118            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
5119            row_id_predicate(table)
5120        );
5121        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
5122            return false;
5123        };
5124        let values = stmt.query_row(rusqlite::params![row_id], |row| {
5125            let mut values = Vec::with_capacity(table.scope_variables.len());
5126            for index in 0..table.scope_variables.len() {
5127                values.push(row.get::<_, Option<String>>(index)?);
5128            }
5129            Ok(values)
5130        });
5131        let Ok(values) = values else {
5132            return false;
5133        };
5134        let mut recorded = false;
5135        for (scope, value) in table.scope_variables.iter().zip(values) {
5136            if let Some(value) = value {
5137                batch.scope(table_name, format!("{}:{value}", scope.prefix));
5138                recorded = true;
5139            }
5140        }
5141        recorded
5142    }
5143
5144    fn record_commit_changes(
5145        &self,
5146        batch: &mut ChangeAccumulator,
5147        tables: &[String],
5148        changes: &[ssp2::model::Change],
5149    ) {
5150        #[cfg(feature = "bench-internals")]
5151        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
5152        for change in changes {
5153            let Some(table_name) = tables.get(change.table_index as usize) else {
5154                continue;
5155            };
5156            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
5157            if let Some(table) = self.schema.table(table_name) {
5158                for (variable, value) in &change.scopes {
5159                    if let Some(scope) = table
5160                        .scope_variables
5161                        .iter()
5162                        .find(|scope| &scope.variable == variable)
5163                    {
5164                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
5165                        precise = true;
5166                    }
5167                }
5168            }
5169            if !precise {
5170                batch.table(table_name);
5171            }
5172        }
5173    }
5174
5175    fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
5176        if effective.is_empty() {
5177            return false;
5178        }
5179        let Some(table) = self.schema.table(table_name) else {
5180            return false;
5181        };
5182        let mut clauses = Vec::new();
5183        let mut params = Vec::new();
5184        for (variable, values) in effective {
5185            let Some(column) = table.scope_column(variable) else {
5186                return false;
5187            };
5188            if values.is_empty() {
5189                return false;
5190            }
5191            let placeholders = values
5192                .iter()
5193                .map(|value| {
5194                    params.push(SqlValue::Text(value.clone()));
5195                    "?"
5196                })
5197                .collect::<Vec<_>>()
5198                .join(", ");
5199            clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
5200        }
5201        let sql = format!(
5202            "SELECT 1 FROM {} WHERE {} LIMIT 1",
5203            base_table(table_name),
5204            clauses.join(" AND ")
5205        );
5206        self.conn
5207            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
5208            .is_ok()
5209    }
5210
5211    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
5212    pub fn upgrading(&self) -> bool {
5213        self.upgrading
5214    }
5215
5216    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
5217    /// keeping this client's local database (identity, outbox, tables). The
5218    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
5219    /// version changed. Mirrors the TS client's boot-time detection —
5220    /// the Rust core has no persistent restart, so recreation IS the boot.
5221    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
5222        let new_schema = parse_schema_json(schema_json)?;
5223        let marker: Option<i32> = self
5224            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
5225            .and_then(|v| v.parse().ok());
5226        self.schema = new_schema;
5227        self.create_bookkeeping_tables()?;
5228        if marker != Some(self.schema.version) {
5229            self.run_schema_reset()?;
5230        }
5231        self.prune_unknown_subscriptions()?;
5232        // The conformance recreate is the in-memory equivalent of reopening a
5233        // durable client. Apply the same startup catch-up contract even when
5234        // the schema itself did not change.
5235        self.enqueue_startup_sync_if_needed();
5236        Ok(())
5237    }
5238
5239    fn enqueue_startup_sync_if_needed(&mut self) {
5240        let startup_work = !self.stopped
5241            && (!self.outbox.is_empty()
5242                || self.subs.iter().any(|sub| sub.state == SubState::Active));
5243        if startup_work {
5244            self.sync_needed = true;
5245            self.sync_intent_queue.push_back(SyncIntent::Interactive);
5246        }
5247    }
5248
5249    /// A native client persists schema-floor stops across process restarts.
5250    /// Once the running app already satisfies that floor, the persisted stop
5251    /// is only stale evidence from an older server round (for example, the
5252    /// server was restarted after catching up to an app that was ahead).
5253    /// Clear it and let the normal startup pull re-negotiate. If the server is
5254    /// still incompatible it will return the floor again in that first round.
5255    fn clear_satisfied_persisted_schema_floor(&mut self) {
5256        let satisfied = self
5257            .schema_floor
5258            .as_ref()
5259            .and_then(|floor| floor.required_schema_version)
5260            .is_some_and(|required| self.schema.version >= required);
5261        if !satisfied {
5262            return;
5263        }
5264        self.schema_floor = None;
5265        self.stopped = false;
5266        self.delete_meta(SCHEMA_FLOOR_KEY);
5267    }
5268
5269    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
5270    /// and leaseState. Drops/recreates every synced table from the new
5271    /// schema, resets subscription sync-state (keeping registrations), clears
5272    /// the schema-floor stop state, rewrites the marker, drops outbox commits
5273    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
5274    fn run_schema_reset(&mut self) -> Result<(), String> {
5275        self.begin_observation("syncular_schema_reset")?;
5276        let mut batch = ChangeAccumulator::default();
5277        let result = self.run_schema_reset_observed(&mut batch, true);
5278        if let Err(error) = result {
5279            self.rollback_observation("syncular_schema_reset");
5280            return Err(error);
5281        }
5282        if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
5283            self.rollback_observation("syncular_schema_reset");
5284            return Err(error);
5285        }
5286        Ok(())
5287    }
5288
5289    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
5290        let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
5291        self.begin_observation("syncular_log_epoch_reset")?;
5292        let mut batch = ChangeAccumulator::default();
5293        let result = self.run_schema_reset_observed(&mut batch, false);
5294        if let Err(error) = result {
5295            self.rollback_observation("syncular_log_epoch_reset");
5296            return Err(error);
5297        }
5298        self.set_meta(LOG_EPOCH_KEY, log_epoch);
5299        self.sync_needed = true;
5300        batch.status = true;
5301        if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
5302            self.rollback_observation("syncular_log_epoch_reset");
5303            return Err(error);
5304        }
5305        self.sync_intent_queue.push_back(SyncIntent::Interactive);
5306        Ok(resets)
5307    }
5308
5309    fn run_schema_reset_observed(
5310        &mut self,
5311        batch: &mut ChangeAccumulator,
5312        drop_incompatible: bool,
5313    ) -> Result<(), String> {
5314        self.upgrading = true;
5315        batch.status = true;
5316        for table in &self.schema.tables {
5317            batch.table(&table.name);
5318        }
5319        for (base_key, unit, table) in self.load_registered_window_units() {
5320            batch.window(&base_key, &table, &unit);
5321        }
5322        // The per-table insert SQL is derived from the OLD column lists.
5323        self.insert_sql.borrow_mut().clear();
5324        self.overlay_dirty.set(true);
5325        // Drop every synced table (base + visible) that currently exists —
5326        // discovered from sqlite_master so a bump that adds/removes tables is
5327        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
5328        // and the blob cache) are preserved; base tables are `_syncular_base_*`
5329        // so they are matched explicitly, not by the bookkeeping filter.
5330        // Drop virtual tables first so SQLite removes every FTS shadow table
5331        // atomically instead of the generic discovery tearing it apart.
5332        let virtual_tables: Vec<String> = {
5333            let mut stmt = self
5334                .conn
5335                .prepare(
5336                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
5337                )
5338                .map_err(|e| e.to_string())?;
5339            let rows = stmt
5340                .query_map([], |row| row.get::<_, String>(0))
5341                .map_err(|e| e.to_string())?;
5342            rows.filter_map(Result::ok)
5343                .filter(|name| is_synced_table_name(name))
5344                .collect()
5345        };
5346        for name in virtual_tables {
5347            self.conn
5348                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
5349                .map_err(|e| e.to_string())?;
5350        }
5351        let existing: Vec<String> = {
5352            let mut stmt = self
5353                .conn
5354                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
5355                .map_err(|e| e.to_string())?;
5356            let rows = stmt
5357                .query_map([], |row| row.get::<_, String>(0))
5358                .map_err(|e| e.to_string())?;
5359            rows.filter_map(Result::ok)
5360                .filter(|name| is_synced_table_name(name))
5361                .collect()
5362        };
5363        for name in &existing {
5364            if let Some(table) = name.strip_prefix("_syncular_base_") {
5365                batch.table(table);
5366            } else if !name.starts_with("_syncular_") {
5367                batch.table(name);
5368            }
5369        }
5370        for name in existing {
5371            self.conn
5372                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
5373                .map_err(|e| e.to_string())?;
5374        }
5375        // Recreate the synced tables from the NEW schema.
5376        self.create_synced_tables()?;
5377        // Reset every subscription's sync-state, keeping the registration.
5378        for sub in &mut self.subs {
5379            sub.cursor = -1;
5380            sub.bootstrap_state = None;
5381            sub.effective = None;
5382            sub.state = SubState::Active;
5383            sub.reason_code = None;
5384            sub.synced_once = false;
5385        }
5386        let subs = self.subs.clone();
5387        for sub in &subs {
5388            self.persist_sub(sub)?;
5389        }
5390        // The stop state is over: this client now ships a servable schema.
5391        self.stopped = false;
5392        self.schema_floor = None;
5393        self.delete_meta(SCHEMA_FLOOR_KEY);
5394        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
5395        self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
5396        // §7.4.4: drop outbox commits that cannot re-encode under the new
5397        // schema (a referenced column/table the bump removed), surfacing each
5398        // as a `sync.outbox_incompatible` rejection.
5399        if drop_incompatible && self.drop_incompatible_outbox()? {
5400            batch.rejections = true;
5401            batch.status = true;
5402            batch.outcomes = true;
5403        }
5404        // Re-apply the surviving outbox optimistically over the empty tables.
5405        self.rebuild_overlay();
5406        Ok(())
5407    }
5408
5409    /// §7.4.4: a persisted upsert whose values reference a column the current
5410    /// schema lacks (or a removed table) cannot be encoded. Drop the commit
5411    /// and raise a client-local `sync.outbox_incompatible` rejection.
5412    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
5413        let schema = &self.schema;
5414        let incompatible = self
5415            .outbox
5416            .iter()
5417            .filter(|commit| {
5418                commit.ops.iter().any(|op| {
5419                    if !op.upsert {
5420                        return false;
5421                    }
5422                    match schema.table(&op.table) {
5423                        None => true,
5424                        Some(table) => op.values.as_ref().is_some_and(|values| {
5425                            values
5426                                .keys()
5427                                .any(|key| !table.columns.iter().any(|c| &c.name == key))
5428                        }),
5429                    }
5430                })
5431            })
5432            .cloned()
5433            .collect::<Vec<_>>();
5434        if incompatible.is_empty() {
5435            return Ok(false);
5436        }
5437        let mut rejections = Vec::new();
5438        for commit in &incompatible {
5439            let results = commit
5440                .ops
5441                .iter()
5442                .enumerate()
5443                .map(|(op_index, operation)| {
5444                    let rejection = RejectionRecord {
5445                        client_commit_id: commit.client_commit_id.clone(),
5446                        op_index: op_index as i32,
5447                        code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
5448                        message: "the persisted commit cannot encode under the current schema"
5449                            .to_owned(),
5450                        retryable: false,
5451                        details: None,
5452                        operation: Some(CommitOperation::from(operation)),
5453                    };
5454                    rejections.push(rejection.clone());
5455                    CommitOperationOutcome::Error { rejection }
5456                })
5457                .collect::<Vec<_>>();
5458            self.persist_commit_outcome(
5459                &commit.client_commit_id,
5460                CommitOutcomeStatus::Rejected,
5461                &results,
5462                Some(&commit.ops),
5463            )?;
5464            self.delete_outbox_persisted(&commit.client_commit_id)?;
5465        }
5466        self.prune_commit_outcomes()?;
5467        let incompatible_ids = incompatible
5468            .iter()
5469            .map(|commit| commit.client_commit_id.as_str())
5470            .collect::<BTreeSet<_>>();
5471        self.outbox
5472            .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
5473        self.rejections.extend(rejections);
5474        Ok(true)
5475    }
5476
5477    /// §7.4.3: (re)create the base + visible table pair for every synced
5478    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
5479    fn create_synced_tables(&self) -> Result<(), String> {
5480        for table in &self.schema.tables {
5481            // The base half + the visible half form the synced-table pair. An
5482            // index name is global in SQLite, so the base half's indexes are
5483            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
5484            for (full, index_prefix) in [
5485                (base_table(&table.name), "_syncular_base_"),
5486                (visible_table(&table.name), ""),
5487            ] {
5488                let mut cols: Vec<String> =
5489                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
5490                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
5491                let sql = format!(
5492                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
5493                    cols.join(", "),
5494                    quote_ident(&table.primary_key)
5495                );
5496                self.conn.execute(&sql, []).map_err(|e| e.to_string())?;
5497                // Local secondary indexes (CREATE INDEX subset). Created on
5498                // both halves so mirror reads hit an index on either. Runs on
5499                // both the initial create and the §7.4.3 reset recreate path.
5500                for index in &table.indexes {
5501                    let unique = if index.unique { "UNIQUE " } else { "" };
5502                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
5503                    let cols_sql = index
5504                        .columns
5505                        .iter()
5506                        .map(|c| quote_ident(c))
5507                        .collect::<Vec<_>>()
5508                        .join(", ");
5509                    let index_sql = format!(
5510                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
5511                    );
5512                    self.conn
5513                        .execute(&index_sql, [])
5514                        .map_err(|e| e.to_string())?;
5515                }
5516            }
5517        }
5518        // FTS exists only on the visible half. It is a local search
5519        // projection, never a synced/base table or a wire-schema member.
5520        for table in &self.schema.tables {
5521            for index in &table.fts_indexes {
5522                self.create_fts_projection(table, index)?;
5523            }
5524        }
5525        Ok(())
5526    }
5527
5528    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
5529        let count: i64 = self
5530            .conn
5531            .query_row(
5532                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
5533                rusqlite::params![index.name],
5534                |row| row.get(0),
5535            )
5536            .map_err(|e| e.to_string())?;
5537        Ok(count > 0)
5538    }
5539
5540    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
5541        for suffix in ["ai", "ad", "au"] {
5542            self.conn
5543                .execute(
5544                    &format!(
5545                        "DROP TRIGGER IF EXISTS {}",
5546                        quote_ident(&format!("{}_{suffix}", index.name))
5547                    ),
5548                    [],
5549                )
5550                .map_err(|e| e.to_string())?;
5551        }
5552        Ok(())
5553    }
5554
5555    fn create_fts_triggers(
5556        &self,
5557        table: &TableSchema,
5558        index: &FtsIndexSchema,
5559    ) -> Result<(), String> {
5560        let fts = quote_ident(&index.name);
5561        let source = visible_table(&table.name);
5562        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
5563        let pk = quote_ident(&table.primary_key);
5564        let projection_columns = std::iter::once(source_id.clone())
5565            .chain(index.columns.iter().map(|column| quote_ident(column)))
5566            .collect::<Vec<_>>()
5567            .join(", ");
5568        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
5569            .chain(
5570                index
5571                    .columns
5572                    .iter()
5573                    .map(|column| format!("new.{}", quote_ident(column))),
5574            )
5575            .collect::<Vec<_>>()
5576            .join(", ");
5577        let delete_new = format!("DELETE FROM {fts} WHERE {source_id} = CAST(new.{pk} AS TEXT)");
5578        let delete_old = format!("DELETE FROM {fts} WHERE {source_id} = CAST(old.{pk} AS TEXT)");
5579        let insert_new = format!("INSERT INTO {fts} ({projection_columns}) VALUES ({new_values})");
5580        let bi = quote_ident(&format!("{}_bi", index.name));
5581        let ai = quote_ident(&format!("{}_ai", index.name));
5582        let ad = quote_ident(&format!("{}_ad", index.name));
5583        let au = quote_ident(&format!("{}_au", index.name));
5584        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
5585        let sql = format!(
5586            "DROP TRIGGER IF EXISTS {bi};
5587             DROP TRIGGER IF EXISTS {ai};
5588             DROP TRIGGER IF EXISTS {ad};
5589             DROP TRIGGER IF EXISTS {au};
5590             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
5591             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
5592             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
5593             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
5594        );
5595        self.conn.execute_batch(&sql).map_err(|e| e.to_string())
5596    }
5597
5598    fn rebuild_fts_projection(
5599        &self,
5600        table: &TableSchema,
5601        index: &FtsIndexSchema,
5602    ) -> Result<(), String> {
5603        let fts = quote_ident(&index.name);
5604        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
5605        let pk = quote_ident(&table.primary_key);
5606        let indexed_columns = index
5607            .columns
5608            .iter()
5609            .map(|column| quote_ident(column))
5610            .collect::<Vec<_>>();
5611        let projection_columns = std::iter::once(source_id)
5612            .chain(indexed_columns.iter().cloned())
5613            .collect::<Vec<_>>()
5614            .join(", ");
5615        let sql = format!(
5616            "DELETE FROM {fts}; INSERT INTO {fts} ({projection_columns}) SELECT CAST({pk} AS TEXT), {columns} FROM {source};",
5617            columns = indexed_columns.join(", "),
5618            source = visible_table(&table.name),
5619        );
5620        self.conn.execute_batch(&sql).map_err(|e| e.to_string())
5621    }
5622
5623    fn create_fts_projection(
5624        &self,
5625        table: &TableSchema,
5626        index: &FtsIndexSchema,
5627    ) -> Result<(), String> {
5628        let existed = self.fts_projection_exists(index)?;
5629        let tokenizer = index.tokenize.replace('\'', "''");
5630        let columns = index
5631            .columns
5632            .iter()
5633            .map(|column| quote_ident(column))
5634            .collect::<Vec<_>>()
5635            .join(", ");
5636        let sql = format!(
5637            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
5638            fts = quote_ident(&index.name),
5639            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
5640        );
5641        self.conn.execute(&sql, []).map_err(|error| {
5642            format!(
5643                "cannot create local FTS5 projection {:?}: {error}",
5644                index.name
5645            )
5646        })?;
5647        self.create_fts_triggers(table, index)?;
5648        if !existed {
5649            self.rebuild_fts_projection(table, index)?;
5650        }
5651        Ok(())
5652    }
5653
5654    // -- persistence write-through --------------------------------------------
5655
5656    fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
5657        let state = serde_json::json!({
5658            "requested": scope_map_to_json(&sub.requested),
5659            "params": sub.params,
5660            "cursor": sub.cursor,
5661            "bootstrapState": sub.bootstrap_state,
5662            "status": sub.state.name(),
5663            "reasonCode": sub.reason_code,
5664            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
5665            "syncedOnce": sub.synced_once,
5666        });
5667        self.conn.execute(
5668            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
5669            rusqlite::params![sub.id, sub.table, state.to_string()],
5670        ).map(|_| ()).map_err(|error| error.to_string())
5671    }
5672
5673    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
5674        let ops: Vec<Value> = commit
5675            .ops
5676            .iter()
5677            .map(|op| {
5678                serde_json::json!({
5679                    "op": if op.upsert { "upsert" } else { "delete" },
5680                    "table": op.table,
5681                    "rowId": op.row_id,
5682                    "baseVersion": op.base_version,
5683                    "values": op.values.clone().map(Value::Object),
5684                    "changedFields": op.changed_fields,
5685                })
5686            })
5687            .collect();
5688        self.conn
5689            .execute(
5690                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
5691                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
5692            )
5693            .map_err(|error| error.to_string())?;
5694        for operation in &commit.ops {
5695            let Some(values) = &operation.values else {
5696                continue;
5697            };
5698            let Some(table) = self.schema.table(&operation.table) else {
5699                continue;
5700            };
5701            for column in table
5702                .columns
5703                .iter()
5704                .filter(|column| column.ty == ColumnType::BlobRef)
5705            {
5706                let Some(Value::String(raw)) = values.get(&column.name) else {
5707                    continue;
5708                };
5709                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
5710                    continue;
5711                };
5712                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
5713                    continue;
5714                };
5715                self.conn
5716                    .execute(
5717                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
5718                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
5719                        rusqlite::params![commit.client_commit_id, blob_id],
5720                    )
5721                    .map_err(|error| error.to_string())?;
5722            }
5723        }
5724        Ok(())
5725    }
5726
5727    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
5728        self.conn
5729            .execute(
5730                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
5731                rusqlite::params![client_commit_id],
5732            )
5733            .map_err(|error| error.to_string())?;
5734        self.conn
5735            .execute(
5736                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
5737                rusqlite::params![client_commit_id],
5738            )
5739            .map(|_| ())
5740            .map_err(|error| error.to_string())
5741    }
5742
5743    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
5744        match status {
5745            CommitOutcomeStatus::Applied => "applied",
5746            CommitOutcomeStatus::Cached => "cached",
5747            CommitOutcomeStatus::Conflict => "conflict",
5748            CommitOutcomeStatus::Rejected => "rejected",
5749        }
5750    }
5751
5752    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
5753        match resolution {
5754            CommitOutcomeResolution::Active => "active",
5755            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
5756            CommitOutcomeResolution::Superseded => "superseded",
5757            CommitOutcomeResolution::Dismissed => "dismissed",
5758        }
5759    }
5760
5761    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
5762        match value {
5763            "applied" => Ok(CommitOutcomeStatus::Applied),
5764            "cached" => Ok(CommitOutcomeStatus::Cached),
5765            "conflict" => Ok(CommitOutcomeStatus::Conflict),
5766            "rejected" => Ok(CommitOutcomeStatus::Rejected),
5767            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
5768        }
5769    }
5770
5771    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
5772        match value {
5773            "active" => Ok(CommitOutcomeResolution::Active),
5774            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
5775            "superseded" => Ok(CommitOutcomeResolution::Superseded),
5776            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
5777            _ => Err(format!(
5778                "invalid persisted commit outcome resolution {value:?}"
5779            )),
5780        }
5781    }
5782
5783    fn persist_commit_outcome(
5784        &self,
5785        client_commit_id: &str,
5786        status: CommitOutcomeStatus,
5787        results: &[CommitOperationOutcome],
5788        operations: Option<&[OutboxOp]>,
5789    ) -> Result<(), String> {
5790        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
5791        let operations_json = operations
5792            .map(|items| {
5793                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
5794            })
5795            .transpose()
5796            .map_err(|error| error.to_string())?;
5797        self.conn
5798            .execute(
5799                "INSERT INTO _syncular_commit_outcomes (
5800                   client_commit_id, status, recorded_at_ms, results_json,
5801                   operations_json, resolution
5802                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
5803                rusqlite::params![
5804                    client_commit_id,
5805                    Self::outcome_status_name(status),
5806                    self.clock_now_ms(),
5807                    results_json,
5808                    operations_json
5809                ],
5810            )
5811            .map(|_| ())
5812            .map_err(|error| error.to_string())
5813    }
5814
5815    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
5816        let StoredCommitOutcomeRow {
5817            sequence,
5818            client_commit_id,
5819            status,
5820            recorded_at_ms,
5821            results_json,
5822            operations_json,
5823            resolution,
5824            resolved_at_ms,
5825            replacement_client_commit_id,
5826        } = row;
5827        Ok(CommitOutcome {
5828            sequence,
5829            client_commit_id,
5830            status: Self::parse_outcome_status(&status)?,
5831            recorded_at_ms,
5832            results: serde_json::from_str(&results_json)
5833                .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
5834            operations: operations_json
5835                .map(|value| {
5836                    serde_json::from_str(&value).map_err(|error| {
5837                        format!("invalid persisted commit outcome operations: {error}")
5838                    })
5839                })
5840                .transpose()?,
5841            resolution: Self::parse_outcome_resolution(&resolution)?,
5842            resolved_at_ms,
5843            replacement_client_commit_id,
5844        })
5845    }
5846
5847    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
5848        let row = self
5849            .conn
5850            .query_row(
5851                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
5852                        resolution, resolved_at_ms, replacement_client_commit_id
5853                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
5854                rusqlite::params![client_commit_id],
5855                |row| {
5856                    Ok(StoredCommitOutcomeRow {
5857                        sequence: row.get(0)?,
5858                        client_commit_id: row.get(1)?,
5859                        status: row.get(2)?,
5860                        recorded_at_ms: row.get(3)?,
5861                        results_json: row.get(4)?,
5862                        operations_json: row.get(5)?,
5863                        resolution: row.get(6)?,
5864                        resolved_at_ms: row.get(7)?,
5865                        replacement_client_commit_id: row.get(8)?,
5866                    })
5867                },
5868            )
5869            .optional()
5870            .map_err(|error| error.to_string())?;
5871        row.map(Self::outcome_from_row).transpose()
5872    }
5873
5874    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
5875        if query.limit == Some(0) {
5876            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
5877        }
5878        let mut sql = String::from(
5879            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
5880                    resolution, resolved_at_ms, replacement_client_commit_id
5881               FROM _syncular_commit_outcomes",
5882        );
5883        if query.active_only {
5884            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
5885        }
5886        sql.push_str(" ORDER BY seq DESC");
5887        if let Some(limit) = query.limit {
5888            sql.push_str(&format!(" LIMIT {limit}"));
5889        }
5890        let mut stmt = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
5891        let rows = stmt
5892            .query_map([], |row| {
5893                Ok(StoredCommitOutcomeRow {
5894                    sequence: row.get(0)?,
5895                    client_commit_id: row.get(1)?,
5896                    status: row.get(2)?,
5897                    recorded_at_ms: row.get(3)?,
5898                    results_json: row.get(4)?,
5899                    operations_json: row.get(5)?,
5900                    resolution: row.get(6)?,
5901                    resolved_at_ms: row.get(7)?,
5902                    replacement_client_commit_id: row.get(8)?,
5903                })
5904            })
5905            .map_err(|error| error.to_string())?;
5906        let mut outcomes = Vec::new();
5907        for row in rows {
5908            outcomes.push(Self::outcome_from_row(
5909                row.map_err(|error| error.to_string())?,
5910            )?);
5911        }
5912        Ok(outcomes)
5913    }
5914
5915    fn prune_commit_outcomes(&self) -> Result<(), String> {
5916        #[cfg(test)]
5917        self.outcome_prune_count
5918            .set(self.outcome_prune_count.get() + 1);
5919        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
5920        let count = self
5921            .conn
5922            .query_row(
5923                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
5924                [],
5925                |row| row.get::<_, i64>(0),
5926            )
5927            .map_err(|error| error.to_string())? as usize;
5928        let excess = count.saturating_sub(max_entries);
5929        if excess == 0 {
5930            return Ok(());
5931        }
5932        let mut stmt = self
5933            .conn
5934            .prepare(
5935                "SELECT seq FROM _syncular_commit_outcomes
5936                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
5937                  ORDER BY seq ASC LIMIT ?1",
5938            )
5939            .map_err(|error| error.to_string())?;
5940        let rows = stmt
5941            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
5942            .map_err(|error| error.to_string())?;
5943        let sequences = rows
5944            .collect::<Result<Vec<_>, _>>()
5945            .map_err(|error| error.to_string())?;
5946        drop(stmt);
5947        for sequence in sequences {
5948            self.conn
5949                .execute(
5950                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
5951                    rusqlite::params![sequence],
5952                )
5953                .map_err(|error| error.to_string())?;
5954        }
5955        Ok(())
5956    }
5957
5958    // -- driver surface ---------------------------------------------------------
5959
5960    pub fn subscribe(
5961        &mut self,
5962        id: String,
5963        table: String,
5964        scopes: Vec<(String, Vec<String>)>,
5965        params: Option<String>,
5966    ) -> Result<(), String> {
5967        if self.schema.table(&table).is_none() {
5968            return Err(format!("unknown table {table:?}"));
5969        }
5970        if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
5971            let same_intent = existing.table == table
5972                && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
5973                && existing.params == params;
5974            if same_intent {
5975                return Ok(());
5976            }
5977            return Err(
5978                "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
5979                    .to_owned(),
5980            );
5981        }
5982        let sub = Subscription {
5983            id: id.clone(),
5984            table,
5985            requested: scopes,
5986            params,
5987            cursor: -1,
5988            bootstrap_state: None,
5989            state: SubState::Active,
5990            reason_code: None,
5991            effective: None,
5992            synced_once: false,
5993        };
5994        self.persist_sub(&sub)?;
5995        self.subs.push(sub);
5996        Ok(())
5997    }
5998
5999    pub fn unsubscribe(&mut self, id: &str) {
6000        self.subs.retain(|s| s.id != id);
6001        let _ = self.conn.execute(
6002            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
6003            rusqlite::params![id],
6004        );
6005    }
6006
6007    // -- windowed subscriptions (§4.8) ------------------------------------------
6008
6009    /// §4.8: set the live window units for a base — a value-sharded family
6010    /// of subscriptions, one per unit. Added units get fresh subscriptions
6011    /// (image-lane bootstrap on the next sync); removed units are
6012    /// unsubscribed and evicted, fused in one local transaction (E1–E4).
6013    /// Idempotent; re-entry cancels any deferred eviction.
6014    pub fn set_window(
6015        &mut self,
6016        base: &WindowBase,
6017        units: &[String],
6018    ) -> Result<CommandEffects, String> {
6019        let table = self
6020            .schema
6021            .table(&base.table)
6022            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
6023        if table.scope_column(&base.variable).is_none() {
6024            return Err(format!(
6025                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
6026                base.table, base.variable
6027            ));
6028        }
6029        let base_key = window_base_key(base);
6030        let wanted: std::collections::HashSet<&String> = units.iter().collect();
6031        let live = self.load_window_units(&base_key);
6032        self.begin_observation("syncular_window")?;
6033        let mut batch = ChangeAccumulator::default();
6034        let mut changed = false;
6035
6036        // Widen: units wanted but not live → fresh subscription + registry row.
6037        for unit in units {
6038            if live.iter().any(|(u, _)| u == unit) {
6039                continue;
6040            }
6041            let sub_id = derive_sub_id(base, unit);
6042            self.delete_pending_evict(&sub_id);
6043            self.insert_window_unit(&base_key, unit, &sub_id);
6044            self.subscribe(
6045                sub_id,
6046                base.table.clone(),
6047                unit_scopes(base, unit),
6048                base.params.clone(),
6049            )?;
6050            batch.window(&base_key, &base.table, unit);
6051            changed = true;
6052        }
6053
6054        // Shrink: units live but not wanted → unsubscribe fused with eviction.
6055        for (unit, sub_id) in live {
6056            if wanted.contains(&unit) {
6057                continue;
6058            }
6059            let effective = self
6060                .subs
6061                .iter()
6062                .find(|sub| sub.id == sub_id)
6063                .and_then(|sub| sub.effective.clone())
6064                .unwrap_or_else(|| unit_scopes(base, &unit));
6065            self.record_scope_map(&mut batch, &base.table, &effective);
6066            batch.window(&base_key, &base.table, &unit);
6067            self.evict_unit(&base_key, base, &unit, &sub_id);
6068            changed = true;
6069        }
6070        if let Err(error) = self.finish_observation("syncular_window", batch) {
6071            self.rollback_observation("syncular_window");
6072            return Err(error);
6073        }
6074        Ok(if changed {
6075            CommandEffects::interactive()
6076        } else {
6077            CommandEffects::none()
6078        })
6079    }
6080
6081    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
6082    /// the subset still bootstrap-pending. Registration alone is not
6083    /// completeness — a unit is pending until its subscription completes a
6084    /// bootstrap round (cursor advances past -1 with no resume token held).
6085    pub fn window_state(&self, base: &WindowBase) -> WindowState {
6086        let mut units = Vec::new();
6087        let mut pending = Vec::new();
6088        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
6089            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
6090                Some(sub) => {
6091                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
6092                }
6093                None => true,
6094            };
6095            if is_pending {
6096                pending.push(unit.clone());
6097            }
6098            units.push(unit);
6099        }
6100        WindowState { units, pending }
6101    }
6102
6103    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
6104        let mut stmt = match self
6105            .conn
6106            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
6107        {
6108            Ok(stmt) => stmt,
6109            Err(_) => return Vec::new(),
6110        };
6111        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
6112            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6113        });
6114        match rows {
6115            Ok(rows) => rows.filter_map(Result::ok).collect(),
6116            Err(_) => Vec::new(),
6117        }
6118    }
6119
6120    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
6121        let mut stmt = match self.conn.prepare(
6122            "SELECT windows.base, windows.unit, subscriptions.tbl
6123               FROM _syncular_windows AS windows
6124               JOIN _syncular_subscriptions AS subscriptions
6125                 ON subscriptions.id = windows.sub_id
6126               ORDER BY windows.base, windows.unit",
6127        ) {
6128            Ok(stmt) => stmt,
6129            Err(_) => return Vec::new(),
6130        };
6131        let rows = stmt.query_map([], |row| {
6132            Ok((
6133                row.get::<_, String>(0)?,
6134                row.get::<_, String>(1)?,
6135                row.get::<_, String>(2)?,
6136            ))
6137        });
6138        match rows {
6139            Ok(rows) => rows.filter_map(Result::ok).collect(),
6140            Err(_) => Vec::new(),
6141        }
6142    }
6143
6144    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
6145        self.conn
6146            .query_row(
6147                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
6148                rusqlite::params![sub_id],
6149                |row| Ok((row.get(0)?, row.get(1)?)),
6150            )
6151            .ok()
6152    }
6153
6154    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) {
6155        let _ = self.conn.execute(
6156            "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
6157            rusqlite::params![base_key, unit, sub_id],
6158        );
6159    }
6160
6161    fn delete_window_unit(&self, base_key: &str, unit: &str) {
6162        let _ = self.conn.execute(
6163            "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
6164            rusqlite::params![base_key, unit],
6165        );
6166    }
6167
6168    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
6169    /// Deletes the unit's rows except those pinned by a pending outbox
6170    /// commit (E1); records a deferred eviction if any pin remains; discards
6171    /// the subscription's cursor/resume/effective-echo (E3) and version
6172    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
6173    /// nothing.
6174    fn evict_unit(&mut self, base_key: &str, base: &WindowBase, unit: &str, sub_id: &str) {
6175        let effective = self
6176            .subs
6177            .iter()
6178            .find(|s| s.id == sub_id)
6179            .and_then(|s| s.effective.clone())
6180            .unwrap_or_else(|| unit_scopes(base, unit));
6181        let pinned = self.pinned_row_ids(&base.table);
6182        let deferred = self
6183            .evict_scope_rows(&base.table, &effective, &pinned)
6184            .unwrap_or(false);
6185        self.delete_window_unit(base_key, unit);
6186        self.unsubscribe(sub_id);
6187        if deferred {
6188            self.save_pending_evict(sub_id, &base.table, &effective);
6189        } else {
6190            self.delete_pending_evict(sub_id);
6191        }
6192        self.rebuild_overlay();
6193    }
6194
6195    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
6196    /// primary keys; returns `Ok(true)` iff a pinned row was left behind (so
6197    /// the eviction must be deferred). `Err(())` = fail-closed (no mapping).
6198    fn evict_scope_rows(
6199        &mut self,
6200        table_name: &str,
6201        effective: &[(String, Vec<String>)],
6202        pinned: &std::collections::HashSet<String>,
6203    ) -> Result<bool, ()> {
6204        if effective.is_empty() {
6205            return Ok(false);
6206        }
6207        let table = self.schema.table(table_name).ok_or(())?.clone();
6208        let mut clauses = Vec::new();
6209        let mut params: Vec<SqlValue> = Vec::new();
6210        for (variable, values) in effective {
6211            let column = table.scope_column(variable).ok_or(())?;
6212            let placeholders: Vec<String> = values
6213                .iter()
6214                .map(|v| {
6215                    params.push(SqlValue::Text(v.clone()));
6216                    "?".to_owned()
6217                })
6218                .collect();
6219            clauses.push(format!(
6220                "{} IN ({})",
6221                quote_ident(column),
6222                placeholders.join(", ")
6223            ));
6224        }
6225        let mut sql = format!(
6226            "DELETE FROM {} WHERE {}",
6227            base_table(table_name),
6228            clauses.join(" AND ")
6229        );
6230        if !pinned.is_empty() {
6231            let pk = quote_ident(&table.primary_key);
6232            let holes: Vec<String> = pinned
6233                .iter()
6234                .map(|id| {
6235                    params.push(SqlValue::Text(id.clone()));
6236                    "?".to_owned()
6237                })
6238                .collect();
6239            sql.push_str(&format!(" AND {} NOT IN ({})", pk, holes.join(", ")));
6240        }
6241        self.overlay_dirty.set(true);
6242        self.conn
6243            .execute(&sql, rusqlite::params_from_iter(params))
6244            .map_err(|_| ())?;
6245        if pinned.is_empty() {
6246            return Ok(false);
6247        }
6248        // A pin defers the eviction only if a pinned row actually falls
6249        // inside this unit's effective scopes — re-select the survivors.
6250        let mut where_params: Vec<SqlValue> = Vec::new();
6251        let mut where_clauses = Vec::new();
6252        for (variable, values) in effective {
6253            let column = table.scope_column(variable).ok_or(())?;
6254            let placeholders: Vec<String> = values
6255                .iter()
6256                .map(|v| {
6257                    where_params.push(SqlValue::Text(v.clone()));
6258                    "?".to_owned()
6259                })
6260                .collect();
6261            where_clauses.push(format!(
6262                "{} IN ({})",
6263                quote_ident(column),
6264                placeholders.join(", ")
6265            ));
6266        }
6267        let pk = quote_ident(&table.primary_key);
6268        let select = format!(
6269            "SELECT {} FROM {} WHERE {}",
6270            pk,
6271            base_table(table_name),
6272            where_clauses.join(" AND ")
6273        );
6274        let mut stmt = self.conn.prepare(&select).map_err(|_| ())?;
6275        let survivors: Vec<String> = stmt
6276            .query_map(rusqlite::params_from_iter(where_params), |row| {
6277                row.get::<_, String>(0)
6278            })
6279            .map_err(|_| ())?
6280            .filter_map(Result::ok)
6281            .collect();
6282        Ok(survivors.iter().any(|id| pinned.contains(id)))
6283    }
6284
6285    /// §4.8 E1: retry deferred evictions after the outbox drains. No
6286    /// pending records (the common case) means nothing to retry — and no
6287    /// overlay rebuild.
6288    fn drain_pending_evictions(&mut self) {
6289        let pending = self.load_pending_evictions();
6290        if pending.is_empty() {
6291            return;
6292        }
6293        for (sub_id, table_name, effective) in pending {
6294            if self.schema.table(&table_name).is_none() {
6295                self.delete_pending_evict(&sub_id);
6296                continue;
6297            }
6298            let pinned = self.pinned_row_ids(&table_name);
6299            let deferred = self
6300                .evict_scope_rows(&table_name, &effective, &pinned)
6301                .unwrap_or(false);
6302            if !deferred {
6303                self.delete_pending_evict(&sub_id);
6304            }
6305        }
6306        self.rebuild_overlay_if_dirty();
6307    }
6308
6309    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
6310    /// commit — rows that MUST NOT be evicted until the commit drains.
6311    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
6312        let mut pinned = std::collections::HashSet::new();
6313        for commit in &self.outbox {
6314            for op in &commit.ops {
6315                if op.table == table {
6316                    pinned.insert(op.row_id.clone());
6317                }
6318            }
6319        }
6320        pinned
6321    }
6322
6323    fn save_pending_evict(&self, sub_id: &str, table: &str, effective: &[(String, Vec<String>)]) {
6324        let _ = self.conn.execute(
6325            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes)
6326               VALUES (?1, ?2, ?3)",
6327            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
6328        );
6329    }
6330
6331    fn delete_pending_evict(&self, sub_id: &str) {
6332        let _ = self.conn.execute(
6333            "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
6334            rusqlite::params![sub_id],
6335        );
6336    }
6337
6338    fn load_pending_evictions(&self) -> Vec<PendingEvict> {
6339        let mut stmt = match self
6340            .conn
6341            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
6342        {
6343            Ok(stmt) => stmt,
6344            Err(_) => return Vec::new(),
6345        };
6346        let rows = stmt.query_map([], |row| {
6347            Ok((
6348                row.get::<_, String>(0)?,
6349                row.get::<_, String>(1)?,
6350                row.get::<_, String>(2)?,
6351            ))
6352        });
6353        let mut out = Vec::new();
6354        if let Ok(rows) = rows {
6355            for entry in rows.filter_map(Result::ok) {
6356                let (sub_id, table, json) = entry;
6357                if let Ok(value) = serde_json::from_str::<Value>(&json) {
6358                    if let Ok(effective) = json_to_scope_map(&value) {
6359                        out.push((sub_id, table, effective));
6360                    }
6361                }
6362            }
6363        }
6364        out
6365    }
6366
6367    /// Record one atomic local commit (§7.1) and apply it optimistically.
6368    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
6369        if mutations.is_empty() {
6370            return Err("a commit must contain at least one operation (§6.1)".to_owned());
6371        }
6372        let mut ops = Vec::with_capacity(mutations.len());
6373        for mutation in mutations {
6374            match mutation {
6375                Mutation::Upsert {
6376                    table,
6377                    values,
6378                    base_version,
6379                } => {
6380                    let schema_table = self
6381                        .schema
6382                        .table(&table)
6383                        .ok_or_else(|| format!("unknown table {table:?}"))?;
6384                    // §5: value keys are accepted in snake_case AND the
6385                    // generated row types' camelCase; normalize to SQL truth
6386                    // before the pk lookup / codec see them.
6387                    let values = normalize_values_casing(schema_table, values)?;
6388                    let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
6389                    // Validate the payload encodes with the current codec
6390                    // (and, §5.11, that the encrypt seam has its keys).
6391                    encode_row_json(schema_table, &row_id, &values, &self.encryption)?;
6392                    ops.push(OutboxOp {
6393                        upsert: true,
6394                        table,
6395                        row_id,
6396                        base_version,
6397                        values: Some(values),
6398                        changed_fields: None,
6399                    });
6400                }
6401                Mutation::Delete {
6402                    table,
6403                    row_id,
6404                    base_version,
6405                } => {
6406                    if self.schema.table(&table).is_none() {
6407                        return Err(format!("unknown table {table:?}"));
6408                    }
6409                    ops.push(OutboxOp {
6410                        upsert: false,
6411                        table,
6412                        row_id,
6413                        base_version,
6414                        values: None,
6415                        changed_fields: None,
6416                    });
6417                }
6418            }
6419        }
6420        self.record_outbox_commit(ops)
6421    }
6422
6423    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
6424        let commit = OutboxCommit {
6425            client_commit_id: uuid::Uuid::new_v4().to_string(),
6426            ops,
6427        };
6428        self.begin_observation("syncular_mutation")?;
6429        let mut batch = ChangeAccumulator::default();
6430        for op in &commit.ops {
6431            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
6432            if let Some(values) = &op.values {
6433                if let Some(table) = self.schema.table(&op.table) {
6434                    for scope in &table.scope_variables {
6435                        if let Some(Value::String(value)) = values.get(&scope.column) {
6436                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
6437                            precise = true;
6438                        }
6439                    }
6440                }
6441            }
6442            if !precise {
6443                batch.table(&op.table);
6444            }
6445        }
6446        if let Err(error) = self.persist_outbox_insert(&commit) {
6447            self.rollback_observation("syncular_mutation");
6448            return Err(error);
6449        }
6450        let was_dirty = self.overlay_dirty.get();
6451        // A clean overlay already contains the FIFO fold of every previous
6452        // commit. Appending a commit only needs its own operations applied.
6453        if !was_dirty {
6454            self.apply_outbox_ops(&commit.ops);
6455        }
6456        let id = commit.client_commit_id.clone();
6457        self.outbox.push(commit);
6458        if was_dirty {
6459            self.rebuild_overlay();
6460        }
6461        batch.status = true;
6462        if let Err(error) = self.finish_observation("syncular_mutation", batch) {
6463            self.rollback_observation("syncular_mutation");
6464            self.outbox.pop();
6465            self.overlay_dirty.set(was_dirty);
6466            return Err(error);
6467        }
6468        Ok(id)
6469    }
6470
6471    /// Merge a partial update over the current visible local row, then record
6472    /// the ordinary full-row upsert. This keeps patch semantics identical
6473    /// across the TypeScript and native cores without weakening the wire's
6474    /// full-row invariant.
6475    pub fn patch(
6476        &mut self,
6477        table: &str,
6478        row_id: &str,
6479        partial: Map<String, Value>,
6480        base_version: Option<i64>,
6481    ) -> Result<String, String> {
6482        let schema_table = self
6483            .schema
6484            .table(table)
6485            .ok_or_else(|| format!("unknown table {table:?}"))?;
6486        let partial = normalize_values_casing(schema_table, partial)?;
6487        let mut values = self
6488            .read_rows(table)?
6489            .into_iter()
6490            .find(|row| row.row_id == row_id)
6491            .map(|row| row.values)
6492            .ok_or_else(|| {
6493                format!(
6494                    "sync.invalid_request: table {table:?} has no local row with primary key {row_id:?} to patch"
6495                )
6496            })?;
6497        let mut changed_fields = partial.keys().cloned().collect::<Vec<_>>();
6498        changed_fields.sort();
6499        values.extend(partial);
6500        let row_id_from_values = render_row_id_json(values.get(&schema_table.primary_key))?;
6501        if row_id_from_values != row_id {
6502            return Err("sync.invalid_request: patch cannot change the primary key".to_owned());
6503        }
6504        encode_row_json(schema_table, row_id, &values, &self.encryption)?;
6505        self.record_outbox_commit(vec![OutboxOp {
6506            upsert: true,
6507            table: table.to_owned(),
6508            row_id: row_id.to_owned(),
6509            base_version,
6510            values: Some(values),
6511            changed_fields: Some(changed_fields),
6512        }])
6513    }
6514
6515    pub fn pending_commit_ids(&self) -> Vec<String> {
6516        self.outbox
6517            .iter()
6518            .map(|c| c.client_commit_id.clone())
6519            .collect()
6520    }
6521
6522    pub fn conflicts(&self) -> &[ConflictRecord] {
6523        &self.conflicts
6524    }
6525
6526    pub fn rejections(&self) -> &[RejectionRecord] {
6527        &self.rejections
6528    }
6529
6530    pub fn resolve_commit_outcome(
6531        &mut self,
6532        input: ResolveCommitOutcomeInput,
6533    ) -> Result<CommitOutcome, String> {
6534        let current = self
6535            .commit_outcome(&input.client_commit_id)?
6536            .ok_or_else(|| {
6537                format!(
6538                    "sync.outcome_not_found: no durable outcome exists for {:?}",
6539                    input.client_commit_id
6540                )
6541            })?;
6542        if current.resolution != CommitOutcomeResolution::Active {
6543            return Ok(current);
6544        }
6545        if input.resolution == CommitOutcomeResolution::Active {
6546            return Err("sync.invalid_request: resolution must leave active state".to_owned());
6547        }
6548        match input.resolution {
6549            CommitOutcomeResolution::Superseded => {
6550                let replacement = input
6551                    .replacement_client_commit_id
6552                    .as_deref()
6553                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
6554                    .ok_or_else(|| {
6555                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
6556                            .to_owned()
6557                    })?;
6558                let _ = replacement;
6559            }
6560            _ if input.replacement_client_commit_id.is_some() => {
6561                return Err(
6562                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
6563                        .to_owned(),
6564                );
6565            }
6566            _ => {}
6567        }
6568        let allowed = match current.status {
6569            CommitOutcomeStatus::Conflict => matches!(
6570                input.resolution,
6571                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
6572            ),
6573            CommitOutcomeStatus::Rejected => {
6574                input.resolution == CommitOutcomeResolution::Superseded
6575            }
6576            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
6577                input.resolution == CommitOutcomeResolution::Dismissed
6578            }
6579        };
6580        if !allowed {
6581            return Err(format!(
6582                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
6583                input.resolution, current.status
6584            ));
6585        }
6586
6587        self.begin_observation("syncular_outcome_resolution")?;
6588        let result = (|| {
6589            self.conn
6590                .execute(
6591                    "UPDATE _syncular_commit_outcomes
6592                        SET resolution = ?1, resolved_at_ms = ?2,
6593                            replacement_client_commit_id = ?3
6594                      WHERE client_commit_id = ?4 AND resolution = 'active'",
6595                    rusqlite::params![
6596                        Self::outcome_resolution_name(input.resolution),
6597                        self.clock_now_ms(),
6598                        input.replacement_client_commit_id,
6599                        input.client_commit_id
6600                    ],
6601                )
6602                .map_err(|error| error.to_string())?;
6603            let resolved = self
6604                .commit_outcome(&current.client_commit_id)?
6605                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
6606            self.prune_commit_outcomes()?;
6607            Ok(resolved)
6608        })();
6609        let resolved = match result {
6610            Ok(outcome) => outcome,
6611            Err(error) => {
6612                self.rollback_observation("syncular_outcome_resolution");
6613                return Err(error);
6614            }
6615        };
6616        self.conflicts
6617            .retain(|record| record.client_commit_id != current.client_commit_id);
6618        self.rejections
6619            .retain(|record| record.client_commit_id != current.client_commit_id);
6620        let batch = ChangeAccumulator {
6621            conflicts: current.status == CommitOutcomeStatus::Conflict,
6622            rejections: current.status == CommitOutcomeStatus::Rejected,
6623            outcomes: true,
6624            ..ChangeAccumulator::default()
6625        };
6626        if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
6627            self.rollback_observation("syncular_outcome_resolution");
6628            return Err(error);
6629        }
6630        Ok(resolved)
6631    }
6632
6633    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
6634        self.schema_floor.as_ref()
6635    }
6636
6637    /// §7.3.5: the client's opaque auth-lease state, if any.
6638    pub fn lease_state(&self) -> Option<&LeaseState> {
6639        self.lease_state.as_ref()
6640    }
6641
6642    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
6643    /// the two lease codes set it; other errors leave leaseState untouched.
6644    fn record_lease_error(&mut self, code: &str) {
6645        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
6646            return;
6647        }
6648        let mut next = self.lease_state.clone().unwrap_or_default();
6649        next.error_code = Some(code.to_owned());
6650        self.set_lease_state(Some(next));
6651    }
6652
6653    fn set_lease_state(&mut self, next: Option<LeaseState>) {
6654        if self.lease_state == next {
6655            return;
6656        }
6657        if self.begin_observation("syncular_lease").is_err() {
6658            return;
6659        }
6660        self.lease_state = next;
6661        if let Some(lease) = &self.lease_state {
6662            if let Ok(json) = serde_json::to_string(lease) {
6663                self.set_meta(LEASE_STATE_KEY, &json);
6664            }
6665        } else {
6666            self.delete_meta(LEASE_STATE_KEY);
6667        }
6668        let batch = ChangeAccumulator {
6669            status: true,
6670            ..ChangeAccumulator::default()
6671        };
6672        if self.finish_observation("syncular_lease", batch).is_err() {
6673            self.rollback_observation("syncular_lease");
6674        }
6675    }
6676
6677    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
6678        if self.schema_floor == next {
6679            return;
6680        }
6681        if self.begin_observation("syncular_schema_floor").is_err() {
6682            return;
6683        }
6684        self.schema_floor = next;
6685        self.stopped = self.schema_floor.is_some();
6686        if let Some(floor) = &self.schema_floor {
6687            if let Ok(json) = serde_json::to_string(floor) {
6688                self.set_meta(SCHEMA_FLOOR_KEY, &json);
6689            }
6690        } else {
6691            self.delete_meta(SCHEMA_FLOOR_KEY);
6692        }
6693        let batch = ChangeAccumulator {
6694            status: true,
6695            ..ChangeAccumulator::default()
6696        };
6697        if self
6698            .finish_observation("syncular_schema_floor", batch)
6699            .is_err()
6700        {
6701            self.rollback_observation("syncular_schema_floor");
6702        }
6703    }
6704
6705    fn set_upgrading(&mut self, value: bool) {
6706        if self.upgrading == value {
6707            return;
6708        }
6709        if self.begin_observation("syncular_upgrading").is_err() {
6710            return;
6711        }
6712        self.upgrading = value;
6713        let batch = ChangeAccumulator {
6714            status: true,
6715            ..ChangeAccumulator::default()
6716        };
6717        if self
6718            .finish_observation("syncular_upgrading", batch)
6719            .is_err()
6720        {
6721            self.rollback_observation("syncular_upgrading");
6722        }
6723    }
6724
6725    pub fn sync_needed(&self) -> bool {
6726        self.sync_needed
6727    }
6728
6729    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
6730        let sub = self.subs.iter().find(|s| s.id == id)?;
6731        Some(SubscriptionStateView {
6732            id: sub.id.clone(),
6733            table: sub.table.clone(),
6734            status: sub.state.name().to_owned(),
6735            cursor: sub.cursor,
6736            has_resume_token: sub.bootstrap_state.is_some(),
6737            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
6738            reason_code: sub.reason_code.clone(),
6739        })
6740    }
6741
6742    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
6743        let schema_table = self
6744            .schema
6745            .table(table)
6746            .ok_or_else(|| format!("unknown table {table:?}"))?;
6747        let sql = format!(
6748            "SELECT * FROM {} ORDER BY {} ASC",
6749            visible_table(table),
6750            quote_ident(&schema_table.primary_key)
6751        );
6752        let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?;
6753        let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
6754        let mut out = Vec::new();
6755        while let Some(row) = rows.next().map_err(|e| e.to_string())? {
6756            let mut values = Map::new();
6757            for (i, column) in schema_table.columns.iter().enumerate() {
6758                let value = row.get_ref(i).map_err(|e| e.to_string())?;
6759                values.insert(column.name.clone(), sql_ref_to_json(column, value));
6760            }
6761            let version: i64 = row
6762                .get(schema_table.columns.len())
6763                .map_err(|e| e.to_string())?;
6764            let row_id = match values.get(&schema_table.primary_key) {
6765                Some(Value::String(s)) => s.clone(),
6766                Some(Value::Number(n)) => n.to_string(),
6767                Some(Value::Bool(b)) => b.to_string(),
6768                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
6769            };
6770            out.push(RowState {
6771                row_id,
6772                version,
6773                values,
6774            });
6775        }
6776        Ok(out)
6777    }
6778
6779    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
6780    //
6781    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
6782    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
6783    // the op with `yrs`, re-encodes the whole doc state, and pushes it as a
6784    // baseVersion-less upsert through the ordinary `mutate` path (§5.10.3
6785    // "crdt-only divergence merges cleanly"). No local merge — merging is
6786    // server-side; the overlay's last-write-wins re-materializes the edit
6787    // immediately (optimistic apply, §7.1) and the server-merged bytes arrive
6788    // on the next pull, idempotently. Byte-compatible with `@syncular/crdt-yjs`.
6789
6790    /// The current stored value of a `crdt` column for one row — the visible
6791    /// (optimistic) bytes, or `None` when the row is absent or the column is
6792    /// NULL (the empty document, §5.10.1). Errors if the column is not a
6793    /// `crdt` column (guards the app against a typo'd column name).
6794    #[cfg(feature = "crdt-yjs")]
6795    fn crdt_column_bytes(
6796        &self,
6797        table: &str,
6798        row_id: &str,
6799        column: &str,
6800    ) -> Result<Option<Vec<u8>>, String> {
6801        let schema_table = self
6802            .schema
6803            .table(table)
6804            .ok_or_else(|| format!("unknown table {table:?}"))?;
6805        let col = schema_table
6806            .columns
6807            .iter()
6808            .find(|c| c.name == column)
6809            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
6810        if col.ty != ColumnType::Crdt {
6811            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
6812        }
6813        let sql = format!(
6814            "SELECT {} FROM {} WHERE {}",
6815            quote_ident(column),
6816            visible_table(table),
6817            row_id_predicate(schema_table)
6818        );
6819        let bytes: Option<Vec<u8>> = self
6820            .conn
6821            .prepare_cached(&sql)
6822            .map_err(|error| error.to_string())?
6823            .query_row(rusqlite::params![row_id], |row| {
6824                row.get::<_, Option<Vec<u8>>>(0)
6825            })
6826            .map_err(|e| match e {
6827                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
6828                other => other.to_string(),
6829            })?;
6830        Ok(bytes)
6831    }
6832
6833    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
6834    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
6835    /// An absent row / NULL column is the empty document (empty string).
6836    #[cfg(feature = "crdt-yjs")]
6837    pub fn crdt_text(
6838        &self,
6839        table: &str,
6840        row_id: &str,
6841        column: &str,
6842        name: &str,
6843    ) -> Result<String, String> {
6844        let bytes = self
6845            .crdt_column_bytes(table, row_id, column)?
6846            .unwrap_or_default();
6847        crate::crdt::text(&bytes, name)
6848    }
6849
6850    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and push
6851    /// the resulting full-state update through the normal (baseVersion-less)
6852    /// mutate path. Returns the enqueued `clientCommitId`.
6853    #[cfg(feature = "crdt-yjs")]
6854    pub fn crdt_insert_text(
6855        &mut self,
6856        table: &str,
6857        row_id: &str,
6858        column: &str,
6859        name: &str,
6860        index: u32,
6861        value: &str,
6862    ) -> Result<String, String> {
6863        let current = self
6864            .crdt_column_bytes(table, row_id, column)?
6865            .unwrap_or_default();
6866        let update = crate::crdt::insert_text(&current, name, index, value)?;
6867        self.crdt_push_update(table, row_id, column, &update)
6868    }
6869
6870    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and push
6871    /// the resulting full-state update. Returns the enqueued `clientCommitId`.
6872    #[cfg(feature = "crdt-yjs")]
6873    pub fn crdt_delete_text(
6874        &mut self,
6875        table: &str,
6876        row_id: &str,
6877        column: &str,
6878        name: &str,
6879        index: u32,
6880        len: u32,
6881    ) -> Result<String, String> {
6882        let current = self
6883            .crdt_column_bytes(table, row_id, column)?
6884            .unwrap_or_default();
6885        let update = crate::crdt::delete_text(&current, name, index, len)?;
6886        self.crdt_push_update(table, row_id, column, &update)
6887    }
6888
6889    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
6890    /// `crdt` column's current state and push the resulting full state. The
6891    /// app authored the update with its own `yrs` model. Returns the enqueued
6892    /// `clientCommitId`.
6893    #[cfg(feature = "crdt-yjs")]
6894    pub fn crdt_apply_update(
6895        &mut self,
6896        table: &str,
6897        row_id: &str,
6898        column: &str,
6899        update: &[u8],
6900    ) -> Result<String, String> {
6901        let current = self
6902            .crdt_column_bytes(table, row_id, column)?
6903            .unwrap_or_default();
6904        let next = crate::crdt::apply_update(&current, update)?;
6905        self.crdt_push_update(table, row_id, column, &next)
6906    }
6907
6908    /// Shared tail of the crdt edit methods: build the full-row upsert that
6909    /// carries the new crdt bytes and enqueue it. The row's other columns are
6910    /// preserved from the current visible row (so a crdt edit does not clobber
6911    /// the LWW columns); a brand-new row is seeded with just the primary key +
6912    /// crdt column. Pushed baseVersion-less (§5.10.3 crdt-only-divergence rule).
6913    #[cfg(feature = "crdt-yjs")]
6914    fn crdt_push_update(
6915        &mut self,
6916        table: &str,
6917        row_id: &str,
6918        column: &str,
6919        crdt_bytes: &[u8],
6920    ) -> Result<String, String> {
6921        let schema_table = self
6922            .schema
6923            .table(table)
6924            .ok_or_else(|| format!("unknown table {table:?}"))?
6925            .clone();
6926        // The current visible row's values (preserving LWW columns), or a
6927        // fresh row keyed by row_id if it does not exist yet.
6928        let mut values: Map<String, Value> = self
6929            .read_rows(table)?
6930            .into_iter()
6931            .find(|r| r.row_id == row_id)
6932            .map(|r| r.values)
6933            .unwrap_or_else(|| {
6934                let mut map = Map::new();
6935                map.insert(
6936                    schema_table.primary_key.clone(),
6937                    Value::from(row_id.to_owned()),
6938                );
6939                map
6940            });
6941        // Replace the crdt column with the new bytes in the driver envelope.
6942        let mut bytes_obj = Map::new();
6943        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
6944        values.insert(column.to_owned(), Value::Object(bytes_obj));
6945        self.mutate(vec![Mutation::Upsert {
6946            table: table.to_owned(),
6947            values,
6948            base_version: None,
6949        }])
6950    }
6951
6952    /// Run an arbitrary read-only SQL query against the local database and
6953    /// return each row as a `column-name → JSON value` map. This is the seam
6954    /// the React `useSyncQuery` live-query API needs (it takes app-authored
6955    /// SQL over the visible tables/views, not a fixed table read like
6956    /// [`read_rows`]).
6957    ///
6958    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
6959    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
6960    /// same envelope the command surface uses everywhere else. Output BLOB
6961    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
6962    ///
6963    /// The result column typing is dynamic (SQLite's stored affinity), because
6964    /// arbitrary SQL can alias, join, and compute — there is no schema column
6965    /// to consult per output cell, unlike [`read_rows`].
6966    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
6967        query_connection(&self.conn, sql, params)
6968    }
6969
6970    /// Repository benchmark and conformance access, present only with `bench-internals`.
6971    #[cfg(feature = "bench-internals")]
6972    #[doc(hidden)]
6973    pub fn benchmark_connection(&mut self) -> &mut Connection {
6974        &mut self.conn
6975    }
6976
6977    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
6978    #[cfg(feature = "bench-internals")]
6979    #[doc(hidden)]
6980    pub fn benchmark_phases(
6981        &self,
6982        enabled: Option<bool>,
6983        reset: bool,
6984    ) -> Result<Option<Value>, String> {
6985        self.benchmark_phases.configure(enabled, reset)?;
6986        Ok(self.benchmark_phases.snapshot())
6987    }
6988
6989    /// Rows, coverage, and local revision from one SQLite read snapshot.
6990    pub fn query_snapshot(
6991        &mut self,
6992        sql: &str,
6993        params: &[QueryValue],
6994        coverage: &[WindowCoverage],
6995    ) -> Result<QuerySnapshot, String> {
6996        snapshot_connection(&self.conn, sql, params, coverage)
6997    }
6998
6999    // -- request building ---------------------------------------------------------
7000
7001    fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
7002        #[cfg(feature = "bench-internals")]
7003        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
7004        let log_epoch = self.get_meta(LOG_EPOCH_KEY);
7005        let mut frames = vec![Frame::ReqHeader {
7006            client_id: self.client_id.clone(),
7007            schema_version: self.schema.version,
7008            log_epoch: log_epoch.clone(),
7009        }];
7010        let mut pushed_ids = Vec::new();
7011        let mut ops_in_request = 0usize;
7012        let mut deferred_commits = 0usize;
7013        #[cfg(feature = "bench-internals")]
7014        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
7015        for (index, commit) in self
7016            .outbox
7017            .iter()
7018            .take(if log_epoch.is_some() {
7019                self.outbox.len()
7020            } else {
7021                0
7022            })
7023            .enumerate()
7024        {
7025            // §6.1 splitBatch: stop at the operation cap — commits apply in
7026            // order, so everything from the first non-fitting commit on is
7027            // deferred to the next round. A single over-cap commit still goes
7028            // alone (commits are atomic and cannot be split).
7029            if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
7030                deferred_commits = self.outbox.len() - index;
7031                break;
7032            }
7033            ops_in_request += commit.ops.len();
7034            let operations = commit
7035                .ops
7036                .iter()
7037                .map(|op| {
7038                    let payload = op.values.as_ref().and_then(|values| {
7039                        let table = self.schema.table(&op.table)?;
7040                        // §0: outbox entries encode at send time with the
7041                        // current codec (validated at mutate()). §5.11:
7042                        // encrypted columns are encrypted here.
7043                        encode_row_json(table, &op.row_id, values, &self.encryption).ok()
7044                    });
7045                    ssp2::model::Operation {
7046                        table: op.table.clone(),
7047                        row_id: op.row_id.clone(),
7048                        op: if op.upsert { Op::Upsert } else { Op::Delete },
7049                        base_version: op.base_version,
7050                        payload,
7051                    }
7052                })
7053                .collect();
7054            frames.push(Frame::PushCommit {
7055                client_commit_id: commit.client_commit_id.clone(),
7056                operations,
7057            });
7058            pushed_ids.push(commit.client_commit_id.clone());
7059        }
7060        #[cfg(feature = "bench-internals")]
7061        drop(outbox_phase);
7062        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
7063        // bare URL — capability negotiation, decided per transport.
7064        let accept = self.limits.accept.unwrap_or(if url_capable {
7065            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
7066        } else {
7067            DEFAULT_ACCEPT
7068        });
7069        frames.push(Frame::PullHeader {
7070            limit_commits: self.limits.limit_commits.unwrap_or(0),
7071            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
7072            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
7073            accept,
7074        });
7075        let mut fresh = Vec::new();
7076        for sub in &self.subs {
7077            if sub.state != SubState::Active {
7078                continue;
7079            }
7080            let mut scopes = sub.requested.clone();
7081            sort_scope_map(&mut scopes);
7082            frames.push(Frame::Subscription {
7083                id: sub.id.clone(),
7084                table: sub.table.clone(),
7085                scopes,
7086                params: sub.params.clone().map(RawJson),
7087                cursor: sub.cursor,
7088                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
7089            });
7090            fresh.push((
7091                sub.id.clone(),
7092                sub.cursor < 0 && sub.bootstrap_state.is_none(),
7093            ));
7094        }
7095        let message = Message {
7096            wire_version: WIRE_VERSION,
7097            msg_kind: MsgKind::Request,
7098            frames,
7099        };
7100        (
7101            message,
7102            RequestMeta {
7103                pushed_ids,
7104                fresh,
7105                accept,
7106                deferred_commits,
7107            },
7108        )
7109    }
7110
7111    // -- sync -------------------------------------------------------------------
7112
7113    /// Cloneable observation handle; no client lock is needed to read it.
7114    pub fn progress(&self) -> ProgressObserver {
7115        self.progress.clone()
7116    }
7117
7118    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
7119        self.progress.start();
7120        let started_at_ms = self.clock_now_ms();
7121        let outcome = self.sync_inner(transport);
7122        self.progress.update(|p| match &outcome {
7123            SyncOutcome::Ok(report) if report.failed.is_empty() => {
7124                p.state = ProgressState::Complete
7125            }
7126            SyncOutcome::Ok(_) => {
7127                p.state = ProgressState::Failed;
7128                p.error_code = Some("sync.scope_revoked".into());
7129            }
7130            SyncOutcome::Failed { error_code, .. } => {
7131                p.state = ProgressState::Failed;
7132                p.error_code = Some(Self::diagnostic_code(error_code));
7133            }
7134        });
7135        let completed_at_ms = self.clock_now_ms();
7136        self.last_round = Some(match &outcome {
7137            SyncOutcome::Ok(report) => DiagnosticLastRound {
7138                status: "succeeded".to_owned(),
7139                started_at_ms,
7140                completed_at_ms,
7141                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
7142                counters: Some(DiagnosticRoundCounters {
7143                    pushed: report.pushed,
7144                    applied: report.applied.len(),
7145                    rejected: report.rejected.len(),
7146                    retryable: report.retryable.len(),
7147                    conflicts: report.conflicts,
7148                    commits_applied: report.commits_applied,
7149                    segment_rows_applied: report.segment_rows_applied,
7150                    bootstrapping: report.bootstrapping.len(),
7151                    resets: report.resets.len(),
7152                    revoked: report.revoked.len(),
7153                    failed: report.failed.len(),
7154                    deferred_commits: report.deferred_commits,
7155                }),
7156                error_code: None,
7157            },
7158            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
7159                status: "failed".to_owned(),
7160                started_at_ms,
7161                completed_at_ms,
7162                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
7163                counters: None,
7164                error_code: Some(Self::diagnostic_code(error_code)),
7165            },
7166        });
7167        outcome
7168    }
7169
7170    fn sync_inner(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
7171        if self.stopped {
7172            // §1.6: the client stopped at the schema floor; syncing is inert
7173            // until an upgrade. The outbox is preserved for replay.
7174            return SyncOutcome::Ok(SyncReport {
7175                schema_floor: self.schema_floor.clone(),
7176                ..SyncReport::default()
7177            });
7178        }
7179        // §8.4: the coalesced sync-needed signal clears when a pull round
7180        // BEGINS, so a wake-up landing mid-round survives it.
7181        self.set_sync_needed(false, false);
7182        // §5.9.7 B4: upload pending blobs before pushing the referencing
7183        // rows, so the server-side existence check (§6.6) passes.
7184        if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
7185            if let Err(TransportError { code, message }) = self.flush_blob_uploads(transport) {
7186                if Self::retryable_transport_code(&code) {
7187                    self.schedule_background_retry();
7188                }
7189                return SyncOutcome::Failed {
7190                    error_code: code,
7191                    message,
7192                };
7193            }
7194        }
7195        let (message, meta) = self.build_request(transport.supports_url_fetch());
7196        let request_bytes = {
7197            #[cfg(feature = "bench-internals")]
7198            let _phase = self.benchmark_phases.start(Phase::RequestEncode);
7199            encode_message(&message)
7200        };
7201        // §8.7: rounds ride the socket whenever it is connected (one
7202        // loop, no fallback pair); the transport seam stays bytes-in /
7203        // bytes-out either way. Registration-at-round-end is server-side.
7204        let round = if self.realtime_connected {
7205            transport.realtime_sync(&request_bytes)
7206        } else {
7207            transport.sync(&request_bytes)
7208        };
7209        let response_bytes = match round {
7210            Ok(bytes) => bytes,
7211            Err(TransportError { code, message }) => {
7212                // §7.3.5: a request-level lease code stops-and-surfaces —
7213                // record it in leaseState (no local-data purge, §7.3.4).
7214                self.record_lease_error(&code);
7215                if Self::retryable_transport_code(&code) {
7216                    self.schedule_background_retry();
7217                }
7218                return SyncOutcome::Failed {
7219                    error_code: code,
7220                    message,
7221                };
7222            }
7223        };
7224        #[cfg(feature = "bench-internals")]
7225        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
7226        let response = match decode_message(&response_bytes) {
7227            Ok(message) => message,
7228            Err(error) => {
7229                // §1.2 rule 1 / §1.4 rule 5: truncated or malformed
7230                // responses abort without persisting anything.
7231                return SyncOutcome::Failed {
7232                    error_code: error.code.as_str().to_owned(),
7233                    message: error.detail,
7234                };
7235            }
7236        };
7237        #[cfg(feature = "bench-internals")]
7238        drop(decode_phase);
7239        if response.msg_kind != MsgKind::Response {
7240            return SyncOutcome::Failed {
7241                error_code: "sync.invalid_request".to_owned(),
7242                message: "expected a response message".to_owned(),
7243            };
7244        }
7245        let mut outcome = self.process_response(transport, response, &meta);
7246        if let SyncOutcome::Ok(report) = &mut outcome {
7247            report.deferred_commits = meta.deferred_commits;
7248        }
7249        match &outcome {
7250            SyncOutcome::Ok(_) => self.reset_background_retry(),
7251            SyncOutcome::Failed { error_code, .. }
7252                if Self::retryable_transport_code(error_code) =>
7253            {
7254                self.schedule_background_retry();
7255            }
7256            SyncOutcome::Failed { .. } => {}
7257        }
7258        if meta.deferred_commits > 0 {
7259            // §6.1 splitBatch: commits past the operation cap wait for the
7260            // next round — keep the host's sync signal raised until then.
7261            self.set_sync_needed(true, true);
7262        }
7263        outcome
7264    }
7265
7266    pub fn sync_until_idle(
7267        &mut self,
7268        transport: &mut dyn Transport,
7269        max_rounds: Option<u32>,
7270    ) -> SyncOutcome {
7271        let rounds = max_rounds.unwrap_or(12).max(1);
7272        let mut aggregate = SyncReport::default();
7273        for _ in 0..rounds {
7274            match self.sync(transport) {
7275                SyncOutcome::Failed {
7276                    error_code,
7277                    message,
7278                } => {
7279                    return SyncOutcome::Failed {
7280                        error_code,
7281                        message,
7282                    };
7283                }
7284                SyncOutcome::Ok(report) => {
7285                    aggregate.pushed += report.pushed;
7286                    aggregate.applied.extend(report.applied.iter().cloned());
7287                    aggregate.rejected.extend(report.rejected.iter().cloned());
7288                    aggregate.retryable.extend(report.retryable.iter().cloned());
7289                    aggregate.conflicts += report.conflicts;
7290                    aggregate.commits_applied += report.commits_applied;
7291                    aggregate.segment_rows_applied += report.segment_rows_applied;
7292                    aggregate.bootstrapping = report.bootstrapping.clone();
7293                    aggregate.resets.extend(report.resets.iter().cloned());
7294                    aggregate.revoked.extend(report.revoked.iter().cloned());
7295                    aggregate.failed.extend(report.failed.iter().cloned());
7296                    aggregate.deferred_commits = report.deferred_commits;
7297                    if report.schema_floor.is_some() {
7298                        aggregate.schema_floor = report.schema_floor.clone();
7299                    }
7300                    // §4.5: pull again whenever the response contained
7301                    // commits or segments; resets re-bootstrap; a pending
7302                    // resume token continues paging (§4.7); a raised
7303                    // sync-needed signal covers §6.1 splitBatch remainders
7304                    // (deferred outbox commits push on the next round).
7305                    let more = !report.bootstrapping.is_empty()
7306                        || report.commits_applied > 0
7307                        || report.segment_rows_applied > 0
7308                        || !report.resets.is_empty()
7309                        || self.sync_needed;
7310                    if !more {
7311                        break;
7312                    }
7313                }
7314            }
7315        }
7316        SyncOutcome::Ok(aggregate)
7317    }
7318
7319    fn process_response(
7320        &mut self,
7321        transport: &mut dyn Transport,
7322        response: Message,
7323        meta: &RequestMeta,
7324    ) -> SyncOutcome {
7325        #[cfg(feature = "bench-internals")]
7326        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
7327        let mut report = SyncReport {
7328            pushed: meta.pushed_ids.len() as u32,
7329            ..SyncReport::default()
7330        };
7331        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
7332            HashMap::new();
7333        let pushed_ids = meta
7334            .pushed_ids
7335            .iter()
7336            .map(String::as_str)
7337            .collect::<HashSet<_>>();
7338        let mut last_final_push_result_id: Option<String> = None;
7339        for frame in &response.frames {
7340            match frame {
7341                Frame::PushResultDetails {
7342                    client_commit_id,
7343                    entries,
7344                } => {
7345                    let details = rejection_details_by_commit
7346                        .entry(client_commit_id.clone())
7347                        .or_default();
7348                    for entry in entries {
7349                        let parsed = match RejectionDetails::parse(&entry.details.0) {
7350                            Ok(value) => value,
7351                            Err(message) => {
7352                                return SyncOutcome::Failed {
7353                                    error_code: "sync.invalid_request".to_owned(),
7354                                    message,
7355                                };
7356                            }
7357                        };
7358                        details.insert(entry.op_index, parsed);
7359                    }
7360                }
7361                Frame::PushResult {
7362                    client_commit_id,
7363                    status,
7364                    results,
7365                    ..
7366                } if pushed_ids.contains(client_commit_id.as_str())
7367                    && Self::push_result_is_final(*status, results) =>
7368                {
7369                    last_final_push_result_id = Some(client_commit_id.clone());
7370                }
7371                _ => {}
7372            }
7373        }
7374        let mut frames = response.frames.into_iter().peekable();
7375        if response.wire_version < 2 {
7376            return SyncOutcome::Failed {
7377                error_code: "client.invalid_host_response".to_owned(),
7378                message: "server response does not carry wire version 2 log-epoch state".to_owned(),
7379            };
7380        }
7381        match frames.next() {
7382            Some(Frame::RespHeader {
7383                required_schema_version,
7384                latest_schema_version,
7385                log_epoch,
7386                reset_required,
7387            }) => {
7388                let Some(log_epoch) = log_epoch else {
7389                    return SyncOutcome::Failed {
7390                        error_code: "client.invalid_host_response".to_owned(),
7391                        message: "response header omits logEpoch".to_owned(),
7392                    };
7393                };
7394                let Some(reset_required) = reset_required else {
7395                    return SyncOutcome::Failed {
7396                        error_code: "client.invalid_host_response".to_owned(),
7397                        message: "response header omits resetRequired".to_owned(),
7398                    };
7399                };
7400                if let Some(required) = required_schema_version {
7401                    // §1.6 schema-floor response: nothing else is processed;
7402                    // stop syncing and surface the upgrade requirement.
7403                    let floor = SchemaFloor {
7404                        required_schema_version: Some(required),
7405                        latest_schema_version,
7406                    };
7407                    self.set_schema_floor(Some(floor.clone()));
7408                    report.schema_floor = Some(floor);
7409                    return SyncOutcome::Ok(report);
7410                }
7411                if reset_required {
7412                    if frames.next().is_some() {
7413                        return SyncOutcome::Failed {
7414                            error_code: "client.invalid_host_response".to_owned(),
7415                            message: "log-epoch reset response contains body frames".to_owned(),
7416                        };
7417                    }
7418                    match self.run_log_epoch_reset(&log_epoch) {
7419                        Ok(resets) => {
7420                            report.resets = resets;
7421                            return SyncOutcome::Ok(report);
7422                        }
7423                        Err(message) => {
7424                            return SyncOutcome::Failed {
7425                                error_code: "sync.local_corrupt".to_owned(),
7426                                message,
7427                            };
7428                        }
7429                    }
7430                }
7431                if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
7432                    return SyncOutcome::Failed {
7433                        error_code: "client.invalid_host_response".to_owned(),
7434                        message: "server changed logEpoch without requiring a reset".to_owned(),
7435                    };
7436                }
7437            }
7438            _ => {
7439                return SyncOutcome::Failed {
7440                    error_code: "sync.invalid_request".to_owned(),
7441                    message: "response does not start with RESP_HEADER".to_owned(),
7442                };
7443            }
7444        }
7445
7446        let mut failure: Option<(String, String)> = None;
7447        while let Some(frame) = frames.next() {
7448            match frame {
7449                frame @ Frame::PushResult { .. } => {
7450                    let successful = matches!(
7451                        &frame,
7452                        Frame::PushResult {
7453                            status: PushStatus::Applied | PushStatus::Cached,
7454                            ..
7455                        }
7456                    );
7457                    let mut results = vec![frame];
7458                    if successful {
7459                        while matches!(
7460                            frames.peek(),
7461                            Some(Frame::PushResult {
7462                                status: PushStatus::Applied | PushStatus::Cached,
7463                                ..
7464                            })
7465                        ) {
7466                            if let Some(next) = frames.next() {
7467                                results.push(next);
7468                            }
7469                        }
7470                    }
7471                    if self
7472                        .handle_push_results(
7473                            &results,
7474                            &pushed_ids,
7475                            &rejection_details_by_commit,
7476                            &mut report,
7477                            last_final_push_result_id.as_deref(),
7478                        )
7479                        .is_err()
7480                    {
7481                        failure = Some((
7482                            "client.outcome_persistence_failed".to_owned(),
7483                            "local commit outcome could not be persisted".to_owned(),
7484                        ));
7485                        break;
7486                    }
7487                }
7488                Frame::PushResultDetails { .. } => {}
7489                Frame::SubStart {
7490                    id,
7491                    status,
7492                    reason_code,
7493                    effective_scopes,
7494                    bootstrap: _,
7495                } => {
7496                    let mut body = Vec::new();
7497                    let mut sub_end: Option<(i64, Option<String>)> = None;
7498                    for inner in frames.by_ref() {
7499                        match inner {
7500                            Frame::SubEnd {
7501                                next_cursor,
7502                                bootstrap_state,
7503                            } => {
7504                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
7505                                break;
7506                            }
7507                            Frame::Unknown { .. } => {}
7508                            other => body.push(other),
7509                        }
7510                    }
7511                    if let Err(SectionError::Abort(code, message)) = self.process_section(
7512                        transport,
7513                        &id,
7514                        status,
7515                        &reason_code,
7516                        effective_scopes,
7517                        body,
7518                        sub_end,
7519                        meta,
7520                        &mut report,
7521                    ) {
7522                        failure = Some((code, message));
7523                        break;
7524                    }
7525                }
7526                Frame::Lease {
7527                    lease_id,
7528                    expires_at_ms,
7529                } => {
7530                    // §7.3.5: persist the opaque lease; a fresh lease clears
7531                    // any prior lease error (the outage/revocation is over).
7532                    self.set_lease_state(Some(LeaseState {
7533                        lease_id: Some(lease_id),
7534                        expires_at_ms: Some(expires_at_ms),
7535                        error_code: None,
7536                    }));
7537                }
7538                Frame::Error { code, message, .. } => {
7539                    // §1.6: the whole request failed; already-completed
7540                    // subscriptions keep their applied data and cursors.
7541                    failure = Some((code, message));
7542                    break;
7543                }
7544                Frame::Unknown { .. } => {}
7545                _ => {
7546                    failure = Some((
7547                        "sync.invalid_request".to_owned(),
7548                        "unexpected frame in response".to_owned(),
7549                    ));
7550                    break;
7551                }
7552            }
7553        }
7554
7555        // §7.1: reconcile the visible overlay once at the response boundary
7556        // after all outbox acknowledgements and server data applied so a
7557        // batch of PUSH_RESULT frames cannot trigger repeated full-table
7558        // rebuilds. This also covers a round that aborted mid-way.
7559        if self.overlay_dirty.get() {
7560            self.rebuild_overlay();
7561        }
7562        if let Some((error_code, message)) = failure {
7563            return SyncOutcome::Failed {
7564                error_code,
7565                message,
7566            };
7567        }
7568        // §4.8 E1: the push half may have drained commits that pinned rows of
7569        // a shrunk window unit — retry any deferred evictions now.
7570        self.drain_pending_evictions();
7571        self.ack_after_pull(transport);
7572        // §7.4.5: the reset is over once the first post-reset pull round
7573        // leaves no subscription mid-bootstrap — the tables are rebuilt.
7574        if self.upgrading && report.bootstrapping.is_empty() {
7575            self.set_upgrading(false);
7576        }
7577        SyncOutcome::Ok(report)
7578    }
7579
7580    // -- push results (§6.3, §7.2) ------------------------------------------------
7581
7582    fn handle_push_results(
7583        &mut self,
7584        frames: &[Frame],
7585        pushed_ids: &HashSet<&str>,
7586        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
7587        report: &mut SyncReport,
7588        last_final_push_result_id: Option<&str>,
7589    ) -> Result<(), String> {
7590        self.begin_observation("syncular_push_results")?;
7591        let conflict_count = self.conflicts.len();
7592        let rejection_count = self.rejections.len();
7593        let applied_count = report.applied.len();
7594        let rejected_count = report.rejected.len();
7595        let retryable_count = report.retryable.len();
7596        let was_dirty = self.overlay_dirty.get();
7597        let mut removed = Vec::new();
7598        let mut batch = ChangeAccumulator::default();
7599        let persisted = (|| {
7600            for frame in frames {
7601                let Frame::PushResult {
7602                    client_commit_id,
7603                    status,
7604                    results,
7605                    ..
7606                } = frame
7607                else {
7608                    return Err("unexpected acknowledgement frame".to_owned());
7609                };
7610                if !pushed_ids.contains(client_commit_id.as_str()) {
7611                    continue;
7612                }
7613                let Some(index) = self
7614                    .outbox
7615                    .iter()
7616                    .position(|commit| commit.client_commit_id == *client_commit_id)
7617                else {
7618                    continue;
7619                };
7620                let status = *status;
7621                let rejection_details = rejection_details_by_commit.get(client_commit_id);
7622                let operations = self.outbox[index].ops.clone();
7623                match status {
7624                    PushStatus::Applied | PushStatus::Cached => {
7625                        // §7.2: a lost ack replays as `cached` — proceed as if the
7626                        // ack had arrived.
7627                        let journal_results = results
7628                            .iter()
7629                            .map(|result| {
7630                                let op_index = match result {
7631                                    OpResult::Applied { op_index }
7632                                    | OpResult::Conflict { op_index, .. }
7633                                    | OpResult::Error { op_index, .. } => *op_index,
7634                                };
7635                                CommitOperationOutcome::Applied { op_index }
7636                            })
7637                            .collect::<Vec<_>>();
7638                        let outcome_status = if status == PushStatus::Applied {
7639                            CommitOutcomeStatus::Applied
7640                        } else {
7641                            CommitOutcomeStatus::Cached
7642                        };
7643                        let persisted = self
7644                            .persist_commit_outcome(
7645                                client_commit_id,
7646                                outcome_status,
7647                                &journal_results,
7648                                None,
7649                            )
7650                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
7651                        persisted?;
7652                        self.overlay_dirty.set(true);
7653                        batch.status = true;
7654                        batch.outcomes = true;
7655                    }
7656                    PushStatus::Rejected => {
7657                        if results.iter().any(|result| {
7658                            matches!(
7659                                result,
7660                                OpResult::Error {
7661                                    code,
7662                                    retryable: true,
7663                                    ..
7664                                } if code == "sync.idempotency_cache_miss"
7665                            )
7666                        }) {
7667                            // §6.3/§7.2: a serving failure, not an outcome — keep the
7668                            // exact commit queued for an identical retry.
7669                            report.retryable.push(client_commit_id.to_owned());
7670                            continue;
7671                        }
7672
7673                        let mut journal_results = Vec::with_capacity(results.len());
7674                        let mut conflicts = Vec::new();
7675                        let mut rejections = Vec::new();
7676                        for result in results {
7677                            match result {
7678                                OpResult::Applied { op_index } => {
7679                                    journal_results.push(CommitOperationOutcome::Applied {
7680                                        op_index: *op_index,
7681                                    });
7682                                }
7683                                OpResult::Conflict {
7684                                    op_index,
7685                                    code,
7686                                    message,
7687                                    server_version,
7688                                    server_row,
7689                                } => {
7690                                    let operation = operations
7691                                        .get(*op_index as usize)
7692                                        .map(CommitOperation::from);
7693                                    let (table, row_id) = operation
7694                                        .as_ref()
7695                                        .map(|op| (op.table.clone(), op.row_id.clone()))
7696                                        .unwrap_or_default();
7697                                    let server_row_json = self
7698                                        .schema
7699                                        .table(&table)
7700                                        .and_then(|t| {
7701                                            decode_row_bytes(t, server_row, &self.encryption)
7702                                                .ok()
7703                                                .map(|row| (t, row))
7704                                        })
7705                                        .map(|(t, row)| {
7706                                            let mut map = Map::new();
7707                                            for (i, column) in t.columns.iter().enumerate() {
7708                                                map.insert(
7709                                                    column.name.clone(),
7710                                                    column_value_to_json(
7711                                                        row.get(i).unwrap_or(&None),
7712                                                    ),
7713                                                );
7714                                            }
7715                                            map
7716                                        })
7717                                        .unwrap_or_default();
7718                                    let conflict = ConflictRecord {
7719                                        client_commit_id: client_commit_id.to_owned(),
7720                                        op_index: *op_index,
7721                                        table,
7722                                        row_id,
7723                                        code: code.clone(),
7724                                        message: message.clone(),
7725                                        server_version: *server_version,
7726                                        server_row: server_row_json,
7727                                        operation,
7728                                    };
7729                                    journal_results.push(CommitOperationOutcome::Conflict {
7730                                        conflict: conflict.clone(),
7731                                    });
7732                                    conflicts.push(conflict);
7733                                }
7734                                OpResult::Error {
7735                                    op_index,
7736                                    code,
7737                                    message,
7738                                    retryable,
7739                                } => {
7740                                    let rejection = RejectionRecord {
7741                                        client_commit_id: client_commit_id.to_owned(),
7742                                        op_index: *op_index,
7743                                        code: code.clone(),
7744                                        message: message.clone(),
7745                                        retryable: *retryable,
7746                                        details: rejection_details
7747                                            .and_then(|details| details.get(op_index))
7748                                            .cloned(),
7749                                        operation: operations
7750                                            .get(*op_index as usize)
7751                                            .map(CommitOperation::from),
7752                                    };
7753                                    journal_results.push(CommitOperationOutcome::Error {
7754                                        rejection: rejection.clone(),
7755                                    });
7756                                    rejections.push(rejection);
7757                                }
7758                            }
7759                        }
7760                        let outcome_status = if conflicts.is_empty() {
7761                            CommitOutcomeStatus::Rejected
7762                        } else {
7763                            CommitOutcomeStatus::Conflict
7764                        };
7765                        let persisted = self
7766                            .persist_commit_outcome(
7767                                client_commit_id,
7768                                outcome_status,
7769                                &journal_results,
7770                                Some(&operations),
7771                            )
7772                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
7773                        persisted?;
7774                        batch.conflicts = !conflicts.is_empty();
7775                        batch.rejections = !rejections.is_empty();
7776                        batch.status = true;
7777                        batch.outcomes = true;
7778                        self.conflicts.extend(conflicts);
7779                        self.rejections.extend(rejections);
7780                        self.overlay_dirty.set(true);
7781                    }
7782                }
7783                removed.push((index, self.outbox.remove(index)));
7784                if batch.status {
7785                    for operation in &operations {
7786                        if !self.record_row_scopes(
7787                            &mut batch,
7788                            &operation.table,
7789                            &operation.row_id,
7790                            false,
7791                        ) {
7792                            batch.table(&operation.table);
7793                        }
7794                    }
7795                }
7796                match status {
7797                    PushStatus::Applied | PushStatus::Cached => {
7798                        report.applied.push(client_commit_id.to_owned())
7799                    }
7800                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
7801                }
7802            }
7803            if batch.outcomes && frames.iter().any(|frame| matches!(frame, Frame::PushResult { client_commit_id, .. } if Some(client_commit_id.as_str()) == last_final_push_result_id)) {
7804                self.prune_commit_outcomes()?;
7805            }
7806            self.finish_observation("syncular_push_results", batch)
7807        })();
7808        if let Err(error) = persisted {
7809            self.rollback_observation("syncular_push_results");
7810            for (index, commit) in removed.into_iter().rev() {
7811                self.outbox.insert(index, commit);
7812            }
7813            self.conflicts.truncate(conflict_count);
7814            self.rejections.truncate(rejection_count);
7815            self.overlay_dirty.set(was_dirty);
7816            report.applied.truncate(applied_count);
7817            report.rejected.truncate(rejected_count);
7818            report.retryable.truncate(retryable_count);
7819            return Err(error);
7820        }
7821        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
7822        Ok(())
7823    }
7824
7825    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
7826        status != PushStatus::Rejected
7827            || !results.iter().any(|result| {
7828                matches!(
7829                    result,
7830                    OpResult::Error {
7831                        code,
7832                        retryable: true,
7833                        ..
7834                    } if code == "sync.idempotency_cache_miss"
7835                )
7836            })
7837    }
7838
7839    // -- subscription sections ------------------------------------------------------
7840
7841    #[allow(clippy::too_many_arguments)]
7842    fn process_section(
7843        &mut self,
7844        transport: &mut dyn Transport,
7845        id: &str,
7846        status: SubStatus,
7847        reason_code: &str,
7848        effective_scopes: Vec<(String, Vec<String>)>,
7849        body: Vec<Frame>,
7850        sub_end: Option<(i64, Option<String>)>,
7851        meta: &RequestMeta,
7852        report: &mut SyncReport,
7853    ) -> Result<(), SectionError> {
7854        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
7855            return Ok(()); // unknown echo: ignore
7856        };
7857        if status != SubStatus::Active && sub_end.is_none() {
7858            return Err(SectionError::Abort(
7859                "sync.invalid_request".into(),
7860                "subscription section without SUB_END".into(),
7861            ));
7862        }
7863        match status {
7864            SubStatus::Revoked => {
7865                self.begin_observation("syncular_revocation")
7866                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
7867                let previous = self.subs[sub_index].clone();
7868                let was_dirty = self.overlay_dirty.get();
7869                let previous_outbox = self.outbox.clone();
7870                let rejection_count = self.rejections.len();
7871                let revoked_count = report.revoked.len();
7872                let failed_count = report.failed.len();
7873                let result =
7874                    (|| {
7875                        let mut batch = ChangeAccumulator::default();
7876                        let registered = self.window_unit_by_sub_id(id);
7877                        // §3.3: stop pulling, purge exactly the last effective grant.
7878                        let (table, effective) = {
7879                            let sub = &self.subs[sub_index];
7880                            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
7881                        };
7882                        let purged = self.purge_scope_rows(&table, &effective);
7883                        match purged {
7884                            Ok(()) => {
7885                                self.record_scope_map(&mut batch, &table, &effective);
7886                                let sub = &mut self.subs[sub_index];
7887                                sub.state = SubState::Revoked;
7888                                sub.reason_code = Some(if reason_code.is_empty() {
7889                                    "sync.scope_revoked".to_owned()
7890                                } else {
7891                                    reason_code.to_owned()
7892                                });
7893                                report.revoked.push(id.to_owned());
7894                                let doomed_effective = effective;
7895                                let sub_table = table;
7896                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
7897                                    |message| SectionError::Abort("storage.failed".into(), message),
7898                                )?;
7899                                let dropped = self
7900                                    .drop_doomed_outbox(&sub_table, &doomed_effective)
7901                                    .map_err(|message| {
7902                                        SectionError::Abort("storage.failed".to_owned(), message)
7903                                    })?;
7904                                if dropped {
7905                                    batch.status = true;
7906                                    batch.rejections = true;
7907                                    batch.outcomes = true;
7908                                }
7909                                // §5.9.7 B2: revocation deletes now-unauthorized blob
7910                                // bodies (evicted ≠ revoked).
7911                                self.delete_unreferenced_cached_blobs();
7912                            }
7913                            Err(()) => {
7914                                // §3.3 fail closed: no local mapping — never clear by
7915                                // approximation; fatal configuration error.
7916                                let sub = &mut self.subs[sub_index];
7917                                sub.state = SubState::Failed;
7918                                sub.reason_code = Some("sync.scope_revoked".to_owned());
7919                                report.failed.push(id.to_owned());
7920                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
7921                                    |message| SectionError::Abort("storage.failed".into(), message),
7922                                )?;
7923                            }
7924                        }
7925                        if let Some((base_key, unit)) = registered {
7926                            batch.window(&base_key, &self.subs[sub_index].table, &unit);
7927                        }
7928                        self.rebuild_overlay_if_dirty();
7929                        self.finish_observation("syncular_revocation", batch)
7930                            .map_err(|message| {
7931                                SectionError::Abort("storage.failed".to_owned(), message)
7932                            })?;
7933                        Ok(())
7934                    })();
7935                if result.is_err() {
7936                    self.rollback_observation("syncular_revocation");
7937                    self.subs[sub_index] = previous;
7938                    self.overlay_dirty.set(was_dirty);
7939                    self.outbox = previous_outbox;
7940                    self.rejections.truncate(rejection_count);
7941                    report.revoked.truncate(revoked_count);
7942                    report.failed.truncate(failed_count);
7943                }
7944                result
7945            }
7946            SubStatus::Reset => {
7947                self.begin_observation("syncular_reset")
7948                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
7949                let previous = self.subs[sub_index].clone();
7950                let was_dirty = self.overlay_dirty.get();
7951                let reset_count = report.resets.len();
7952                let result = (|| {
7953                    let mut batch = ChangeAccumulator::default();
7954                    let registered = self.window_unit_by_sub_id(id);
7955                    // §4.6: discard cursor + bootstrap state, keep local rows —
7956                    // reset is a staleness signal, not a purge signal.
7957                    let sub = &mut self.subs[sub_index];
7958                    sub.cursor = -1;
7959                    sub.bootstrap_state = None;
7960                    report.resets.push(id.to_owned());
7961                    self.persist_sub(&self.subs[sub_index].clone())
7962                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
7963                    if let Some((base_key, unit)) = registered {
7964                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
7965                    }
7966                    self.finish_observation("syncular_reset", batch)
7967                        .map_err(|message| {
7968                            SectionError::Abort("storage.failed".to_owned(), message)
7969                        })?;
7970                    Ok(())
7971                })();
7972                if result.is_err() {
7973                    self.rollback_observation("syncular_reset");
7974                    self.subs[sub_index] = previous;
7975                    self.overlay_dirty.set(was_dirty);
7976                    report.resets.truncate(reset_count);
7977                }
7978                result
7979            }
7980            SubStatus::Active => {
7981                let fresh = meta
7982                    .fresh
7983                    .iter()
7984                    .find(|(fid, _)| fid == id)
7985                    .map(|(_, fresh)| *fresh)
7986                    .unwrap_or(false);
7987                let previous = self.subs[sub_index].clone();
7988                self.subs[sub_index].effective = Some(effective_scopes);
7989                let outcome = self
7990                    .apply_section_body(transport, sub_index, body, fresh, meta, report)
7991                    .and_then(|()| {
7992                        let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
7993                            SectionError::Abort(
7994                                "sync.invalid_request".into(),
7995                                "subscription section without SUB_END".into(),
7996                            )
7997                        })?;
7998                        self.apply_sub_end(sub_index, cursor, bootstrap_state)
7999                            .map_err(|message| {
8000                                SectionError::Abort("storage.failed".into(), message)
8001                            })
8002                    });
8003                match outcome {
8004                    Ok(()) => {
8005                        if self.subs[sub_index].bootstrap_state.is_some() {
8006                            report.bootstrapping.push(id.to_owned());
8007                        }
8008                        Ok(())
8009                    }
8010                    Err(SectionError::FailClosed) => {
8011                        self.subs[sub_index] = previous.clone();
8012                        self.begin_observation("syncular_section_failure")
8013                            .map_err(|message| {
8014                                SectionError::Abort("storage.failed".into(), message)
8015                            })?;
8016                        let persisted = (|| {
8017                            let mut batch = ChangeAccumulator::default();
8018                            if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
8019                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
8020                            }
8021                            self.subs[sub_index].state = SubState::Failed;
8022                            self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
8023                            self.persist_sub(&self.subs[sub_index])?;
8024                            self.finish_observation("syncular_section_failure", batch)
8025                        })();
8026                        if let Err(message) = persisted {
8027                            self.rollback_observation("syncular_section_failure");
8028                            self.subs[sub_index] = previous;
8029                            return Err(SectionError::Abort("storage.failed".into(), message));
8030                        }
8031                        report.failed.push(id.to_owned());
8032                        Ok(())
8033                    }
8034                    Err(error) => {
8035                        // Frame/block transactions already completed. Preserve their
8036                        // rows and revisions, but leave this subscription cursor unchanged.
8037                        self.subs[sub_index] = previous;
8038                        Err(error)
8039                    }
8040                }
8041            }
8042        }
8043    }
8044
8045    fn apply_sub_end(
8046        &mut self,
8047        sub_index: usize,
8048        cursor: i64,
8049        bootstrap_state: Option<String>,
8050    ) -> Result<(), String> {
8051        #[cfg(feature = "bench-internals")]
8052        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
8053        let previous = self.subs[sub_index].clone();
8054        self.begin_observation("syncular_sub_end")?;
8055        let persisted = (|| {
8056            let mut batch = ChangeAccumulator::default();
8057            let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
8058                && cursor >= 0
8059                && bootstrap_state.is_none();
8060            if completed {
8061                if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
8062                    batch.window(&base_key, &previous.table, &unit);
8063                }
8064            }
8065            let sub = &mut self.subs[sub_index];
8066            sub.cursor = cursor;
8067            sub.bootstrap_state = bootstrap_state;
8068            sub.synced_once = true;
8069            self.persist_sub(&self.subs[sub_index])?;
8070            self.finish_observation("syncular_sub_end", batch)
8071        })();
8072        if persisted.is_err() {
8073            self.rollback_observation("syncular_sub_end");
8074            self.subs[sub_index] = previous;
8075        }
8076        persisted
8077    }
8078
8079    fn apply_section_body(
8080        &mut self,
8081        transport: &mut dyn Transport,
8082        sub_index: usize,
8083        body: Vec<Frame>,
8084        fresh: bool,
8085        meta: &RequestMeta,
8086        report: &mut SyncReport,
8087    ) -> Result<(), SectionError> {
8088        let mut saw_segment = false;
8089        for frame in body {
8090            match frame {
8091                Frame::Commit {
8092                    tables, changes, ..
8093                } => {
8094                    self.apply_commit_frame(&tables, &changes)
8095                        .map_err(|(c, m)| SectionError::Abort(c, m))?;
8096                    report.commits_applied += 1;
8097                }
8098                Frame::SegmentInline { payload } => {
8099                    let segment = decode_rows_segment(&payload)
8100                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
8101                    self.progress.update(|p| {
8102                        p.phase = ProgressPhase::Import;
8103                        p.subscription_id = Some(self.subs[sub_index].id.clone());
8104                        p.table = Some(self.subs[sub_index].table.clone());
8105                        p.segment_id = None;
8106                        p.bytes_received = payload.len() as u64;
8107                        p.bytes_total = Some(payload.len() as u64);
8108                        p.rows_processed = 0;
8109                        p.rows_total =
8110                            Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
8111                    });
8112                    let first = !saw_segment;
8113                    saw_segment = true;
8114                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
8115                    report.segment_rows_applied += applied;
8116                }
8117                Frame::SegmentRef {
8118                    segment_id,
8119                    byte_length,
8120                    media_type,
8121                    table,
8122                    row_count,
8123                    as_of_commit_seq,
8124                    scope_digest,
8125                    row_cursor,
8126                    next_row_cursor,
8127                    url,
8128                    url_expires_at_ms,
8129                    ..
8130                } => {
8131                    // §4.2: reject a descriptor whose mediaType was not
8132                    // advertised — never skip or guess.
8133                    let advertised = match media_type {
8134                        MediaType::Rows => {
8135                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
8136                                || meta.accept & ACCEPT_INLINE_ROWS != 0
8137                        }
8138                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
8139                    };
8140                    if !advertised {
8141                        return Err(SectionError::Abort(
8142                            "sync.invalid_request".to_owned(),
8143                            format!(
8144                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
8145                                media_type.name()
8146                            ),
8147                        ));
8148                    }
8149                    self.progress.update(|p| {
8150                        p.phase = ProgressPhase::Download;
8151                        p.subscription_id = Some(self.subs[sub_index].id.clone());
8152                        p.table = Some(table.clone());
8153                        p.segment_id = Some(segment_id.clone());
8154                        p.bytes_received = 0;
8155                        p.bytes_total = u64::try_from(byte_length).ok();
8156                        p.rows_processed = 0;
8157                        p.rows_total = u64::try_from(row_count).ok();
8158                    });
8159                    let observer = self.progress.clone();
8160                    let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
8161                    let bytes = if let Some(url) = url {
8162                        // §5.4: a url-carrying descriptor MUST be fetched
8163                        // from that URL; failure invalidates the whole
8164                        // descriptor (no fall-through to §5.5 — re-pull
8165                        // recovers, §1.4 rule 5).
8166                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
8167                            return Err(SectionError::Abort(
8168                                "sync.invalid_request".to_owned(),
8169                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
8170                                    .to_owned(),
8171                            ));
8172                        }
8173                        // §5.4: MUST NOT start a fetch at/past expiry.
8174                        if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
8175                            return Err(SectionError::Abort(
8176                                "sync.segment_expired".to_owned(),
8177                                format!(
8178                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
8179                                ),
8180                            ));
8181                        }
8182                        transport
8183                            .fetch_url(&url, &mut on_progress)
8184                            .map_err(|e| SectionError::Abort(e.code, e.message))?
8185                    } else {
8186                        let requested_scopes_json =
8187                            canonical_scope_json(&self.subs[sub_index].requested);
8188                        transport
8189                            .download_segment(
8190                                &SegmentRequest {
8191                                    segment_id: segment_id.clone(),
8192                                    table,
8193                                    requested_scopes_json,
8194                                },
8195                                &mut on_progress,
8196                            )
8197                            .map_err(|e| SectionError::Abort(e.code, e.message))?
8198                    };
8199                    // §5.1: verify the content address before applying.
8200                    on_progress(bytes.len() as u64);
8201                    let digest = Sha256::digest(&bytes);
8202                    let expected = segment_id
8203                        .strip_prefix("sha256:")
8204                        .unwrap_or(segment_id.as_str());
8205                    if bytes_to_hex(&digest) != expected {
8206                        return Err(SectionError::Abort(
8207                            "sync.invalid_request".to_owned(),
8208                            "segment bytes do not match the content address (§5.1)".to_owned(),
8209                        ));
8210                    }
8211                    self.progress.update(|p| p.phase = ProgressPhase::Import);
8212                    if media_type == MediaType::Sqlite {
8213                        // §5.3: images are whole-table — a paged sqlite
8214                        // descriptor is invalid.
8215                        if row_cursor.is_some() || next_row_cursor.is_some() {
8216                            return Err(SectionError::Abort(
8217                                "sync.invalid_request".to_owned(),
8218                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
8219                                    .to_owned(),
8220                            ));
8221                        }
8222                        let first = !saw_segment;
8223                        saw_segment = true;
8224                        let applied = self.apply_sqlite_segment(
8225                            sub_index,
8226                            &bytes,
8227                            fresh && first,
8228                            row_count,
8229                            as_of_commit_seq,
8230                            &scope_digest,
8231                        )?;
8232                        report.segment_rows_applied += applied;
8233                    } else {
8234                        let segment = decode_rows_segment(&bytes).map_err(|e| {
8235                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
8236                        })?;
8237                        let first = row_cursor.is_none();
8238                        saw_segment = true;
8239                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
8240                        report.segment_rows_applied += applied;
8241                    }
8242                }
8243                Frame::Unknown { .. } => {}
8244                Frame::Error { code, message, .. } => {
8245                    return Err(SectionError::Abort(code, message))
8246                }
8247                _ => {
8248                    return Err(SectionError::Abort(
8249                        "sync.invalid_request".to_owned(),
8250                        "unexpected frame inside a subscription section".to_owned(),
8251                    ));
8252                }
8253            }
8254        }
8255        Ok(())
8256    }
8257
8258    // Both pull and realtime frames use the same durable observation boundary.
8259    fn apply_commit_frame(
8260        &mut self,
8261        tables: &[String],
8262        changes: &[ssp2::model::Change],
8263    ) -> Result<(), (String, String)> {
8264        #[cfg(feature = "bench-internals")]
8265        let _phase = self.benchmark_phases.start(Phase::CommitApply);
8266        let was_dirty = self.overlay_dirty.get();
8267        self.begin_observation("syncular_commit")
8268            .map_err(|message| ("storage.failed".into(), message))?;
8269        let applied = (|| {
8270            let mut batch = ChangeAccumulator::default();
8271            self.record_commit_changes(&mut batch, tables, changes);
8272            self.apply_commit_changes(tables, changes)?;
8273            self.rebuild_overlay_if_dirty();
8274            self.finish_observation("syncular_commit", batch)
8275                .map_err(|message| ("storage.failed".into(), message))
8276        })();
8277        if applied.is_err() {
8278            self.rollback_observation("syncular_commit");
8279            self.overlay_dirty.set(was_dirty);
8280        }
8281        applied
8282    }
8283
8284    fn apply_commit_changes(
8285        &mut self,
8286        tables: &[String],
8287        changes: &[ssp2::model::Change],
8288    ) -> Result<(), (String, String)> {
8289        // A clean overlay can reconcile changed primary keys independently
8290        // when their tables have no secondary unique constraints. Such a
8291        // constraint can change whether another row's pending write succeeds,
8292        // so those frames still require the complete FIFO replay below.
8293        let mirror_visible = !self.overlay_dirty.get()
8294            && (self.outbox.is_empty()
8295                || changes.iter().all(|change| {
8296                    tables
8297                        .get(change.table_index as usize)
8298                        .and_then(|name| self.schema.table(name))
8299                        .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
8300                }));
8301        for change in changes {
8302            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
8303                (
8304                    "sync.invalid_request".to_owned(),
8305                    "change tableIndex out of range".to_owned(),
8306                )
8307            })?;
8308            let table = self.schema.table(table_name).ok_or_else(|| {
8309                (
8310                    "sync.schema_mismatch".to_owned(),
8311                    format!("change targets unknown table {table_name:?}"),
8312                )
8313            })?;
8314            match change.op {
8315                Op::Upsert => {
8316                    let payload = change.row.as_ref().ok_or_else(|| {
8317                        (
8318                            "sync.invalid_request".to_owned(),
8319                            "upsert change without row payload".to_owned(),
8320                        )
8321                    })?;
8322                    // §5.11: decrypt encrypted columns on apply.
8323                    let row = {
8324                        #[cfg(feature = "bench-internals")]
8325                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
8326                        decode_row_bytes(table, payload, &self.encryption)
8327                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?
8328                    };
8329                    let version = change.row_version.unwrap_or(0);
8330                    let table_name = table.name.clone();
8331                    self.write_base_row(&table_name, &row, version)
8332                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
8333                    if mirror_visible {
8334                        self.write_row(&visible_table(&table_name), &table_name, &row, version)
8335                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
8336                    }
8337                }
8338                Op::Delete => {
8339                    self.delete_base_row(table_name, &change.row_id)
8340                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
8341                    if mirror_visible {
8342                        let sql = format!(
8343                            "DELETE FROM {} WHERE {}",
8344                            visible_table(table_name),
8345                            row_id_predicate(table)
8346                        );
8347                        self.conn
8348                            .prepare_cached(&sql)
8349                            .and_then(|mut statement| {
8350                                statement.execute(rusqlite::params![change.row_id])
8351                            })
8352                            .map_err(|error| {
8353                                ("sync.invalid_request".to_owned(), error.to_string())
8354                            })?;
8355                    }
8356                }
8357            }
8358        }
8359        if mirror_visible {
8360            if !self.outbox.is_empty() {
8361                let changed_rows: HashSet<(&str, &str)> = changes
8362                    .iter()
8363                    .map(|change| {
8364                        (
8365                            tables[change.table_index as usize].as_str(),
8366                            change.row_id.as_str(),
8367                        )
8368                    })
8369                    .collect();
8370                self.apply_outbox_ops(
8371                    self.outbox
8372                        .iter()
8373                        .flat_map(|commit| &commit.ops)
8374                        .filter(|op| {
8375                            changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
8376                        }),
8377                );
8378            }
8379            self.overlay_dirty.set(false);
8380        }
8381        Ok(())
8382    }
8383
8384    /// §5.6 segment application: validate against the generated schema,
8385    /// clear the grant on a fresh bootstrap's first page (fail closed
8386    /// without a mapping), then replace-or-upsert each row with its
8387    /// segment-carried server version (§5.2).
8388    fn apply_segment(
8389        &mut self,
8390        sub_index: usize,
8391        segment: &RowsSegment,
8392        first_fresh_page: bool,
8393    ) -> Result<u32, SectionError> {
8394        let (sub_table, effective) = {
8395            let sub = &self.subs[sub_index];
8396            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
8397        };
8398        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
8399            SectionError::Abort(
8400                "sync.schema_mismatch".to_owned(),
8401                format!("subscription table {sub_table:?} is not in the client schema"),
8402            )
8403        })?;
8404        // §5.2: the column table validates against the generated schema —
8405        // order, names, types, nullability; mismatch is fatal. §5.11: the
8406        // server sends the WIRE types (bytes for an encrypted column), so
8407        // validate against wire_columns.
8408        let matches = segment.table == table.name
8409            && segment.schema_version == self.schema.version
8410            && segment.columns.len() == table.wire_columns.len()
8411            && segment
8412                .columns
8413                .iter()
8414                .zip(table.wire_columns.iter())
8415                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
8416        if !matches {
8417            return Err(SectionError::Abort(
8418                "sync.schema_mismatch".to_owned(),
8419                "segment column table does not match the generated schema (§5.2)".to_owned(),
8420            ));
8421        }
8422        let mut applied = 0u32;
8423        let empty = Vec::new();
8424        let blocks = if segment.blocks.is_empty() {
8425            std::slice::from_ref(&empty)
8426        } else {
8427            &segment.blocks
8428        };
8429        for (index, block) in blocks.iter().enumerate() {
8430            let clear = first_fresh_page && index == 0;
8431            if !clear && block.is_empty() {
8432                continue;
8433            }
8434            let was_dirty = self.overlay_dirty.get();
8435            self.begin_observation("syncular_segment_block")
8436                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
8437            let outcome = (|| {
8438                let mut batch = ChangeAccumulator::default();
8439                if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
8440                    batch.table(&table.name);
8441                }
8442                if clear {
8443                    self.purge_scope_rows(&table.name, &effective)
8444                        .map_err(|()| SectionError::FailClosed)?;
8445                }
8446                for (row_index, row) in block.iter().enumerate() {
8447                    let decrypted;
8448                    let values = if table.has_encrypted_columns() {
8449                        let mut values = row.values.clone();
8450                        crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
8451                            .map_err(|message| {
8452                            SectionError::Abort("client.decrypt_failed".into(), message)
8453                        })?;
8454                        decrypted = values;
8455                        &decrypted
8456                    } else {
8457                        &row.values
8458                    };
8459                    self.write_base_row(&table.name, values, row.server_version)
8460                        .map_err(|message| {
8461                            SectionError::Abort("sync.invalid_request".into(), message)
8462                        })?;
8463                    let processed = u64::from(applied) + row_index as u64 + 1;
8464                    if processed.is_multiple_of(1024) {
8465                        self.progress.update(|p| p.rows_processed = processed);
8466                    }
8467                }
8468                self.rebuild_overlay_if_dirty();
8469                self.finish_observation("syncular_segment_block", batch)
8470                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
8471            })();
8472            if let Err(error) = outcome {
8473                self.rollback_observation("syncular_segment_block");
8474                self.overlay_dirty.set(was_dirty);
8475                return Err(error);
8476            }
8477            applied += block.len() as u32;
8478        }
8479        self.progress
8480            .update(|p| p.rows_processed = u64::from(applied));
8481        Ok(applied)
8482    }
8483
8484    /// §5.3 sqlite-image application: validate the in-file metadata
8485    /// against the descriptor, validate column names/order against the
8486    /// generated schema, run the §5.6 first-page clear when fresh, then
8487    /// replace-or-upsert every image row with its `_syncular_version`.
8488    /// Mechanics: the image lands in a temp file read through a second
8489    /// rusqlite connection (semantics identical to ATTACH + INSERT…SELECT;
8490    /// The image's writes and revision share one local transaction).
8491    fn apply_sqlite_segment(
8492        &mut self,
8493        sub_index: usize,
8494        bytes: &[u8],
8495        first_fresh_page: bool,
8496        row_count: i64,
8497        as_of_commit_seq: i64,
8498        scope_digest: &str,
8499    ) -> Result<u32, SectionError> {
8500        let invalid = |detail: &str| {
8501            SectionError::Abort(
8502                "sync.invalid_request".to_owned(),
8503                format!("sqlite segment rejected: {detail} (§5.3)"),
8504            )
8505        };
8506        let (sub_table, effective) = {
8507            let sub = &self.subs[sub_index];
8508            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
8509        };
8510        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
8511            SectionError::Abort(
8512                "sync.schema_mismatch".to_owned(),
8513                format!("subscription table {sub_table:?} is not in the client schema"),
8514            )
8515        })?;
8516
8517        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
8518        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
8519        let img = match rusqlite::Connection::open_with_flags(
8520            &path,
8521            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
8522        ) {
8523            Ok(conn) => conn,
8524            Err(_) => {
8525                let _ = std::fs::remove_file(&path);
8526                return Err(invalid("bytes do not open as a SQLite database"));
8527            }
8528        };
8529        let was_dirty = self.overlay_dirty.get();
8530        let outcome = (|| {
8531            self.begin_observation("syncular_image")
8532                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
8533            let mut batch = ChangeAccumulator::default();
8534            let cleared = first_fresh_page && self.scoped_rows_exist(&table.name, &effective);
8535            let result = self.apply_sqlite_image(
8536                &img,
8537                &table,
8538                first_fresh_page,
8539                &effective,
8540                row_count,
8541                as_of_commit_seq,
8542                scope_digest,
8543            )?;
8544            if result > 0 || cleared {
8545                batch.table(&table.name);
8546            }
8547            self.rebuild_overlay_if_dirty();
8548            self.finish_observation("syncular_image", batch)
8549                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
8550            Ok(result)
8551        })();
8552        if outcome.is_err() {
8553            self.rollback_observation("syncular_image");
8554            self.overlay_dirty.set(was_dirty);
8555        }
8556        drop(img);
8557        let _ = std::fs::remove_file(&path);
8558        outcome
8559    }
8560
8561    #[allow(clippy::too_many_arguments)]
8562    fn apply_sqlite_image(
8563        &mut self,
8564        img: &rusqlite::Connection,
8565        table: &crate::schema::TableSchema,
8566        first_fresh_page: bool,
8567        effective: &[(String, Vec<String>)],
8568        row_count: i64,
8569        as_of_commit_seq: i64,
8570        scope_digest: &str,
8571    ) -> Result<u32, SectionError> {
8572        let invalid = |detail: String| {
8573            SectionError::Abort(
8574                "sync.invalid_request".to_owned(),
8575                format!("sqlite segment rejected: {detail} (§5.3)"),
8576            )
8577        };
8578
8579        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
8580        //    one row).
8581        type MetaRow = (i64, String, i64, i64, String, i64, i64);
8582        let meta: MetaRow = img
8583            .query_row(
8584                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
8585                        \"scopeDigest\", \"rowCount\",
8586                        (SELECT count(*) FROM _syncular_segment)
8587                 FROM _syncular_segment",
8588                [],
8589                |row| {
8590                    Ok((
8591                        row.get(0)?,
8592                        row.get(1)?,
8593                        row.get(2)?,
8594                        row.get(3)?,
8595                        row.get(4)?,
8596                        row.get(5)?,
8597                        row.get(6)?,
8598                    ))
8599                },
8600            )
8601            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
8602        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
8603        if meta_count != 1 {
8604            return Err(invalid(format!(
8605                "_syncular_segment must contain exactly one row, found {meta_count}"
8606            )));
8607        }
8608        if format != 1 {
8609            return Err(invalid(format!("format {format}")));
8610        }
8611        if meta_table != table.name {
8612            return Err(invalid(format!("image table {meta_table:?}")));
8613        }
8614        if schema_version != i64::from(self.schema.version) {
8615            return Err(invalid(format!("schemaVersion {schema_version}")));
8616        }
8617        if pin != as_of_commit_seq {
8618            return Err(invalid(format!("asOfCommitSeq {pin}")));
8619        }
8620        if digest != scope_digest {
8621            return Err(invalid("scopeDigest mismatch".to_owned()));
8622        }
8623        if meta_rows != row_count {
8624            return Err(invalid(format!("rowCount {meta_rows}")));
8625        }
8626
8627        // 2. Column names and order vs the generated schema (§5.3 rule 3).
8628        let mut names: Vec<String> = Vec::new();
8629        {
8630            let mut stmt = img
8631                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
8632                .map_err(|_| invalid("image data table missing".to_owned()))?;
8633            let mut rows = stmt
8634                .query([])
8635                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
8636            while let Some(row) = rows
8637                .next()
8638                .map_err(|_| invalid("image data table unreadable".to_owned()))?
8639            {
8640                names.push(
8641                    row.get::<_, String>(1)
8642                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
8643                );
8644            }
8645        }
8646        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
8647        expected.push("_syncular_version");
8648        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
8649            return Err(SectionError::Abort(
8650                "sync.schema_mismatch".to_owned(),
8651                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
8652            ));
8653        }
8654
8655        // 3. §5.6 first-page clear (fail closed without a mapping), then
8656        //    replace-or-upsert with the image-carried server versions.
8657        if first_fresh_page {
8658            self.purge_scope_rows(&table.name, effective)
8659                .map_err(|()| SectionError::FailClosed)?;
8660        }
8661        // One cached INSERT statement on our side, one SELECT cursor on the
8662        // image side; every cell is validated against the declared column
8663        // type and bound BORROWED (no per-cell allocation, no per-row
8664        // statement re-preparation) — the Rust analogue of the TS client's
8665        // one prepared primary-key upsert per imported row.
8666        self.overlay_dirty.set(true);
8667        // A fresh whole-table load pays secondary-index maintenance per row;
8668        // dropping the base half's NON-unique indexes for the load and
8669        // recreating them after replaces that with one bulk sort per index.
8670        // Unique indexes stay in place because they are semantics, not just
8671        // speed. A collision outside the primary key aborts the section and
8672        // preserves the existing row. The DDL rides the image
8673        // savepoint (§5.3): an abort rolls the drop back.
8674        let bulk_indexes: Vec<&crate::schema::IndexSchema> = if first_fresh_page {
8675            table.indexes.iter().filter(|i| !i.unique).collect()
8676        } else {
8677            Vec::new()
8678        };
8679        for index in &bulk_indexes {
8680            let index_name = quote_ident(&format!("_syncular_base_{}", index.name));
8681            self.conn
8682                .execute(&format!("DROP INDEX IF EXISTS {index_name}"), [])
8683                .map_err(|e| invalid(e.to_string()))?;
8684        }
8685        let insert = self.insert_row_sql(&base_table(&table.name), table);
8686        let applied = {
8687            let mut ins = self
8688                .conn
8689                .prepare_cached(&insert)
8690                .map_err(|e| invalid(e.to_string()))?;
8691            let column_list: Vec<String> = names.iter().map(|n| quote_ident(n)).collect();
8692            let mut stmt = img
8693                .prepare(&format!(
8694                    "SELECT {} FROM {}",
8695                    column_list.join(", "),
8696                    quote_ident(&table.name)
8697                ))
8698                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
8699            let mut rows = stmt
8700                .query([])
8701                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
8702            let version_index = table.columns.len();
8703            let mut applied = 0u32;
8704            while let Some(row) = rows
8705                .next()
8706                .map_err(|_| invalid("image row unreadable".to_owned()))?
8707            {
8708                for (i, column) in table.columns.iter().enumerate() {
8709                    let cell = row
8710                        .get_ref(i)
8711                        .map_err(|_| invalid("image row unreadable".to_owned()))?;
8712                    let param = image_cell_param(column, cell).map_err(&invalid)?;
8713                    ins.raw_bind_parameter(i + 1, param)
8714                        .map_err(|e| invalid(e.to_string()))?;
8715                }
8716                let version: i64 = row
8717                    .get(version_index)
8718                    .map_err(|_| invalid("image row unreadable".to_owned()))?;
8719                if version < 1 {
8720                    return Err(invalid(format!(
8721                        "row _syncular_version must be >= 1, got {version}"
8722                    )));
8723                }
8724                ins.raw_bind_parameter(version_index + 1, version)
8725                    .map_err(|e| invalid(e.to_string()))?;
8726                ins.raw_execute().map_err(|e| invalid(e.to_string()))?;
8727                applied += 1;
8728                if applied.is_multiple_of(1024) {
8729                    self.progress
8730                        .update(|p| p.rows_processed = u64::from(applied));
8731                }
8732            }
8733            applied
8734        };
8735        for index in &bulk_indexes {
8736            let index_name = quote_ident(&format!("_syncular_base_{}", index.name));
8737            let cols_sql = index
8738                .columns
8739                .iter()
8740                .map(|c| quote_ident(c))
8741                .collect::<Vec<_>>()
8742                .join(", ");
8743            self.conn
8744                .execute(
8745                    &format!(
8746                        "CREATE INDEX IF NOT EXISTS {index_name} ON {} ({cols_sql})",
8747                        base_table(&table.name)
8748                    ),
8749                    [],
8750                )
8751                .map_err(|e| invalid(e.to_string()))?;
8752        }
8753        if i64::from(applied) != row_count {
8754            return Err(invalid(format!(
8755                "image holds {applied} rows, descriptor says {row_count}"
8756            )));
8757        }
8758        self.progress
8759            .update(|p| p.rows_processed = u64::from(applied));
8760        Ok(applied)
8761    }
8762
8763    // -- application-authorized local purge ---------------------------------------
8764
8765    fn compile_local_data_purge(
8766        &self,
8767        input: &LocalDataPurgeInput,
8768    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
8769        let invalid = |message: String| format!("sync.invalid_request: {message}");
8770        if input.purge_id.is_empty()
8771            || input.purge_id.len() > 128
8772            || !is_local_operation_code_like(&input.purge_id)
8773        {
8774            return Err(invalid(
8775                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
8776            ));
8777        }
8778        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
8779            return Err(invalid(format!(
8780                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
8781            )));
8782        }
8783
8784        let mut deduplicated: BTreeMap<
8785            String,
8786            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
8787        > = BTreeMap::new();
8788        for target in &input.targets {
8789            let table = self.schema.table(&target.table).ok_or_else(|| {
8790                invalid(format!(
8791                    "local purge names unknown table {:?}",
8792                    target.table
8793                ))
8794            })?;
8795            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
8796                return Err(invalid(format!(
8797                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
8798                    target.table
8799                )));
8800            }
8801            let mut selectors = Vec::with_capacity(target.selectors.len());
8802            let mut canonical_selectors = BTreeMap::new();
8803            for (column_name, raw_values) in &target.selectors {
8804                let Some((column_index, column)) = table
8805                    .columns
8806                    .iter()
8807                    .enumerate()
8808                    .find(|(_, column)| column.name == *column_name)
8809                else {
8810                    return Err(invalid(format!(
8811                        "local purge target {:?} names unknown column {:?}",
8812                        target.table, column_name
8813                    )));
8814                };
8815                let encrypted = table
8816                    .encrypted_columns
8817                    .iter()
8818                    .any(|candidate| candidate.index == column_index);
8819                if column.ty != ColumnType::String || encrypted {
8820                    return Err(invalid(format!(
8821                        "local purge selector {:?}.{:?} must be a plaintext string column",
8822                        target.table, column_name
8823                    )));
8824                }
8825                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
8826                    return Err(invalid(format!(
8827                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
8828                        target.table, column_name
8829                    )));
8830                }
8831                let mut values = raw_values.clone();
8832                values.sort();
8833                values.dedup();
8834                if values.iter().any(|value| {
8835                    value.is_empty()
8836                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
8837                        || !is_local_operation_code_like(value)
8838                }) {
8839                    return Err(invalid(format!(
8840                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
8841                    )));
8842                }
8843                selectors.push((column_name.clone(), values.clone()));
8844                canonical_selectors.insert(column_name.clone(), values);
8845            }
8846            let canonical = LocalDataPurgeTarget {
8847                table: target.table.clone(),
8848                selectors: canonical_selectors,
8849            };
8850            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
8851            deduplicated.insert(
8852                key,
8853                (
8854                    CompiledLocalDataPurgeTarget {
8855                        table: target.table.clone(),
8856                        selectors,
8857                    },
8858                    canonical,
8859                ),
8860            );
8861        }
8862        let targets = deduplicated
8863            .values()
8864            .map(|(compiled, _)| compiled.clone())
8865            .collect::<Vec<_>>();
8866        let canonical = deduplicated
8867            .values()
8868            .map(|(_, target)| target.clone())
8869            .collect::<Vec<_>>();
8870        let canonical_plan =
8871            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
8872        Ok((targets, canonical_plan))
8873    }
8874
8875    fn local_purge_base_row_ids(
8876        &self,
8877        targets: &[CompiledLocalDataPurgeTarget],
8878    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
8879        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
8880        for target in targets {
8881            let table = self
8882                .schema
8883                .table(&target.table)
8884                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
8885            let mut clauses = Vec::with_capacity(target.selectors.len());
8886            let mut params = Vec::new();
8887            for (column, values) in &target.selectors {
8888                clauses.push(format!(
8889                    "{} IN ({})",
8890                    quote_ident(column),
8891                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
8892                ));
8893                params.extend(values.iter().cloned().map(SqlValue::Text));
8894            }
8895            let sql = format!(
8896                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
8897                quote_ident(&table.primary_key),
8898                base_table(&target.table),
8899                clauses.join(" AND ")
8900            );
8901            let mut statement = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
8902            let rows = statement
8903                .query_map(rusqlite::params_from_iter(params), |row| {
8904                    row.get::<_, String>(0)
8905                })
8906                .map_err(|error| error.to_string())?;
8907            let ids = by_table.entry(target.table.clone()).or_default();
8908            for row in rows {
8909                ids.insert(row.map_err(|error| error.to_string())?);
8910            }
8911        }
8912        Ok(by_table)
8913    }
8914
8915    fn local_purge_values_match(
8916        target: &CompiledLocalDataPurgeTarget,
8917        values: &Map<String, Value>,
8918    ) -> bool {
8919        target.selectors.iter().all(|(column, allowed)| {
8920            values
8921                .get(column)
8922                .and_then(Value::as_str)
8923                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
8924        })
8925    }
8926
8927    /// Apply one host-authorized local security purge. The host MUST gate the
8928    /// corresponding subscriptions first; this primitive owns local SQLite
8929    /// cleanup and never grants/revokes server authority by itself.
8930    pub fn purge_local_data(
8931        &mut self,
8932        input: &LocalDataPurgeInput,
8933    ) -> Result<LocalDataPurgeResult, String> {
8934        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
8935        let meta_key = format!("localPurge:{}", input.purge_id);
8936        let applied_plan = self
8937            .conn
8938            .query_row(
8939                "SELECT value FROM _syncular_meta WHERE key = ?1",
8940                rusqlite::params![meta_key],
8941                |row| row.get::<_, String>(0),
8942            )
8943            .optional()
8944            .map_err(|error| error.to_string())?;
8945        if let Some(applied_plan) = applied_plan {
8946            if applied_plan != canonical_plan {
8947                return Err(format!(
8948                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
8949                    input.purge_id
8950                ));
8951            }
8952            return Ok(LocalDataPurgeResult {
8953                already_applied: true,
8954                purged_rows: 0,
8955                dropped_commits: 0,
8956            });
8957        }
8958
8959        let prior_outbox = self.outbox.clone();
8960        let prior_rejection_count = self.rejections.len();
8961        let prior_overlay_dirty = self.overlay_dirty.get();
8962        self.begin_observation("syncular_local_purge")?;
8963        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
8964            let row_ids = self.local_purge_base_row_ids(&targets)?;
8965            let doomed = self
8966                .outbox
8967                .iter()
8968                .filter(|commit| {
8969                    commit.ops.iter().any(|operation| {
8970                        let matching_targets = targets
8971                            .iter()
8972                            .filter(|target| target.table == operation.table)
8973                            .collect::<Vec<_>>();
8974                        if matching_targets.is_empty() {
8975                            return false;
8976                        }
8977                        if row_ids
8978                            .get(&operation.table)
8979                            .is_some_and(|ids| ids.contains(&operation.row_id))
8980                        {
8981                            return true;
8982                        }
8983                        operation.values.as_ref().is_some_and(|values| {
8984                            matching_targets
8985                                .iter()
8986                                .any(|target| Self::local_purge_values_match(target, values))
8987                        })
8988                    })
8989                })
8990                .cloned()
8991                .collect::<Vec<_>>();
8992            let doomed_ids = doomed
8993                .iter()
8994                .map(|commit| commit.client_commit_id.clone())
8995                .collect::<BTreeSet<_>>();
8996            let mut batch = ChangeAccumulator::default();
8997            let mut rejections = Vec::new();
8998            for commit in &doomed {
8999                for operation in &commit.ops {
9000                    batch.table(&operation.table);
9001                }
9002                let results = commit
9003                    .ops
9004                    .iter()
9005                    .enumerate()
9006                    .map(|(op_index, operation)| {
9007                        let rejection = RejectionRecord {
9008                            client_commit_id: commit.client_commit_id.clone(),
9009                            op_index: op_index as i32,
9010                            code: "client.local_data_purged".to_owned(),
9011                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
9012                            retryable: false,
9013                            details: None,
9014                            operation: Some(CommitOperation::from(operation)),
9015                        };
9016                        rejections.push(rejection.clone());
9017                        CommitOperationOutcome::Error { rejection }
9018                    })
9019                    .collect::<Vec<_>>();
9020                self.persist_commit_outcome(
9021                    &commit.client_commit_id,
9022                    CommitOutcomeStatus::Rejected,
9023                    &results,
9024                    Some(&commit.ops),
9025                )?;
9026                self.delete_outbox_persisted(&commit.client_commit_id)?;
9027            }
9028            if !doomed.is_empty() {
9029                self.outbox
9030                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
9031                self.rejections.extend(rejections);
9032                self.prune_commit_outcomes()?;
9033                self.overlay_dirty.set(true);
9034                batch.status = true;
9035                batch.rejections = true;
9036                batch.outcomes = true;
9037            }
9038
9039            let mut purged_rows = 0usize;
9040            for (table, ids) in &row_ids {
9041                if ids.is_empty() {
9042                    continue;
9043                }
9044                batch.table(table);
9045                for row_id in ids {
9046                    self.delete_base_row(table, row_id)?;
9047                    purged_rows += 1;
9048                }
9049            }
9050            self.rebuild_overlay_if_dirty();
9051            self.delete_unreferenced_cached_blobs();
9052            self.conn
9053                .execute(
9054                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
9055                    rusqlite::params![meta_key, canonical_plan],
9056                )
9057                .map_err(|error| error.to_string())?;
9058            Ok((
9059                batch,
9060                LocalDataPurgeResult {
9061                    already_applied: false,
9062                    purged_rows,
9063                    dropped_commits: doomed.len(),
9064                },
9065            ))
9066        })();
9067        let (batch, result) = match applied {
9068            Ok(value) => value,
9069            Err(error) => {
9070                self.rollback_observation("syncular_local_purge");
9071                self.outbox = prior_outbox;
9072                self.rejections.truncate(prior_rejection_count);
9073                self.overlay_dirty.set(prior_overlay_dirty);
9074                return Err(error);
9075            }
9076        };
9077        if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
9078            self.rollback_observation("syncular_local_purge");
9079            self.outbox = prior_outbox;
9080            self.rejections.truncate(prior_rejection_count);
9081            self.overlay_dirty.set(prior_overlay_dirty);
9082            return Err(error);
9083        }
9084        Ok(result)
9085    }
9086
9087    /// Application-authorized recovery of the replicated projection. Unlike a
9088    /// local security purge, this retains the entire outbox, device identity,
9089    /// lease, outcomes, subscription registrations, and protected bookkeeping.
9090    /// The projection reset, subscription rewind, optimistic replay, and
9091    /// idempotency marker share one savepoint for interruption safety.
9092    pub fn rebootstrap_local_data(
9093        &mut self,
9094        input: &LocalDataRebootstrapInput,
9095    ) -> Result<LocalDataRebootstrapResult, String> {
9096        if input.rebootstrap_id.is_empty()
9097            || input.rebootstrap_id.len() > 128
9098            || !is_local_operation_code_like(&input.rebootstrap_id)
9099        {
9100            return Err(
9101                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
9102                    .to_owned(),
9103            );
9104        }
9105        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
9106        if let Some(persisted) = self.get_meta_strict(&meta_key)? {
9107            let (retained_commits, reset_subscriptions) =
9108                decode_local_rebootstrap_receipt(&persisted)?;
9109            return Ok(LocalDataRebootstrapResult {
9110                already_applied: true,
9111                retained_commits,
9112                reset_subscriptions,
9113            });
9114        }
9115        if self.stopped || self.schema_floor.is_some() {
9116            return Err(
9117                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
9118                    .to_owned(),
9119            );
9120        }
9121
9122        let retained_commits = self.outbox.len();
9123        let reset_subscriptions = self.subs.len();
9124        let prior_subs = self.subs.clone();
9125        let prior_upgrading = self.upgrading;
9126        let prior_stopped = self.stopped;
9127        let prior_schema_floor = self.schema_floor.clone();
9128        let prior_overlay_dirty = self.overlay_dirty.get();
9129        let prior_sync_needed = self.sync_needed;
9130        let prior_sync_intents = self.sync_intent_queue.clone();
9131        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
9132
9133        self.begin_observation("syncular_local_rebootstrap")?;
9134        let mut batch = ChangeAccumulator::default();
9135        let applied = (|| -> Result<(), String> {
9136            self.run_schema_reset_observed(&mut batch, false)?;
9137            self.conn
9138                .execute(
9139                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
9140                    rusqlite::params![meta_key, receipt],
9141                )
9142                .map_err(|error| error.to_string())?;
9143            self.sync_needed = true;
9144            self.sync_intent_queue.push_back(SyncIntent::Interactive);
9145            batch.status = true;
9146            Ok(())
9147        })();
9148        if let Err(error) = applied {
9149            self.rollback_observation("syncular_local_rebootstrap");
9150            self.subs = prior_subs;
9151            self.upgrading = prior_upgrading;
9152            self.stopped = prior_stopped;
9153            self.schema_floor = prior_schema_floor;
9154            self.overlay_dirty.set(prior_overlay_dirty);
9155            self.sync_needed = prior_sync_needed;
9156            self.sync_intent_queue = prior_sync_intents;
9157            return Err(error);
9158        }
9159        if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
9160            self.rollback_observation("syncular_local_rebootstrap");
9161            self.subs = prior_subs;
9162            self.upgrading = prior_upgrading;
9163            self.stopped = prior_stopped;
9164            self.schema_floor = prior_schema_floor;
9165            self.overlay_dirty.set(prior_overlay_dirty);
9166            self.sync_needed = prior_sync_needed;
9167            self.sync_intent_queue = prior_sync_intents;
9168            return Err(error);
9169        }
9170
9171        Ok(LocalDataRebootstrapResult {
9172            already_applied: false,
9173            retained_commits,
9174            reset_subscriptions,
9175        })
9176    }
9177
9178    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
9179
9180    /// Delete base rows matching the effective scopes; `Err(())` = no local
9181    /// scope-column mapping for a key (the fail-closed case).
9182    fn purge_scope_rows(
9183        &mut self,
9184        table_name: &str,
9185        effective: &[(String, Vec<String>)],
9186    ) -> Result<(), ()> {
9187        if effective.is_empty() {
9188            return Ok(());
9189        }
9190        let table = self.schema.table(table_name).ok_or(())?.clone();
9191        let mut clauses = Vec::new();
9192        let mut params: Vec<SqlValue> = Vec::new();
9193        for (variable, values) in effective {
9194            let column = table.scope_column(variable).ok_or(())?;
9195            let placeholders: Vec<String> = values
9196                .iter()
9197                .map(|v| {
9198                    params.push(SqlValue::Text(v.clone()));
9199                    "?".to_owned()
9200                })
9201                .collect();
9202            clauses.push(format!(
9203                "{} IN ({})",
9204                quote_ident(column),
9205                placeholders.join(", ")
9206            ));
9207        }
9208        let sql = format!(
9209            "DELETE FROM {} WHERE {}",
9210            base_table(table_name),
9211            clauses.join(" AND ")
9212        );
9213        self.overlay_dirty.set(true);
9214        self.conn
9215            .execute(&sql, rusqlite::params_from_iter(params))
9216            .map_err(|_| ())?;
9217        Ok(())
9218    }
9219
9220    /// §3.3: drop pending commits whose upserts provably land in the
9221    /// revoked effective scopes — whole-commit, never per-operation.
9222    fn drop_doomed_outbox(
9223        &mut self,
9224        table_name: &str,
9225        effective: &[(String, Vec<String>)],
9226    ) -> Result<bool, String> {
9227        if effective.is_empty() {
9228            return Ok(false);
9229        }
9230        let Some(table) = self.schema.table(table_name).cloned() else {
9231            return Ok(false);
9232        };
9233        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
9234        for (variable, values) in effective {
9235            match table.scope_column(variable) {
9236                Some(column) => mappings.push((column, values)),
9237                None => return Ok(false), // not provable without a mapping
9238            }
9239        }
9240        let doomed: Vec<OutboxCommit> = self
9241            .outbox
9242            .iter()
9243            .filter(|commit| {
9244                commit.ops.iter().any(|op| {
9245                    op.upsert
9246                        && op.table == table_name
9247                        && op.values.as_ref().is_some_and(|values| {
9248                            mappings
9249                                .iter()
9250                                .all(|(column, allowed)| match values.get(*column) {
9251                                    Some(Value::String(s)) => allowed.contains(s),
9252                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
9253                                    _ => false,
9254                                })
9255                        })
9256                })
9257            })
9258            .cloned()
9259            .collect();
9260        if doomed.is_empty() {
9261            return Ok(false);
9262        }
9263        let mut rejections = Vec::new();
9264        for commit in &doomed {
9265            let results = commit
9266                .ops
9267                .iter()
9268                .enumerate()
9269                .map(|(op_index, operation)| {
9270                    let rejection = RejectionRecord {
9271                        client_commit_id: commit.client_commit_id.clone(),
9272                        op_index: op_index as i32,
9273                        code: "sync.scope_revoked".to_owned(),
9274                        message: "the commit was dropped because its effective scope was revoked"
9275                            .to_owned(),
9276                        retryable: false,
9277                        details: None,
9278                        operation: Some(CommitOperation::from(operation)),
9279                    };
9280                    rejections.push(rejection.clone());
9281                    CommitOperationOutcome::Error { rejection }
9282                })
9283                .collect::<Vec<_>>();
9284            self.persist_commit_outcome(
9285                &commit.client_commit_id,
9286                CommitOutcomeStatus::Rejected,
9287                &results,
9288                Some(&commit.ops),
9289            )?;
9290            self.delete_outbox_persisted(&commit.client_commit_id)?;
9291        }
9292        self.prune_commit_outcomes()?;
9293        let doomed_ids = doomed
9294            .iter()
9295            .map(|commit| commit.client_commit_id.as_str())
9296            .collect::<BTreeSet<_>>();
9297        self.outbox
9298            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
9299        self.rejections.extend(rejections);
9300        self.overlay_dirty.set(true);
9301        Ok(true)
9302    }
9303
9304    // -- blobs (§5.9) ----------------------------------------------------------------
9305
9306    /// §5.9.7: hash bytes into the content address, cache them, queue the
9307    /// upload (flushed before the next push, B4). Returns the canonical
9308    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
9309    /// column value.
9310    pub fn upload_blob(
9311        &mut self,
9312        bytes: &[u8],
9313        media_type: Option<String>,
9314        name: Option<String>,
9315    ) -> Result<Value, String> {
9316        let blob_id = blob_id_for(bytes);
9317        let now = self.clock_now_ms();
9318        let tx = self.conn.transaction().map_err(|e| e.to_string())?;
9319        tx.execute(
9320            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
9321             ON CONFLICT(blob_id) DO NOTHING",
9322            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
9323        )
9324        .map_err(|e| e.to_string())?;
9325        tx.execute(
9326            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
9327            rusqlite::params![blob_id, media_type, now],
9328        )
9329        .map_err(|e| e.to_string())?;
9330        tx.commit().map_err(|e| e.to_string())?;
9331        self.enforce_blob_cache_cap();
9332        let mut obj = Map::new();
9333        obj.insert("blobId".to_owned(), Value::from(blob_id));
9334        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
9335        if let Some(mt) = media_type {
9336            obj.insert("mediaType".to_owned(), Value::from(mt));
9337        }
9338        if let Some(n) = name {
9339            obj.insert("name".to_owned(), Value::from(n));
9340        }
9341        Ok(Value::Object(obj))
9342    }
9343
9344    /// §5.9.7: resolve an owned blob body without driver encoding. A
9345    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
9346    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
9347    pub fn fetch_blob_bytes(
9348        &mut self,
9349        transport: &mut dyn Transport,
9350        blob_id_or_ref: &str,
9351    ) -> Result<FetchedBlob, (String, String)> {
9352        let simple = |m: String| ("client.failed".to_owned(), m);
9353        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
9354            blob_id_or_ref.to_owned()
9355        } else {
9356            let value: Value = serde_json::from_str(blob_id_or_ref)
9357                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
9358            value
9359                .get("blobId")
9360                .and_then(Value::as_str)
9361                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
9362                .to_owned()
9363        };
9364        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
9365            return Ok(cached);
9366        }
9367        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
9368        // blob.not_found) verbatim so the harness can assert on it. The
9369        // authorized endpoint serves bytes inline OR (always-issue, presign
9370        // configured) a signed url the client fetches directly — no host auth,
9371        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
9372        #[cfg(feature = "bench-internals")]
9373        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
9374        let bytes = match transport
9375            .blob_download(&blob_id)
9376            .map_err(|e| (e.code, e.message))?
9377        {
9378            BlobDownload::Bytes(bytes) => bytes,
9379            BlobDownload::Url {
9380                url,
9381                url_expires_at_ms,
9382            } => {
9383                // §5.9.5: MUST NOT start a fetch at/past expiry.
9384                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
9385                    return Err((
9386                        "sync.segment_expired".to_owned(),
9387                        format!(
9388                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
9389                        ),
9390                    ));
9391                }
9392                transport
9393                    .fetch_blob_url(&url)
9394                    .map_err(|e| (e.code, e.message))?
9395            }
9396        };
9397        #[cfg(feature = "bench-internals")]
9398        drop(download_phase);
9399        #[cfg(feature = "bench-internals")]
9400        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
9401        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
9402        if blob_id_for(&bytes) != blob_id {
9403            return Err(simple(format!(
9404                "blob content address mismatch for {blob_id}"
9405            )));
9406        }
9407        #[cfg(feature = "bench-internals")]
9408        drop(validate_phase);
9409        #[cfg(feature = "bench-internals")]
9410        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
9411        let now = self.clock_now_ms();
9412        self.conn
9413            .execute(
9414                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
9415                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
9416            )
9417            .map_err(|e| simple(e.to_string()))?;
9418        self.conn
9419            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
9420            .map_err(|e| simple(e.to_string()))?;
9421        self.enforce_blob_cache_cap();
9422        #[cfg(feature = "bench-internals")]
9423        drop(insert_phase);
9424        self.get_cached_blob_bytes(&blob_id)
9425            .map_err(simple)?
9426            .ok_or_else(|| simple("blob cache write failed".to_owned()))
9427    }
9428
9429    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
9430        #[cfg(feature = "bench-internals")]
9431        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
9432        let mut stmt = self
9433            .conn
9434            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
9435            .map_err(|e| e.to_string())?;
9436        let mut rows = stmt
9437            .query(rusqlite::params![blob_id])
9438            .map_err(|e| e.to_string())?;
9439        if let Some(row) = rows.next().map_err(|e| e.to_string())? {
9440            let bytes: Vec<u8> = row.get(0).map_err(|e| e.to_string())?;
9441            let byte_length: i64 = row.get(1).map_err(|e| e.to_string())?;
9442            let media_type: Option<String> = row.get(2).map_err(|e| e.to_string())?;
9443            return Ok(Some(FetchedBlob {
9444                blob_id: blob_id.to_owned(),
9445                byte_length,
9446                bytes,
9447                media_type,
9448            }));
9449        }
9450        Ok(None)
9451    }
9452
9453    /// §5.9.7 B4: upload every queued blob before push.
9454    fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
9455        let pending: Vec<(String, Option<String>)> = {
9456            let mut stmt = self
9457                .conn
9458                .prepare(
9459                    "SELECT blob_id, media_type FROM (
9460                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
9461                      UNION ALL
9462                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
9463                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
9464                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
9465                    ) ORDER BY created_at_ms, blob_id",
9466                )
9467                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
9468            let rows = stmt
9469                .query_map([], |row| {
9470                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
9471                })
9472                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
9473            rows.collect::<Result<_, _>>()
9474                .map_err(|error| match error {
9475                    rusqlite::Error::InvalidColumnType(..)
9476                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
9477                        "sync.local_corrupt",
9478                        "Pending blob upload metadata is invalid",
9479                    ),
9480                    _ => TransportError::new("client.failed", error.to_string()),
9481                })?
9482        };
9483        for (blob_id, media_type) in pending {
9484            let stored: Option<(SqlValue, SqlValue)> = self
9485                .conn
9486                .query_row(
9487                    "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
9488                    rusqlite::params![blob_id],
9489                    |row| Ok((row.get(0)?, row.get(1)?)),
9490                )
9491                .optional()
9492                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
9493            let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
9494                return Err(TransportError::new(
9495                    "sync.local_corrupt",
9496                    "Pending blob upload body is missing or corrupt",
9497                ));
9498            };
9499            if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
9500                return Err(TransportError::new(
9501                    "sync.local_corrupt",
9502                    "Pending blob upload body is missing or corrupt",
9503                ));
9504            }
9505            self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
9506            self.conn
9507                .execute(
9508                    "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
9509                    rusqlite::params![blob_id],
9510                )
9511                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
9512        }
9513        Ok(())
9514    }
9515
9516    /// §5.9.3: upload one blob, preferring the presigned direct-to-storage
9517    /// grant when the transport supports it, else streaming through the direct
9518    /// host-authenticated endpoint (capability, not fallback). A `Url` grant
9519    /// PUTs direct with no host auth; on a grant PUT failure the client streams
9520    /// through the direct endpoint — a *different, host-authenticated
9521    /// capability*, not a fall-through of the grant's authority.
9522    fn upload_one(
9523        &self,
9524        transport: &mut dyn Transport,
9525        blob_id: &str,
9526        bytes: &[u8],
9527        media_type: Option<&str>,
9528    ) -> Result<(), TransportError> {
9529        match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
9530            BlobUploadGrant::Present => return Ok(()), // idempotent §5.9.3
9531            BlobUploadGrant::Url {
9532                url,
9533                url_expires_at_ms,
9534            } => {
9535                let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
9536                if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
9537                    return Ok(());
9538                }
9539                // Failed/expired grant PUT — stream through the direct endpoint.
9540            }
9541            BlobUploadGrant::None => {
9542                // No presign store — stream through the direct endpoint.
9543            }
9544        }
9545        transport.blob_upload(blob_id, bytes, media_type)
9546    }
9547
9548    fn visible_blob_ids_sql(&self) -> String {
9549        let mut selects = Vec::new();
9550        for table in &self.schema.tables {
9551            for column in table
9552                .columns
9553                .iter()
9554                .filter(|column| column.ty == ColumnType::BlobRef)
9555            {
9556                let identifier = quote_ident(&column.name);
9557                selects.push(format!(
9558                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
9559                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
9560                     AND json_type({identifier}, '$.blobId') = 'text'",
9561                    quote_ident(&table.name),
9562                ));
9563            }
9564        }
9565        if selects.is_empty() {
9566            "SELECT NULL AS blob_id WHERE 0".to_owned()
9567        } else {
9568            selects.join(" UNION ")
9569        }
9570    }
9571
9572    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
9573    /// staged uploads pin bodies directly; no derived refcount is stored.
9574    fn enforce_blob_cache_cap(&self) {
9575        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
9576            return;
9577        };
9578        let mut total: i64 = self
9579            .conn
9580            .query_row(
9581                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
9582                [],
9583                |row| row.get(0),
9584            )
9585            .unwrap_or(0);
9586        if total <= max_bytes {
9587            return;
9588        }
9589        let query = format!(
9590            "SELECT blob_id, byte_length FROM _syncular_blobs
9591             WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
9592               AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
9593               AND blob_id NOT IN ({})
9594             ORDER BY created_at_ms ASC, blob_id ASC",
9595            self.visible_blob_ids_sql(),
9596        );
9597        let candidates: Vec<(String, i64)> = {
9598            let Ok(mut stmt) = self.conn.prepare(&query) else {
9599                return;
9600            };
9601            let Ok(rows) = stmt.query_map([], |row| {
9602                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
9603            }) else {
9604                return;
9605            };
9606            rows.filter_map(Result::ok).collect()
9607        };
9608        for (blob_id, byte_length) in candidates {
9609            if total <= max_bytes {
9610                break;
9611            }
9612            let _ = self.conn.execute(
9613                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
9614                rusqlite::params![blob_id],
9615            );
9616            total -= byte_length;
9617        }
9618    }
9619
9620    fn delete_unreferenced_cached_blobs(&mut self) {
9621        #[cfg(feature = "bench-internals")]
9622        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
9623        if !self.schema_has_blobs() {
9624            return;
9625        }
9626        self.rebuild_overlay_if_dirty();
9627        let _ = self.conn.execute(
9628            &format!(
9629                "DELETE FROM _syncular_blobs
9630                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
9631                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
9632                   AND blob_id NOT IN ({})",
9633                self.visible_blob_ids_sql(),
9634            ),
9635            [],
9636        );
9637    }
9638
9639    // -- local row storage ----------------------------------------------------------
9640
9641    /// The cached per-table primary-key upsert SQL for `full_table` (see
9642    /// the `insert_sql` field: built once, reused per row).
9643    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
9644        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
9645            return sql.clone();
9646        }
9647        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
9648        columns.push(quote_ident("_syncular_version"));
9649        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
9650        let primary_key = quote_ident(&table.primary_key);
9651        let updates = columns
9652            .iter()
9653            .filter(|column| **column != primary_key)
9654            .map(|column| format!("{column}=excluded.{column}"))
9655            .collect::<Vec<_>>()
9656            .join(", ");
9657        let sql = format!(
9658            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
9659            columns.join(", "),
9660            placeholders.join(", ")
9661        );
9662        self.insert_sql
9663            .borrow_mut()
9664            .insert(full_table.to_owned(), sql.clone());
9665        sql
9666    }
9667
9668    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
9669        self.overlay_dirty.set(true);
9670        self.write_row(&base_table(table_name), table_name, row, version)
9671    }
9672
9673    fn write_row(
9674        &self,
9675        full_table: &str,
9676        table_name: &str,
9677        row: &Row,
9678        version: i64,
9679    ) -> Result<(), String> {
9680        #[cfg(feature = "bench-internals")]
9681        let _phase = self.benchmark_phases.start(Phase::RowWrite);
9682        let table = self
9683            .schema
9684            .table(table_name)
9685            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
9686        let sql = self.insert_row_sql(full_table, table);
9687        let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
9688        let params = row
9689            .iter()
9690            .map(RowParam::Cell)
9691            .chain(std::iter::once(RowParam::Version(version)));
9692        stmt.execute(rusqlite::params_from_iter(params))
9693            .map_err(|e| e.to_string())?;
9694        Ok(())
9695    }
9696
9697    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
9698        let table = self
9699            .schema
9700            .table(table_name)
9701            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
9702        self.overlay_dirty.set(true);
9703        let sql = format!(
9704            "DELETE FROM {} WHERE {}",
9705            base_table(table_name),
9706            row_id_predicate(table)
9707        );
9708        let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
9709        stmt.execute(rusqlite::params![row_id])
9710            .map_err(|e| e.to_string())?;
9711        Ok(())
9712    }
9713
9714    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
9715    /// the outbox changed since the last rebuild (the no-op sync round).
9716    fn rebuild_overlay_if_dirty(&mut self) {
9717        if self.overlay_dirty.get() {
9718            self.rebuild_overlay();
9719        }
9720    }
9721
9722    /// §7.1: local reads see outbox state applied optimistically — rebuild
9723    /// every visible table as (base server state) + (pending outbox replay
9724    /// on top). Optimistic rows carry version `-1`.
9725    fn rebuild_overlay(&mut self) {
9726        #[cfg(feature = "bench-internals")]
9727        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
9728        #[cfg(test)]
9729        self.overlay_rebuild_count
9730            .set(self.overlay_rebuild_count.get() + 1);
9731        self.exec("SAVEPOINT syncular_overlay");
9732        for table in self.schema.tables.clone() {
9733            for index in &table.fts_indexes {
9734                let _ = self.drop_fts_triggers(index);
9735            }
9736            let visible = visible_table(&table.name);
9737            let base = base_table(&table.name);
9738            self.exec(&format!("DELETE FROM {visible}"));
9739            self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
9740        }
9741        for commit in &self.outbox {
9742            self.apply_outbox_ops(&commit.ops);
9743        }
9744        for table in self.schema.tables.clone() {
9745            for index in &table.fts_indexes {
9746                let _ = self.rebuild_fts_projection(&table, index);
9747                let _ = self.create_fts_triggers(&table, index);
9748            }
9749        }
9750        self.exec("RELEASE syncular_overlay");
9751        self.overlay_dirty.set(false);
9752    }
9753
9754    fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
9755        #[cfg(feature = "bench-internals")]
9756        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
9757        for op in ops {
9758            #[cfg(feature = "bench-internals")]
9759            if let Some(phase) = &mut phase {
9760                phase.unit();
9761            }
9762            let Some(table) = self.schema.table(&op.table) else {
9763                continue;
9764            };
9765            if op.upsert {
9766                let Some(values) = op.values.as_ref() else {
9767                    continue;
9768                };
9769                let mut row: Row = Vec::with_capacity(table.columns.len());
9770                let mut ok = true;
9771                for column in &table.columns {
9772                    match json_to_column_value(column, values.get(&column.name)) {
9773                        Ok(v) => row.push(v),
9774                        Err(_) => {
9775                            ok = false;
9776                            break;
9777                        }
9778                    }
9779                }
9780                if ok {
9781                    let _ = self.write_row(&visible_table(&table.name), &table.name, &row, -1);
9782                }
9783            } else {
9784                let sql = format!(
9785                    "DELETE FROM {} WHERE {}",
9786                    visible_table(&table.name),
9787                    row_id_predicate(table)
9788                );
9789                let _ = self
9790                    .conn
9791                    .prepare_cached(&sql)
9792                    .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]));
9793            }
9794        }
9795    }
9796
9797    fn exec(&self, sql: &str) {
9798        let _ = self.conn.execute_batch(sql);
9799    }
9800
9801    // -- realtime (§8) ---------------------------------------------------------------
9802
9803    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
9804        if self.realtime_connected {
9805            return Ok(());
9806        }
9807        transport
9808            .realtime_connect_for_client(&self.client_id)
9809            .map_err(|e| format!("{}: {}", e.code, e.message))?;
9810        self.realtime_connected = true;
9811        Ok(())
9812    }
9813
9814    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
9815        if !self.realtime_connected {
9816            return;
9817        }
9818        let _ = transport.realtime_close();
9819        self.realtime_connected = false;
9820        self.presence.clear(); // §8.6.1: presence is per-connection
9821    }
9822
9823    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
9824    /// document for `scope_key`. Requires a live socket; the document is
9825    /// ephemeral (lost on disconnect). Authorization is the connection's
9826    /// registration (§8.6.3) — an unheld key is rejected loudly by the
9827    /// server with `presence.forbidden`.
9828    pub fn set_presence(
9829        &mut self,
9830        transport: &mut dyn Transport,
9831        scope_key: &str,
9832        doc: Option<&Value>,
9833    ) -> Result<(), String> {
9834        if !self.realtime_connected {
9835            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
9836        }
9837        let text = encode_presence_publish(scope_key, doc);
9838        transport
9839            .realtime_send(&text)
9840            .map_err(|e| format!("{}: {}", e.code, e.message))
9841    }
9842
9843    /// §8.6: the peers currently present on a scope key (ephemeral).
9844    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
9845        self.presence
9846            .get(scope_key)
9847            .map(|peers| peers.values().cloned().collect())
9848            .unwrap_or_default()
9849    }
9850
9851    /// §8.6 apply an inbound presence fanout to the local map.
9852    fn apply_presence(
9853        &mut self,
9854        scope_key: String,
9855        kind: Option<PresenceKind>,
9856        actor_id: Option<String>,
9857        client_id: Option<String>,
9858        doc: Option<Value>,
9859        error: Option<String>,
9860    ) {
9861        // The publisher-directed error variant is out-of-band; nothing to
9862        // record in the peer map.
9863        if error.is_some() {
9864            return;
9865        }
9866        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
9867            return;
9868        };
9869        let peer_key = format!("{actor_id} {client_id}");
9870        match kind {
9871            PresenceKind::Leave => {
9872                if let Some(peers) = self.presence.get_mut(&scope_key) {
9873                    peers.remove(&peer_key);
9874                    if peers.is_empty() {
9875                        self.presence.remove(&scope_key);
9876                    }
9877                }
9878            }
9879            _ => {
9880                let doc = match doc {
9881                    Some(Value::Object(_)) => doc.unwrap(),
9882                    _ => return,
9883                };
9884                self.presence.entry(scope_key).or_default().insert(
9885                    peer_key,
9886                    PresencePeer {
9887                        actor_id,
9888                        client_id,
9889                        doc,
9890                    },
9891                );
9892            }
9893        }
9894    }
9895
9896    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
9897    pub fn on_realtime_text(&mut self, text: &str) {
9898        match parse_control(text) {
9899            Ok(ControlMessage::Hello { requires_sync, .. }) => {
9900                if requires_sync {
9901                    // §8.1: pull before trusting the socket for continuity.
9902                    self.set_sync_needed(true, true);
9903                }
9904            }
9905            Ok(ControlMessage::Presence {
9906                scope_key,
9907                kind,
9908                actor_id,
9909                client_id,
9910                doc,
9911                error,
9912                ..
9913            }) => {
9914                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
9915            }
9916            Ok(ControlMessage::Wake { .. }) => {
9917                // §8.3: any wake-up means "run a pull soon", never data.
9918                self.set_sync_needed(true, true);
9919            }
9920            _ => {}
9921        }
9922    }
9923
9924    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
9925    /// a pull response per section; an unapplied delta is a wake-up.
9926    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
9927        if self.stopped {
9928            return;
9929        }
9930        #[cfg(feature = "bench-internals")]
9931        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
9932        let message = match decode_message(bytes) {
9933            Ok(m) if m.msg_kind == MsgKind::Response => m,
9934            _ => {
9935                self.set_sync_needed(true, true);
9936                return;
9937            }
9938        };
9939        #[cfg(feature = "bench-internals")]
9940        drop(decode_phase);
9941        let mut frames = message.frames.into_iter();
9942        let mut applied_cursor: Option<i64> = None;
9943        let mut any_covered = false;
9944        let mut dropped = false;
9945        while let Some(frame) = frames.next() {
9946            let Frame::SubStart {
9947                id,
9948                status,
9949                effective_scopes,
9950                ..
9951            } = frame
9952            else {
9953                continue;
9954            };
9955            let mut body = Vec::new();
9956            let mut next_cursor: Option<i64> = None;
9957            for inner in frames.by_ref() {
9958                match inner {
9959                    Frame::SubEnd {
9960                        next_cursor: nc, ..
9961                    } => {
9962                        next_cursor = Some(nc);
9963                        break;
9964                    }
9965                    Frame::Unknown { .. } => {}
9966                    other => body.push(other),
9967                }
9968            }
9969            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
9970                dropped = true;
9971                continue;
9972            };
9973            let sub = &self.subs[sub_index];
9974            // §8.2: only locally active, not mid-bootstrap subscriptions
9975            // apply; skipped sections are repaired by the next pull.
9976            if status != SubStatus::Active
9977                || sub.state != SubState::Active
9978                || sub.bootstrap_state.is_some()
9979                || !sub.synced_once
9980            {
9981                dropped = true;
9982                continue;
9983            }
9984            if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
9985                // Idempotent redelivery of an already-covered window.
9986                any_covered = true;
9987                continue;
9988            }
9989            let previous_effective = self.subs[sub_index].effective.clone();
9990            let previous_cursor = self.subs[sub_index].cursor;
9991            self.subs[sub_index].effective = Some(effective_scopes);
9992            let mut failed = false;
9993            for inner in body {
9994                match inner {
9995                    Frame::Commit {
9996                        tables, changes, ..
9997                    } => {
9998                        if self.apply_commit_frame(&tables, &changes).is_err() {
9999                            failed = true;
10000                            break;
10001                        }
10002                    }
10003                    _ => {
10004                        failed = true;
10005                        break;
10006                    }
10007                }
10008            }
10009            let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
10010                self.apply_sub_end(sub_index, cursor, None)
10011                    .ok()
10012                    .map(|()| cursor)
10013            });
10014            let Some(next_cursor) = committed else {
10015                self.subs[sub_index].effective = previous_effective;
10016                self.subs[sub_index].cursor = previous_cursor;
10017                dropped = true;
10018                continue;
10019            };
10020            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
10021        }
10022        if let Some(cursor) = applied_cursor {
10023            // §8.2 ack point: the highest applied SUB_END.nextCursor.
10024            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
10025            let _ = transport.realtime_send(&ack);
10026        } else if !any_covered || dropped {
10027            // §8.2: a delta not applied at all is treated as a wake-up.
10028            self.set_sync_needed(true, true);
10029        }
10030    }
10031
10032    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
10033    /// cursor across active, non-bootstrapping subscriptions that have
10034    /// synced at least once. No such subscription, no ack.
10035    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
10036        if !self.realtime_connected {
10037            return;
10038        }
10039        let floor = self
10040            .subs
10041            .iter()
10042            .filter(|s| {
10043                s.state == SubState::Active
10044                    && s.bootstrap_state.is_none()
10045                    && s.synced_once
10046                    && s.cursor >= 0
10047            })
10048            .map(|s| s.cursor)
10049            .min();
10050        if let Some(cursor) = floor {
10051            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
10052            let _ = transport.realtime_send(&ack);
10053        }
10054    }
10055}