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};
11#[cfg(test)]
12use ssp2::encode_message;
13#[cfg(test)]
14use std::cell::Cell;
15use std::cell::RefCell;
16use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
17
18use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
19use rusqlite::{Connection, OpenFlags, OptionalExtension};
20use serde::{Deserialize, Serialize};
21use serde_json::{Map, Value};
22use sha2::{Digest, Sha256};
23use ssp2::decode::WIRE_VERSION;
24use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
25use ssp2::primitives::RawJson;
26use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
27use ssp2::{decode_message, encode_presence_publish, parse_control, ControlMessage, PresenceKind};
28
29use crate::api::{
30    ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
31    ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
32    ClientDiagnosticsStorage, ClientLimits, CommandEffects, CommitOperation,
33    CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery, CommitOutcomeResolution,
34    CommitOutcomeStatus, ConflictRecord, CoverageSnapshot, DiagnosticLastChange,
35    DiagnosticLastRound, DiagnosticQueryFailure, DiagnosticRoundCounters, DiagnosticSubscription,
36    FetchedBlob, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, LocalDataPurgeTarget,
37    LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation, PresencePeer,
38    PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot, QueryValue,
39    RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RetainedCommitRow,
40    RetainedUniqueConflict, RowState, SchemaFloor, SubscriptionStateView, SyncIntent, SyncOutcome,
41    SyncReport, SyncStatusSnapshot, TableChange, WindowBase, WindowChange, WindowCoverage,
42    WindowState, WindowUnitRef, CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
43    MAX_DIAGNOSTIC_QUERY_FAILURES,
44};
45use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
46#[cfg(feature = "bench-internals")]
47use crate::bench::{Phase, Recorder};
48use crate::previous_version::{
49    capture_previous_version_from_replica, first_incompatibility,
50    reconcile_previous_version_at_boot, set_local_schema_descriptor,
51    sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
52    PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
53};
54use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
55use crate::transport::{BlobDownload, SegmentRequest, Transport, TransportError};
56use crate::values::{
57    bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
58    encode_sparse_row_json, full_row_values, json_to_column_value, json_to_scope_map,
59    normalize_values_casing, render_row_id_json, scope_map_to_json, sort_scope_map,
60};
61
62/// §4.2 default: rows and SQLite images, both applied in committed chunks.
63/// Bit 3 (signed URLs, §5.4) is
64/// added per transport capability at request-build time.
65const DEFAULT_ACCEPT: u8 = 0b0111;
66const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
67const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
68const ACCEPT_SQLITE: u8 = 1 << 2;
69const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
70const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
71
72/// §7.4.1 persisted local schema-version marker (`_syncular_meta` key).
73pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
74const LOCAL_REVISION_KEY: &str = "localRevision";
75const CLIENT_ID_KEY: &str = "clientId";
76const LEASE_STATE_KEY: &str = "leaseState";
77const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
78const LOG_EPOCH_KEY: &str = "logEpoch";
79/// Persisted fail-closed quarantine marker: present while a security preflight
80/// is pending and `activateSecurity` has yet to run. Storing it in the replica
81/// keeps the gate with the data it protects, so a rebuilt host handle reopening
82/// the same file returns in preflight even though its in-memory state is fresh.
83const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
84const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
85const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
86/// §7.4.4 client-local code: a pending outbox commit cannot re-encode under
87/// the new schema after a bump. Never a wire code (§10.3).
88const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
89/// §5.11 client-local code: an encode at the push seam resolved no usable key
90/// id or named an unknown key. Never a wire code (§10.3).
91const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
92
93#[derive(Debug, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase", deny_unknown_fields)]
95struct PersistedLocalDataRebootstrapReceipt {
96    version: u8,
97    retained_commits: u64,
98    reset_subscriptions: u64,
99}
100
101fn invalid_local_rebootstrap_receipt() -> String {
102    "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
103}
104
105fn encode_local_rebootstrap_receipt(
106    retained_commits: usize,
107    reset_subscriptions: usize,
108) -> Result<String, String> {
109    let retained_commits =
110        u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
111    let reset_subscriptions =
112        u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
113    if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
114        return Err(invalid_local_rebootstrap_receipt());
115    }
116    serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
117        version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
118        retained_commits,
119        reset_subscriptions,
120    })
121    .map_err(|_| invalid_local_rebootstrap_receipt())
122}
123
124fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
125    // Pre-0.15.36 markers proved application but did not retain the receipt.
126    if value == "v1" {
127        return Ok((0, 0));
128    }
129    let receipt: PersistedLocalDataRebootstrapReceipt =
130        serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
131    if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
132        || receipt.retained_commits > MAX_JS_SAFE_INTEGER
133        || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
134    {
135        return Err(invalid_local_rebootstrap_receipt());
136    }
137    Ok((
138        usize::try_from(receipt.retained_commits)
139            .map_err(|_| invalid_local_rebootstrap_receipt())?,
140        usize::try_from(receipt.reset_subscriptions)
141            .map_err(|_| invalid_local_rebootstrap_receipt())?,
142    ))
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146enum SubState {
147    Active,
148    Revoked,
149    Failed,
150}
151
152#[cfg(test)]
153mod observation_tests {
154    use super::*;
155    use crate::native_transport::HostTransport;
156    use crate::values::encode_row_json;
157    use serde_json::json;
158
159    fn client() -> SyncClient {
160        SyncClient::new(
161            "retry-test".to_owned(),
162            &json!({
163                "version": 1,
164                "tables": [{
165                    "name": "tasks",
166                    "primaryKey": "id",
167                    "columns": [
168                        { "name": "id", "type": "string", "nullable": false },
169                        { "name": "project_id", "type": "string", "nullable": false }
170                    ],
171                    "scopes": [{ "pattern": "project:{project_id}" }]
172                }]
173            }),
174            ClientLimits::default(),
175        )
176        .expect("test client")
177    }
178
179    #[cfg(feature = "e2ee")]
180    #[test]
181    fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
182        let schema = json!({
183            "version": 1,
184            "tables": [{
185                "name": "secrets",
186                "primaryKey": "id",
187                "columns": [
188                    { "name": "id", "type": "string", "nullable": false },
189                    { "name": "encryption_key_id", "type": "string", "nullable": true },
190                    { "name": "note", "type": "bytes", "nullable": true,
191                      "encrypted": true, "declaredType": "string" }
192                ],
193                "scopes": []
194            }]
195        });
196        let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
197            .expect("test client");
198        let mut config = crate::values::EncryptionConfig::default();
199        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
200        config
201            .key_id_columns
202            .insert("secrets".to_owned(), "encryption_key_id".to_owned());
203        client.set_encryption(config);
204        // The row exists, but its key selector is absent. The encrypted edit
205        // authors locally and fails at the push seam.
206        client
207            .write_base_row(
208                "secrets",
209                &vec![Some(ColumnValue::String("ghost".into())), None, None],
210                1,
211            )
212            .unwrap();
213        client.rebuild_overlay();
214        let commit_id = client
215            .patch(
216                "secrets",
217                "ghost",
218                Map::from_iter([("note".to_owned(), json!("x"))]),
219                None,
220            )
221            .expect("patch records without an author-time encrypt failure");
222        assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
223        // ...and the push seam raises a durable local rejection, not a throw.
224        assert!(client.drop_unencodable_outbox().unwrap());
225        assert!(client.pending_commit_ids().is_empty());
226        let rejections = client.rejections();
227        assert_eq!(rejections.len(), 1);
228        assert_eq!(rejections[0].code, "client.encrypt_failed");
229        assert_eq!(rejections[0].client_commit_id, commit_id);
230        assert_eq!(
231            client
232                .commit_outcome(&commit_id)
233                .unwrap()
234                .map(|outcome| outcome.status),
235            Some(CommitOutcomeStatus::Rejected)
236        );
237    }
238
239    #[test]
240    fn patch_with_a_scope_column_matches_the_stored_local_row() {
241        let mut client = client();
242        client.create_synced_tables().unwrap();
243        client
244            .conn
245            .execute(
246                "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
247                [],
248            )
249            .unwrap();
250
251        // §3.4 rule 5: a value equal to the stored local row is a no-op, so
252        // the scope column leaves the presence set.
253        client
254            .patch(
255                "tasks",
256                "t1",
257                Map::from_iter([
258                    ("project_id".to_owned(), json!("p1")),
259                    ("id".to_owned(), json!("t1")),
260                ]),
261                None,
262            )
263            .expect("an equal scope column is a no-op");
264        let values = client.outbox[0].ops[0]
265            .values
266            .clone()
267            .expect("the patch records values");
268        assert_eq!(values.get("id"), Some(&json!("t1")));
269        assert!(!values.contains_key("project_id"));
270
271        // A differing value stays rejected.
272        let differing = client.patch(
273            "tasks",
274            "t1",
275            Map::from_iter([("project_id".to_owned(), json!("p2"))]),
276            None,
277        );
278        assert!(differing
279            .expect_err("a differing scope column is rejected")
280            .contains("patch cannot write scope column"));
281
282        // An absent local row leaves nothing to prove equality against, so
283        // the patch fails closed.
284        let absent = client.patch(
285            "tasks",
286            "ghost",
287            Map::from_iter([("project_id".to_owned(), json!("p1"))]),
288            None,
289        );
290        assert!(absent
291            .expect_err("an absent local row is rejected")
292            .starts_with("sync.row_missing:"));
293        assert_eq!(client.pending_commit_ids().len(), 1);
294    }
295
296    #[test]
297    fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
298        let schema = json!({
299            "version": 1,
300            "tables": [
301                {
302                    "name": "tenants",
303                    "primaryKey": "tenant_id",
304                    "columns": [
305                        { "name": "tenant_id", "type": "string", "nullable": false },
306                        { "name": "body", "type": "string", "nullable": false }
307                    ],
308                    "scopes": [{ "pattern": "tenant:{tenant_id}" }]
309                },
310                {
311                    "name": "buckets",
312                    "primaryKey": "id",
313                    "columns": [
314                        { "name": "id", "type": "string", "nullable": false },
315                        { "name": "bucket", "type": "float", "nullable": false },
316                        { "name": "body", "type": "string", "nullable": false }
317                    ],
318                    "scopes": [{ "pattern": "bucket:{bucket}" }]
319                }
320            ]
321        });
322        let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
323            .expect("test client");
324        client.create_synced_tables().unwrap();
325
326        // A primary key that is also a scope column still needs a local base.
327        client
328            .conn
329            .execute(
330                "INSERT INTO tenants (tenant_id, body, _syncular_version) VALUES ('t1', 'seed', 1)",
331                [],
332            )
333            .unwrap();
334        client
335            .patch(
336                "tenants",
337                "t1",
338                Map::from_iter([
339                    ("tenant_id".to_owned(), json!("t1")),
340                    ("body".to_owned(), json!("created")),
341                ]),
342                None,
343            )
344            .expect("the primary-key scope column is proven equal by construction");
345
346        // A stored REAL scope value coerces from a supplied integer.
347        client
348            .conn
349            .execute(
350                "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
351                [],
352            )
353            .unwrap();
354        client
355            .patch(
356                "buckets",
357                "b1",
358                Map::from_iter([
359                    ("bucket".to_owned(), json!(2)),
360                    ("body".to_owned(), json!("coerced")),
361                ]),
362                None,
363            )
364            .expect("the stored REAL value coerces from a supplied integer");
365        let values = client.outbox[1].ops[0]
366            .values
367            .clone()
368            .expect("the patch records values");
369        assert!(!values.contains_key("bucket"));
370
371        // A differing value stays rejected.
372        let differing = client.patch(
373            "buckets",
374            "b1",
375            Map::from_iter([("bucket".to_owned(), json!(3))]),
376            None,
377        );
378        assert!(differing
379            .expect_err("a differing scope value is rejected")
380            .contains("patch cannot write scope column"));
381    }
382
383    #[cfg(feature = "e2ee")]
384    #[test]
385    fn stored_key_fallback_resolves_an_integer_primary_key() {
386        let schema = json!({
387            "version": 1,
388            "tables": [{
389                "name": "patients",
390                "primaryKey": "id",
391                "columns": [
392                    { "name": "id", "type": "integer", "nullable": false },
393                    { "name": "encryption_key_id", "type": "string", "nullable": true },
394                    { "name": "note", "type": "bytes", "nullable": true,
395                      "encrypted": true, "declaredType": "string" }
396                ],
397                "scopes": []
398            }]
399        });
400        let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
401            .expect("test client");
402        client.create_synced_tables().unwrap();
403        client
404            .conn
405            .execute(
406                "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
407                 VALUES (5, 'k1', NULL, 1)",
408                [],
409            )
410            .unwrap();
411        let mut config = crate::values::EncryptionConfig::default();
412        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
413        config
414            .key_id_columns
415            .insert("patients".to_owned(), "encryption_key_id".to_owned());
416        client.set_encryption(config);
417        let table = client.schema.table("patients").unwrap();
418        let fallback = client
419            .stored_key_fallback(table, "5")
420            .expect("the stored integer key resolves the fallback");
421        assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
422    }
423
424    #[test]
425    fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
426        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
427            {"name":"id","type":"string","nullable":false},
428            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
429        for state in ["absent", "cached", "pinned"] {
430            let path = std::env::temp_dir().join(format!(
431                "syncular-blob-stage-{}.sqlite",
432                uuid::Uuid::new_v4()
433            ));
434            let mut client = SyncClient::open_path(
435                "stage-test".into(),
436                &schema,
437                Default::default(),
438                path.to_str().unwrap(),
439            )
440            .unwrap();
441            let bytes = b"atomic stage";
442            if state != "absent" {
443                client
444                    .upload_blob(bytes, Some("text/plain".into()), None)
445                    .unwrap();
446                if state == "cached" {
447                    client
448                        .conn
449                        .execute("DELETE FROM _syncular_blob_uploads", [])
450                        .unwrap();
451                }
452            }
453            let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
454            client
455                .conn
456                .execute_batch(
457                    "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
458                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
459                 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
460                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
461                )
462                .unwrap();
463            let error = client
464                .upload_blob(bytes, Some("application/octet-stream".into()), None)
465                .unwrap_err();
466            assert!(error.contains("injected stage failure"), "{state}: {error}");
467            assert!(client.conn.is_autocommit());
468            let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
469            drop(client);
470            let mut reopened = SyncClient::open_path(
471                "stage-test".into(),
472                &schema,
473                Default::default(),
474                path.to_str().unwrap(),
475            )
476            .unwrap();
477            let durable = reopened
478                .query("SELECT * FROM _syncular_blobs", &[])
479                .unwrap();
480            reopened
481                .conn
482                .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
483                .unwrap();
484            let retried = reopened.upload_blob(bytes, None, None).unwrap();
485            assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
486            assert_eq!(
487                reopened
488                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
489                    .unwrap(),
490                vec![Map::from_iter([(
491                    "blob_id".into(),
492                    Value::from(blob_id_for(bytes)),
493                )])]
494            );
495            drop(reopened);
496            std::fs::remove_file(&path).unwrap();
497            assert_eq!(durable, bodies, "{state}: reopened body state");
498            assert_eq!(immediate, bodies, "{state}: immediate body state");
499        }
500    }
501
502    #[test]
503    fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
504        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
505            {"name":"id","type":"string","nullable":false},
506            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
507        for fault in [
508            "missing",
509            "body-type",
510            "length",
511            "hash",
512            "pin-metadata",
513            "read",
514            "pin-delete",
515        ] {
516            let path = std::env::temp_dir().join(format!(
517                "syncular-blob-upload-{}.sqlite",
518                uuid::Uuid::new_v4()
519            ));
520            let mut client = SyncClient::open_path(
521                "upload-test".into(),
522                &schema,
523                Default::default(),
524                path.to_str().unwrap(),
525            )
526            .unwrap();
527            let bytes = b"durable upload";
528            let ref_value = client
529                .upload_blob(bytes, Some("text/plain".into()), None)
530                .unwrap();
531            let commit = client
532                .mutate(vec![Mutation::Upsert {
533                    table: "attachments".into(),
534                    values: Map::from_iter([
535                        ("id".into(), Value::from("pending")),
536                        (
537                            "file".into(),
538                            Value::from(format!(
539                                "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
540                                blob_id_for(bytes),
541                                bytes.len()
542                            )),
543                        ),
544                    ]),
545                    base_version: None,
546                }])
547                .unwrap();
548            client
549                .conn
550                .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
551                .unwrap();
552            client.conn.execute_batch(match fault {
553                "missing" => "DELETE FROM _syncular_blobs",
554                "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
555                "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
556                "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
557                "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
558                "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
559                _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
560            }).unwrap();
561            let state = if fault == "read" {
562                Vec::new()
563            } else {
564                client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
565            };
566            let mut transport = CountingRealtimeTransport::default();
567            let error = client.flush_blob_uploads(&mut transport).unwrap_err();
568            assert_eq!(
569                error.code,
570                if matches!(fault, "read" | "pin-delete") {
571                    "client.failed"
572                } else {
573                    "sync.local_corrupt"
574                },
575                "{fault}"
576            );
577            assert_eq!(
578                transport.blob_uploads.len(),
579                usize::from(fault == "pin-delete"),
580                "{fault}: corrupt bytes must never be uploaded"
581            );
582            assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
583            if fault != "read" {
584                assert_eq!(
585                    client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
586                    state
587                );
588            } else {
589                client
590                    .conn
591                    .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
592                    .unwrap();
593            }
594            drop(client);
595            let mut reopened = SyncClient::open_path(
596                "upload-test".into(),
597                &schema,
598                Default::default(),
599                path.to_str().unwrap(),
600            )
601            .unwrap();
602            assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
603            if fault != "read" {
604                assert_eq!(
605                    reopened
606                        .query("SELECT * FROM _syncular_blobs", &[])
607                        .unwrap(),
608                    state
609                );
610            }
611            if fault == "pin-delete" {
612                reopened
613                    .conn
614                    .execute_batch("DROP TRIGGER fail_pin_delete")
615                    .unwrap();
616            }
617            if fault == "pin-metadata" {
618                reopened
619                    .conn
620                    .execute(
621                        "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
622                        [],
623                    )
624                    .unwrap();
625            }
626            reopened
627                .conn
628                .execute_batch(
629                    "DELETE FROM _syncular_blobs;
630                     INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
631                )
632                .unwrap();
633            transport.blob_uploads.clear();
634            reopened.flush_blob_uploads(&mut transport).unwrap();
635            assert_eq!(
636                transport.blob_uploads,
637                [(blob_id_for(bytes), bytes.to_vec())]
638            );
639            assert_eq!(
640                reopened
641                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
642                    .unwrap(),
643                Vec::<Map<String, Value>>::new()
644            );
645            assert_eq!(reopened.pending_commit_ids(), [commit]);
646            assert_eq!(
647                reopened
648                    .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
649                    .unwrap()
650                    .unwrap()
651                    .bytes,
652                bytes
653            );
654            drop(reopened);
655            std::fs::remove_file(path).unwrap();
656        }
657    }
658
659    #[test]
660    fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
661        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
662            {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
663            "scopes":[{"pattern":"project:{project_id}"}]}]});
664        for realtime in [false, true] {
665            for fault in [
666                "none",
667                "row",
668                "revision",
669                "commit",
670                "cursor",
671                "error",
672                "missing-end",
673            ] {
674                if realtime && fault == "missing-end" {
675                    continue;
676                }
677                let path = std::env::temp_dir()
678                    .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
679                let mut client = SyncClient::open_path(
680                    "frame-test".into(),
681                    &schema,
682                    Default::default(),
683                    path.to_str().unwrap(),
684                )
685                .unwrap();
686                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
687                client
688                    .subscribe(
689                        "tasks".into(),
690                        "tasks".into(),
691                        vec![("project_id".into(), vec!["p1".into()])],
692                        None,
693                    )
694                    .unwrap();
695                client.subs[0].cursor = 0;
696                client.subs[0].synced_once = true;
697                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
698                client.persist_sub(&client.subs[0]).unwrap();
699                client.realtime_state = RealtimeState::Connected;
700                client.realtime_reason_code = None;
701                client.drain_change_batches();
702                let mut transport = CountingRealtimeTransport::default();
703                let (_, meta) = client.build_request(false);
704                let mut response = Message {
705                    wire_version: WIRE_VERSION,
706                    msg_kind: MsgKind::Response,
707                    frames: vec![
708                        Frame::RespHeader {
709                            required_schema_version: None,
710                            latest_schema_version: None,
711                            log_epoch: Some("epoch-1".into()),
712                            reset_required: Some(false),
713                        },
714                        Frame::SubStart {
715                            id: "tasks".into(),
716                            status: SubStatus::Active,
717                            reason_code: "".into(),
718                            effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
719                            bootstrap: false,
720                        },
721                    ],
722                };
723                for (index, id) in ["first", "second"].iter().enumerate() {
724                    let values = Map::from_iter([
725                        ("id".into(), Value::from(*id)),
726                        ("project_id".into(), Value::from("p1")),
727                    ]);
728                    response.frames.push(Frame::Commit {
729                        commit_seq: index as i64 + 1,
730                        actor_id: "writer".into(),
731                        created_at_ms: 1,
732                        tables: vec!["tasks".into()],
733                        changes: vec![ssp2::model::Change {
734                            table_index: 0,
735                            row_id: (*id).into(),
736                            op: Op::Upsert,
737                            row_version: Some(index as i64 + 1),
738                            scopes: vec![("project_id".into(), "p1".into())],
739                            row: Some(
740                                encode_row_json(
741                                    client.schema.table("tasks").unwrap(),
742                                    id,
743                                    &values,
744                                    &client.encryption,
745                                )
746                                .unwrap(),
747                            ),
748                        }],
749                    });
750                }
751                response.frames.push(Frame::SubEnd {
752                    next_cursor: 2,
753                    bootstrap_state: None,
754                });
755                let valid = response.clone();
756                match fault {
757                    "row" => {
758                        let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
759                        let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
760                    },
761                    "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(),
762                    "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(),
763                    "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
764                    "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});},
765                    "missing-end" => {response.frames.truncate(3);},
766                    _ => {}
767                }
768                if realtime {
769                    client.on_realtime_binary(&mut transport, &encode_message(&response));
770                } else {
771                    assert_eq!(
772                        matches!(
773                            client.process_response(&mut transport, response, &meta),
774                            SyncOutcome::Ok(_)
775                        ),
776                        fault == "none",
777                        "{fault}"
778                    );
779                }
780                assert!(
781                    client.conn.is_autocommit(),
782                    "no open transaction after {realtime}/{fault}"
783                );
784                let count = if matches!(fault, "none" | "cursor") {
785                    2
786                } else {
787                    1
788                };
789                let expected_revision = count + u64::from(realtime && fault != "none");
790                assert_eq!(
791                    client.local_revision(),
792                    expected_revision,
793                    "{realtime}/{fault}"
794                );
795                let batches = client.drain_change_batches();
796                assert_eq!(
797                    batches.len(),
798                    expected_revision as usize,
799                    "{realtime}/{fault}"
800                );
801                let row_batches: Vec<_> = batches
802                    .iter()
803                    .filter(|batch| !batch.tables.is_empty())
804                    .collect();
805                assert_eq!(row_batches.len(), count as usize);
806                assert_eq!(
807                    row_batches
808                        .iter()
809                        .map(|b| b.revision.clone())
810                        .collect::<Vec<_>>(),
811                    (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
812                );
813                for table in ["tasks", "_syncular_base_tasks"] {
814                    assert_eq!(
815                        client
816                            .conn
817                            .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
818                                .get::<_, u64>(0))
819                            .unwrap(),
820                        count,
821                        "{realtime}/{fault}/{table}"
822                    );
823                }
824                assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
825                assert_eq!(
826                    transport
827                        .messages
828                        .iter()
829                        .filter(|m| m.contains("\"ack\""))
830                        .count(),
831                    usize::from(fault == "none")
832                );
833                if matches!(fault, "revision" | "commit" | "cursor") {
834                    client
835                        .conn
836                        .execute_batch("DROP TRIGGER fail_frame")
837                        .unwrap();
838                }
839                drop(client);
840                let mut reopened = SyncClient::open_path(
841                    "frame-test".into(),
842                    &schema,
843                    Default::default(),
844                    path.to_str().unwrap(),
845                )
846                .unwrap();
847                assert_eq!(reopened.local_revision(), expected_revision);
848                assert_eq!(
849                    reopened
850                        .query("SELECT id FROM tasks ORDER BY id", &[])
851                        .unwrap()
852                        .len(),
853                    count as usize
854                );
855                assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
856                let (_, meta) = reopened.build_request(false);
857                assert!(matches!(
858                    reopened.process_response(&mut transport, valid, &meta),
859                    SyncOutcome::Ok(_)
860                ));
861                assert_eq!(
862                    reopened
863                        .query("SELECT id FROM tasks ORDER BY id", &[])
864                        .unwrap()
865                        .len(),
866                    2
867                );
868                assert_eq!(reopened.subs[0].cursor, 2);
869                drop(reopened);
870                std::fs::remove_file(path).unwrap();
871            }
872        }
873    }
874
875    #[test]
876    fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
877        for status in [SubStatus::Reset, SubStatus::Revoked] {
878            for fault in ["subscription", "revision"] {
879                let mut client = client();
880                client
881                    .subscribe(
882                        "tasks".into(),
883                        "tasks".into(),
884                        vec![("project_id".into(), vec!["p1".into()])],
885                        None,
886                    )
887                    .unwrap();
888                client.subs[0].cursor = 41;
889                client.subs[0].synced_once = true;
890                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
891                client.persist_sub(&client.subs[0]).unwrap();
892                client
893                    .mutate(vec![Mutation::Upsert {
894                        table: "tasks".into(),
895                        values: Map::from_iter([
896                            ("id".into(), Value::from("pending")),
897                            ("project_id".into(), Value::from("p1")),
898                        ]),
899                        base_version: None,
900                    }])
901                    .unwrap();
902                let ids = client.pending_commit_ids();
903                let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
904                let revision = client.local_revision();
905                client.drain_change_batches();
906                let (_, meta) = client.build_request(false);
907                client.conn.execute_batch(if fault == "subscription" {
908                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
909                } else {
910                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
911                }).unwrap();
912                let mut transport = CountingRealtimeTransport::default();
913                let mut report = SyncReport::default();
914                // Reset changes window completeness and therefore needs a revision.
915                if status == SubStatus::Reset {
916                    client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
917                }
918                let result = client.process_section(
919                    &mut transport,
920                    "tasks",
921                    status,
922                    "sync.scope_revoked",
923                    vec![],
924                    vec![],
925                    Some((42, None)),
926                    &meta,
927                    &mut report,
928                );
929                assert!(result.is_err(), "{status:?}/{fault}");
930                assert!(client.conn.is_autocommit());
931                assert_eq!(client.subs[0].cursor, 41);
932                assert_eq!(client.subs[0].state, SubState::Active);
933                assert_eq!(client.pending_commit_ids(), ids);
934                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
935                assert!(client.rejections.is_empty());
936                assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
937                assert_eq!(client.local_revision(), revision);
938                assert!(client.drain_change_batches().is_empty());
939                assert!(report.revoked.is_empty() && report.resets.is_empty());
940            }
941        }
942    }
943
944    #[test]
945    fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
946        let mut client = client();
947        client
948            .subscribe(
949                "tasks".into(),
950                "tasks".into(),
951                vec![("project_id".into(), vec!["p1".into()])],
952                None,
953            )
954            .unwrap();
955        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
956        client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
957            CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
958        let row = |id: &str| ssp2::segment::SegmentRow {
959            server_version: 1,
960            values: vec![
961                Some(ColumnValue::String(id.into())),
962                Some(ColumnValue::String("p1".into())),
963            ],
964        };
965        let segment = RowsSegment {
966            table: "tasks".into(),
967            schema_version: 1,
968            columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
969            blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
970        };
971        assert!(client.apply_segment(0, &segment, true).is_err());
972        assert!(client.conn.is_autocommit());
973        assert_eq!(
974            client
975                .query("SELECT id FROM tasks ORDER BY id", &[])
976                .unwrap(),
977            vec![Map::from_iter([("id".into(), Value::from("first"))])]
978        );
979        assert_eq!(client.local_revision(), 1);
980        assert_eq!(client.drain_change_batches().len(), 1);
981        assert_eq!(client.subs[0].cursor, -1);
982        client
983            .conn
984            .execute_batch("DROP TRIGGER fail_block")
985            .unwrap();
986        assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
987        assert_eq!(client.local_revision(), 3);
988        assert_eq!(client.drain_change_batches().len(), 2);
989    }
990
991    #[test]
992    fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
993        assert_eq!(
994            client().diagnostics_storage().blob_cache_bytes_approx,
995            Some(0)
996        );
997        let client = SyncClient::new(
998            "diagnostic-storage".to_owned(),
999            &json!({
1000                "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
1001                    "columns": [{"name": "id", "type": "string", "nullable": false},
1002                        {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
1003            }),
1004            ClientLimits {
1005                blob_cache_max_bytes: Some(5),
1006                ..Default::default()
1007            },
1008        )
1009        .unwrap();
1010        let empty = client.diagnostics_storage();
1011        assert_eq!(empty.status, "healthy");
1012        assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
1013        assert_eq!(empty.retained_outcome_entries, Some(0));
1014        assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1015        assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1016        client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1017            INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1018                VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1019            INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1020                VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1021        let written = client.diagnostics_storage();
1022        assert_eq!(written.status, "pressure");
1023        assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1024        assert_eq!(written.retained_outcome_entries, Some(2));
1025        assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1026        assert_eq!(written.blob_cache_bytes_approx, Some(8));
1027        assert_eq!(
1028            written.pressure_reason_code.as_deref(),
1029            Some("client.blob_cache_over_limit")
1030        );
1031        client
1032            .conn
1033            .execute_batch(
1034                "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1035            DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1036            )
1037            .unwrap();
1038        let staged = client.diagnostics_storage();
1039        assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1040        assert_eq!(staged.retained_outcome_entries, Some(0));
1041        assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1042        assert_eq!(staged.status, "healthy");
1043        client.conn.execute_batch("ROLLBACK").unwrap();
1044        assert_eq!(client.diagnostics_storage(), written);
1045        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1046        let unreadable = client.diagnostics_storage();
1047        assert_eq!(unreadable.status, "unreadable");
1048        assert!(unreadable.database_bytes_approx.is_none());
1049        assert!(unreadable.pending_outbox_bytes_approx.is_none());
1050        assert!(unreadable.retained_outcome_entries.is_none());
1051        assert!(unreadable.retained_outcome_bytes_approx.is_none());
1052        assert!(unreadable.blob_cache_bytes_approx.is_none());
1053        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1054        let restored = client.diagnostics_storage();
1055        assert_eq!(restored.status, written.status);
1056        assert_eq!(
1057            restored.retained_outcome_bytes_approx,
1058            written.retained_outcome_bytes_approx
1059        );
1060        client.conn.set_prepared_statement_cache_capacity(0);
1061        assert_eq!(client.diagnostics_storage(), restored);
1062    }
1063
1064    /// Answers every round with one server `ERROR` frame (§1.6).
1065    struct ServerErrorTransport {
1066        code: &'static str,
1067        retryable: bool,
1068    }
1069
1070    impl Transport for ServerErrorTransport {
1071        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1072            Ok(encode_message(&Message {
1073                wire_version: WIRE_VERSION,
1074                msg_kind: MsgKind::Response,
1075                frames: vec![
1076                    Frame::RespHeader {
1077                        required_schema_version: None,
1078                        latest_schema_version: None,
1079                        log_epoch: Some("epoch-1".to_owned()),
1080                        reset_required: Some(false),
1081                    },
1082                    Frame::Error {
1083                        code: self.code.to_owned(),
1084                        message: "server error".to_owned(),
1085                        category: "internal".to_owned(),
1086                        retryable: self.retryable,
1087                        recommended_action: "retryLater".to_owned(),
1088                        details: None,
1089                    },
1090                ],
1091            }))
1092        }
1093
1094        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1095            self.sync(request)
1096        }
1097
1098        fn download_segment(
1099            &mut self,
1100            _request: &SegmentRequest,
1101            _on_progress: &mut dyn FnMut(u64),
1102        ) -> Result<Vec<u8>, TransportError> {
1103            Err(TransportError::new("sync.transport_failed", "offline"))
1104        }
1105
1106        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1107            Ok(())
1108        }
1109
1110        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1111            Ok(())
1112        }
1113
1114        fn realtime_close(&mut self) -> Result<(), TransportError> {
1115            Ok(())
1116        }
1117    }
1118
1119    #[test]
1120    fn retryable_server_error_schedules_a_background_retry() {
1121        let mut client = client();
1122        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1123        let mut transport = ServerErrorTransport {
1124            code: "sync.internal_error",
1125            retryable: true,
1126        };
1127        assert!(matches!(
1128            client.sync(&mut transport),
1129            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1130        ));
1131        assert!(matches!(
1132            client.drain_sync_intents().as_slice(),
1133            [SyncIntent::Background { delay_ms: 250 }]
1134        ));
1135        assert_eq!(
1136            client.progress().snapshot().unwrap().retry_delay_ms,
1137            Some(250)
1138        );
1139        let mut transport = ServerErrorTransport {
1140            code: "sync.invalid_request",
1141            retryable: false,
1142        };
1143        assert!(matches!(
1144            client.sync(&mut transport),
1145            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1146        ));
1147        assert!(client.drain_sync_intents().is_empty());
1148        let progress = client.progress().snapshot().unwrap();
1149        assert_eq!(progress.state, ProgressState::Failed);
1150        assert_eq!(progress.retry_delay_ms, None);
1151    }
1152
1153    #[test]
1154    fn failed_progress_retry_delay_doubles_to_the_cap() {
1155        let mut client = client();
1156        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1157        let mut transport = ServerErrorTransport {
1158            code: "sync.internal_error",
1159            retryable: true,
1160        };
1161        let delays: Vec<Option<u64>> = (0..9)
1162            .map(|_| {
1163                client.sync(&mut transport);
1164                client.progress().snapshot().unwrap().retry_delay_ms
1165            })
1166            .collect();
1167        assert_eq!(
1168            delays,
1169            [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
1170        );
1171    }
1172
1173    #[test]
1174    fn background_retry_deadlines_back_off_and_reset() {
1175        let mut client = client();
1176        client.schedule_background_retry();
1177        assert!(matches!(
1178            client.drain_sync_intents().as_slice(),
1179            [SyncIntent::Background { delay_ms: 250 }]
1180        ));
1181        client.schedule_background_retry();
1182        assert!(matches!(
1183            client.drain_sync_intents().as_slice(),
1184            [SyncIntent::Background { delay_ms: 500 }]
1185        ));
1186        client.reset_background_retry();
1187        client.schedule_background_retry();
1188        assert!(matches!(
1189            client.drain_sync_intents().as_slice(),
1190            [SyncIntent::Background { delay_ms: 250 }]
1191        ));
1192    }
1193
1194    #[test]
1195    fn log_epoch_reset_requests_an_immediate_follow_up_round() {
1196        let mut client = client();
1197        client
1198            .subscribe(
1199                "epoch-tasks".to_owned(),
1200                "tasks".to_owned(),
1201                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1202                None,
1203            )
1204            .expect("subscribe");
1205        client.drain_sync_intents();
1206
1207        client
1208            .run_log_epoch_reset("epoch-2")
1209            .expect("reset partition log epoch");
1210
1211        assert!(matches!(
1212            client.drain_sync_intents().as_slice(),
1213            [SyncIntent::Interactive]
1214        ));
1215    }
1216
1217    #[derive(Default)]
1218    struct CountingRealtimeTransport {
1219        connects: usize,
1220        closes: usize,
1221        messages: Vec<String>,
1222        blob_uploads: Vec<(String, Vec<u8>)>,
1223        blob_download: Option<Vec<u8>>,
1224        sync_calls: usize,
1225        realtime_sync_calls: usize,
1226        refuse_connect: bool,
1227    }
1228
1229    impl Transport for CountingRealtimeTransport {
1230        fn blob_upload(
1231            &mut self,
1232            blob_id: &str,
1233            bytes: &[u8],
1234            _media_type: Option<&str>,
1235        ) -> Result<(), TransportError> {
1236            self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1237            Ok(())
1238        }
1239
1240        fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1241            self.blob_download
1242                .clone()
1243                .map(BlobDownload::Bytes)
1244                .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1245        }
1246
1247        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1248            self.sync_calls += 1;
1249            Err(TransportError::new("sync.transport_failed", "offline"))
1250        }
1251
1252        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1253            self.realtime_sync_calls += 1;
1254            Err(TransportError::new("sync.transport_failed", "offline"))
1255        }
1256
1257        fn download_segment(
1258            &mut self,
1259            _request: &SegmentRequest,
1260            _on_progress: &mut dyn FnMut(u64),
1261        ) -> Result<Vec<u8>, TransportError> {
1262            Err(TransportError::new("sync.transport_failed", "offline"))
1263        }
1264
1265        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1266            self.connects += 1;
1267            if self.refuse_connect {
1268                return Err(TransportError::new("transport.failed", "refused"));
1269            }
1270            Ok(())
1271        }
1272
1273        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1274            self.messages.push(text.to_owned());
1275            Ok(())
1276        }
1277
1278        fn realtime_close(&mut self) -> Result<(), TransportError> {
1279            self.closes += 1;
1280            Ok(())
1281        }
1282    }
1283
1284    #[test]
1285    fn blob_results_own_downloaded_bytes() {
1286        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1287            {"name":"id","type":"string","nullable":false},
1288            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1289        let bytes = b"owned typed result".to_vec();
1290        let blob_id = blob_id_for(&bytes);
1291        let mut transport = CountingRealtimeTransport {
1292            blob_download: Some(bytes.clone()),
1293            ..Default::default()
1294        };
1295        let result = {
1296            let mut client =
1297                SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1298                    .unwrap();
1299            let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1300            assert_eq!(result.blob_id, blob_id);
1301            assert_eq!(result.byte_length, bytes.len() as i64);
1302            assert_eq!(result.bytes, bytes);
1303            assert_eq!(result.media_type, None);
1304            assert_eq!(
1305                client
1306                    .conn
1307                    .query_row(
1308                        "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1309                        rusqlite::params![blob_id],
1310                        |row| row.get::<_, i64>(0),
1311                    )
1312                    .unwrap(),
1313                0
1314            );
1315            result
1316        };
1317        assert_eq!(result.bytes, bytes);
1318
1319        let mut client =
1320            SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1321        assert_eq!(
1322            client
1323                .fetch_blob_bytes(&mut transport, "not json")
1324                .unwrap_err(),
1325            (
1326                "client.failed".to_owned(),
1327                "blob ref is not JSON".to_owned()
1328            )
1329        );
1330    }
1331
1332    #[test]
1333    fn visible_blob_references_survive_cache_trimming() {
1334        let schema = json!({"version":1,"tables":[
1335            {"name":"attachments","primaryKey":"id","columns":[
1336                {"name":"id","type":"string","nullable":false},
1337                {"name":"body","type":"blob_ref","nullable":true},
1338                {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
1339            {"name":"avatars","primaryKey":"id","columns":[
1340                {"name":"id","type":"string","nullable":false},
1341                {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
1342        ]});
1343        let bytes = b"referenced above cap".to_vec();
1344        let blob_id = blob_id_for(&bytes);
1345        let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
1346        let mut client = SyncClient::new(
1347            "visible-blob-refs".to_owned(),
1348            &schema,
1349            ClientLimits {
1350                blob_cache_max_bytes: Some(1),
1351                ..Default::default()
1352            },
1353        )
1354        .unwrap();
1355        client.conn.execute(
1356            "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
1357            rusqlite::params![reference, reference, reference, reference],
1358        ).unwrap();
1359        client
1360            .conn
1361            .execute(
1362                "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
1363                rusqlite::params![reference],
1364            )
1365            .unwrap();
1366        client.conn.execute(
1367            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
1368            [],
1369        ).unwrap();
1370        let mut transport = CountingRealtimeTransport {
1371            blob_download: Some(bytes.clone()),
1372            ..Default::default()
1373        };
1374
1375        assert_eq!(
1376            client
1377                .fetch_blob_bytes(&mut transport, &blob_id)
1378                .unwrap()
1379                .bytes,
1380            bytes
1381        );
1382        let rows = client
1383            .conn
1384            .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1385            .unwrap()
1386            .query_map([], |row| row.get::<_, String>(0))
1387            .unwrap()
1388            .collect::<Result<Vec<_>, _>>()
1389            .unwrap();
1390        assert_eq!(rows, vec![blob_id]);
1391    }
1392
1393    #[test]
1394    fn realtime_connection_ownership_is_idempotent() {
1395        let mut client = client();
1396        let mut transport = CountingRealtimeTransport {
1397            connects: 0,
1398            closes: 0,
1399            ..Default::default()
1400        };
1401        client
1402            .connect_realtime(&mut transport)
1403            .expect("first connect");
1404        client
1405            .connect_realtime(&mut transport)
1406            .expect("idempotent connect");
1407        assert_eq!(transport.connects, 1);
1408        client.disconnect_realtime(&mut transport);
1409        client.disconnect_realtime(&mut transport);
1410        assert_eq!(transport.closes, 1);
1411        client
1412            .connect_realtime(&mut transport)
1413            .expect("deliberate reconnect");
1414        assert_eq!(transport.connects, 2);
1415    }
1416
1417    #[test]
1418    fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
1419        let mut client = client();
1420        client
1421            .set_realtime_policy(RealtimePolicy::Required)
1422            .expect("set policy");
1423        let mut transport = CountingRealtimeTransport::default();
1424        match client.sync(&mut transport) {
1425            SyncOutcome::RealtimeUnavailable {
1426                state,
1427                reason_code,
1428                retry_delay_ms,
1429            } => {
1430                assert_eq!(state, RealtimeState::Disconnected);
1431                assert_eq!(reason_code, None);
1432                assert_eq!(retry_delay_ms, 250);
1433            }
1434            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1435        }
1436        assert_eq!(transport.sync_calls, 0);
1437        assert!(matches!(
1438            client.drain_sync_intents().as_slice(),
1439            [SyncIntent::Background { delay_ms: 250 }]
1440        ));
1441        let snapshot = client
1442            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1443            .expect("diagnostics");
1444        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1445        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
1446        assert_eq!(snapshot.host.realtime_reason_code, None);
1447        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1448        assert_eq!(
1449            snapshot
1450                .last_round
1451                .as_ref()
1452                .and_then(|round| round.error_code.clone()),
1453            Some(REALTIME_UNAVAILABLE_CODE.to_owned())
1454        );
1455    }
1456
1457    #[test]
1458    fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
1459        let mut client = client();
1460        client
1461            .set_realtime_policy(RealtimePolicy::Required)
1462            .expect("set policy");
1463        let mut transport = CountingRealtimeTransport {
1464            refuse_connect: true,
1465            ..Default::default()
1466        };
1467        assert!(client.connect_realtime(&mut transport).is_err());
1468        assert_eq!(client.realtime_state(), RealtimeState::Refused);
1469        let snapshot = client
1470            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1471            .expect("diagnostics");
1472        assert_eq!(
1473            snapshot.host.realtime_reason_code.as_deref(),
1474            Some("transport.failed")
1475        );
1476        match client.sync(&mut transport) {
1477            SyncOutcome::RealtimeUnavailable {
1478                state, reason_code, ..
1479            } => {
1480                assert_eq!(state, RealtimeState::Refused);
1481                assert_eq!(reason_code.as_deref(), Some("transport.failed"));
1482            }
1483            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1484        }
1485        assert_eq!(transport.sync_calls, 0);
1486        assert_eq!(transport.realtime_sync_calls, 0);
1487    }
1488
1489    #[test]
1490    fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
1491        let mut client = client();
1492        client
1493            .set_realtime_policy(RealtimePolicy::Required)
1494            .expect("set policy");
1495        let mut transport = CountingRealtimeTransport::default();
1496        client.connect_realtime(&mut transport).expect("connect");
1497        assert_eq!(client.realtime_state(), RealtimeState::Connected);
1498        assert!(
1499            client.set_realtime_policy(RealtimePolicy::Off).is_err(),
1500            "a policy change cannot silently re-route a connected socket"
1501        );
1502        assert!(matches!(
1503            client.sync(&mut transport),
1504            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
1505        ));
1506        assert_eq!(client.realtime_state(), RealtimeState::Lost);
1507        assert_eq!(transport.realtime_sync_calls, 1);
1508        assert_eq!(transport.sync_calls, 0);
1509        let snapshot = client
1510            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1511            .expect("diagnostics");
1512        assert_eq!(
1513            snapshot.host.realtime_reason_code.as_deref(),
1514            Some("sync.transport_failed")
1515        );
1516        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1517        match client.sync(&mut transport) {
1518            SyncOutcome::RealtimeUnavailable {
1519                state, reason_code, ..
1520            } => {
1521                assert_eq!(state, RealtimeState::Lost);
1522                assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
1523            }
1524            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1525        }
1526        assert_eq!(transport.sync_calls, 0);
1527        client.disconnect_realtime(&mut transport);
1528        assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
1529        assert_eq!(transport.closes, 1);
1530        client.connect_realtime(&mut transport).expect("reconnect");
1531        assert_eq!(client.realtime_state(), RealtimeState::Connected);
1532        assert_eq!(transport.connects, 2);
1533    }
1534
1535    #[test]
1536    fn off_policy_uses_http_and_refuses_an_explicit_connect() {
1537        let mut client = client();
1538        client
1539            .set_realtime_policy(RealtimePolicy::Off)
1540            .expect("set policy");
1541        let mut transport = CountingRealtimeTransport::default();
1542        assert!(client.connect_realtime(&mut transport).is_err());
1543        assert_eq!(client.realtime_state(), RealtimeState::Disabled);
1544        assert!(matches!(
1545            client.sync(&mut transport),
1546            SyncOutcome::Failed { .. }
1547        ));
1548        assert_eq!(transport.sync_calls, 1);
1549        assert_eq!(transport.realtime_sync_calls, 0);
1550        let snapshot = client
1551            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1552            .expect("diagnostics");
1553        assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
1554        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
1555    }
1556
1557    #[test]
1558    fn optional_policy_keeps_the_http_round() {
1559        let mut client = client();
1560        let mut transport = CountingRealtimeTransport::default();
1561        assert!(matches!(
1562            client.sync(&mut transport),
1563            SyncOutcome::Failed { .. }
1564        ));
1565        assert_eq!(transport.sync_calls, 1);
1566        assert_eq!(transport.realtime_sync_calls, 0);
1567        let snapshot = client
1568            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1569            .expect("diagnostics");
1570        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1571        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
1572    }
1573
1574    #[test]
1575    fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1576        let mut client = client();
1577        client
1578            .subscribe(
1579                "repair-tasks".to_owned(),
1580                "tasks".to_owned(),
1581                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1582                None,
1583            )
1584            .expect("subscribe");
1585        {
1586            let sub = client
1587                .subs
1588                .iter_mut()
1589                .find(|sub| sub.id == "repair-tasks")
1590                .expect("subscription");
1591            sub.cursor = 42;
1592            sub.synced_once = true;
1593            let persisted = sub.clone();
1594            client.persist_sub(&persisted).unwrap();
1595        }
1596        for table in ["_syncular_base_tasks", "tasks"] {
1597            client
1598                .conn
1599                .execute(
1600                    &format!(
1601                        "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1602                    ),
1603                    rusqlite::params!["server-row", "p1"],
1604                )
1605                .expect("seed server row");
1606        }
1607        let pending = client
1608            .mutate(vec![Mutation::Upsert {
1609                table: "tasks".to_owned(),
1610                values: Map::from_iter([
1611                    ("id".to_owned(), Value::from("offline-row")),
1612                    ("project_id".to_owned(), Value::from("p1")),
1613                ]),
1614                base_version: None,
1615            }])
1616            .expect("queue offline work");
1617        client.drain_change_batches();
1618        client.drain_sync_intents();
1619
1620        assert_eq!(
1621            client
1622                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1623                    rebootstrap_id: "support-case-001".to_owned(),
1624                })
1625                .expect("rebootstrap"),
1626            LocalDataRebootstrapResult {
1627                already_applied: false,
1628                retained_commits: 1,
1629                reset_subscriptions: 1,
1630            }
1631        );
1632        let visible_ids = client
1633            .conn
1634            .prepare("SELECT id FROM tasks ORDER BY id")
1635            .expect("prepare visible ids")
1636            .query_map([], |row| row.get::<_, String>(0))
1637            .expect("query visible ids")
1638            .collect::<Result<Vec<_>, _>>()
1639            .expect("collect visible ids");
1640        assert_eq!(visible_ids, vec!["offline-row"]);
1641        assert_eq!(client.pending_commit_ids(), vec![pending]);
1642        assert_eq!(
1643            client
1644                .subscription_state("repair-tasks")
1645                .expect("subscription")
1646                .cursor,
1647            -1
1648        );
1649        assert!(client.upgrading());
1650        assert!(client.sync_needed());
1651        assert!(matches!(
1652            client.drain_sync_intents().as_slice(),
1653            [SyncIntent::Interactive]
1654        ));
1655        assert_eq!(client.drain_change_batches().len(), 1);
1656
1657        assert_eq!(
1658            client
1659                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1660                    rebootstrap_id: "support-case-001".to_owned(),
1661                })
1662                .expect("idempotent retry"),
1663            LocalDataRebootstrapResult {
1664                already_applied: true,
1665                retained_commits: 1,
1666                reset_subscriptions: 1,
1667            }
1668        );
1669        assert!(client.drain_change_batches().is_empty());
1670    }
1671
1672    #[test]
1673    fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1674        let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1675        assert_eq!(
1676            decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1677            (3, 4)
1678        );
1679        assert_eq!(
1680            decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1681            (0, 0)
1682        );
1683        for malformed in [
1684            "",
1685            "{}",
1686            r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1687            r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1688            r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1689        ] {
1690            assert_eq!(
1691                decode_local_rebootstrap_receipt(malformed)
1692                    .expect_err("malformed receipt must fail"),
1693                "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1694            );
1695        }
1696    }
1697
1698    #[test]
1699    fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1700        let path = std::env::temp_dir().join(format!(
1701            "syncular-rebootstrap-receipt-{}.db",
1702            uuid::Uuid::new_v4()
1703        ));
1704        let schema = json!({
1705            "version": 1,
1706            "tables": [{
1707                "name": "tasks",
1708                "primaryKey": "id",
1709                "columns": [
1710                    { "name": "id", "type": "string", "nullable": false },
1711                    { "name": "project_id", "type": "string", "nullable": false }
1712                ],
1713                "scopes": [{ "pattern": "project:{project_id}" }]
1714            }]
1715        });
1716        let path_string = path.to_str().expect("UTF-8 temp path");
1717
1718        {
1719            let mut first = SyncClient::open_path(
1720                "repair-restart-client".to_owned(),
1721                &schema,
1722                ClientLimits::default(),
1723                path_string,
1724            )
1725            .expect("first open");
1726            first
1727                .subscribe(
1728                    "repair-tasks".to_owned(),
1729                    "tasks".to_owned(),
1730                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1731                    None,
1732                )
1733                .expect("subscribe");
1734            first
1735                .mutate(vec![Mutation::Upsert {
1736                    table: "tasks".to_owned(),
1737                    values: Map::from_iter([
1738                        ("id".to_owned(), Value::from("offline-row")),
1739                        ("project_id".to_owned(), Value::from("p1")),
1740                    ]),
1741                    base_version: None,
1742                }])
1743                .expect("queue offline work");
1744            assert_eq!(
1745                first
1746                    .rebootstrap_local_data(&LocalDataRebootstrapInput {
1747                        rebootstrap_id: "restart-receipt".to_owned(),
1748                    })
1749                    .expect("first rebootstrap"),
1750                LocalDataRebootstrapResult {
1751                    already_applied: false,
1752                    retained_commits: 1,
1753                    reset_subscriptions: 1,
1754                }
1755            );
1756        }
1757
1758        let mut reopened = SyncClient::open_path(
1759            "repair-restart-client".to_owned(),
1760            &schema,
1761            ClientLimits::default(),
1762            path_string,
1763        )
1764        .expect("reopen");
1765        reopened.drain_change_batches();
1766        reopened.drain_sync_intents();
1767        assert_eq!(
1768            reopened
1769                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1770                    rebootstrap_id: "restart-receipt".to_owned(),
1771                })
1772                .expect("receipt replay"),
1773            LocalDataRebootstrapResult {
1774                already_applied: true,
1775                retained_commits: 1,
1776                reset_subscriptions: 1,
1777            }
1778        );
1779        assert!(reopened.drain_change_batches().is_empty());
1780        assert!(reopened.drain_sync_intents().is_empty());
1781        drop(reopened);
1782        std::fs::remove_file(path).expect("remove temp database");
1783    }
1784
1785    #[test]
1786    fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1787        let mut malformed = client();
1788        malformed
1789            .subscribe(
1790                "repair-tasks".to_owned(),
1791                "tasks".to_owned(),
1792                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1793                None,
1794            )
1795            .expect("subscribe");
1796        malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1797        malformed.drain_change_batches();
1798        malformed.drain_sync_intents();
1799        let malformed_error = malformed
1800            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1801                rebootstrap_id: "malformed".to_owned(),
1802            })
1803            .expect_err("malformed receipt must fail closed");
1804        assert_eq!(
1805            malformed_error,
1806            "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1807        );
1808        assert!(!malformed.upgrading());
1809        assert_eq!(
1810            malformed
1811                .subscription_state("repair-tasks")
1812                .expect("unchanged subscription")
1813                .cursor,
1814            -1
1815        );
1816        assert!(malformed.drain_change_batches().is_empty());
1817        assert!(malformed.drain_sync_intents().is_empty());
1818
1819        let mut unreadable = client();
1820        unreadable
1821            .conn
1822            .execute(
1823                "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1824                rusqlite::params!["server-row", "p1"],
1825            )
1826            .expect("seed visible row");
1827        unreadable
1828            .conn
1829            .execute("DROP TABLE _syncular_meta", [])
1830            .expect("break marker storage");
1831        let unreadable_error = unreadable
1832            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1833                rebootstrap_id: "unreadable".to_owned(),
1834            })
1835            .expect_err("unreadable marker storage must fail closed");
1836        assert_eq!(
1837            unreadable_error,
1838            "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1839        );
1840        let visible_rows = unreadable
1841            .conn
1842            .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1843            .expect("visible projection remains");
1844        assert_eq!(visible_rows, 1);
1845        assert!(!unreadable.upgrading());
1846        assert!(unreadable.drain_change_batches().is_empty());
1847        assert!(unreadable.drain_sync_intents().is_empty());
1848    }
1849
1850    #[test]
1851    fn local_rebootstrap_cannot_bypass_schema_floor() {
1852        let mut client = client();
1853        client.set_schema_floor(Some(SchemaFloor {
1854            required_schema_version: Some(2),
1855            latest_schema_version: Some(2),
1856        }));
1857        let error = client
1858            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1859                rebootstrap_id: "blocked-floor".to_owned(),
1860            })
1861            .expect_err("schema floor must block repair");
1862        assert!(error.contains("cannot bypass an active schema-floor stop"));
1863    }
1864
1865    #[test]
1866    fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
1867        let mut client = client();
1868        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1869        const COMMIT_COUNT: usize = 32;
1870
1871        for index in 0..COMMIT_COUNT {
1872            client
1873                .mutate(vec![Mutation::Upsert {
1874                    table: "tasks".to_owned(),
1875                    values: Map::from_iter([
1876                        ("id".to_owned(), Value::from(format!("task-{index}"))),
1877                        ("project_id".to_owned(), Value::from("project-1")),
1878                    ]),
1879                    base_version: None,
1880                }])
1881                .expect("queue commit");
1882        }
1883
1884        let (_, request_meta) = client.build_request(false);
1885        assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
1886        let mut frames = vec![Frame::RespHeader {
1887            required_schema_version: None,
1888            latest_schema_version: None,
1889            log_epoch: Some("epoch-1".to_owned()),
1890            reset_required: Some(false),
1891        }];
1892        frames.extend(request_meta.pushed_ids.iter().enumerate().map(
1893            |(index, client_commit_id)| Frame::PushResult {
1894                client_commit_id: client_commit_id.clone(),
1895                status: PushStatus::Applied,
1896                commit_seq: Some(index as i64 + 1),
1897                results: vec![OpResult::Applied { op_index: 0 }],
1898            },
1899        ));
1900        let response = Message {
1901            wire_version: WIRE_VERSION,
1902            msg_kind: MsgKind::Response,
1903            frames,
1904        };
1905        let mut transport =
1906            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
1907
1908        client.overlay_rebuild_count.set(0);
1909        client.outcome_prune_count.set(0);
1910        let outcome = client.process_response(&mut transport, response, &request_meta);
1911        assert!(matches!(outcome, SyncOutcome::Ok(_)));
1912        assert!(client.pending_commit_ids().is_empty());
1913        assert_eq!(
1914            client.overlay_rebuild_count.get(),
1915            1,
1916            "one response must reconcile its acknowledged commits with one overlay rebuild"
1917        );
1918        assert_eq!(
1919            client.outcome_prune_count.get(),
1920            1,
1921            "one response must enforce outcome retention once"
1922        );
1923    }
1924
1925    #[test]
1926    fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
1927        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
1928            "columns": [
1929                {"name":"id", "type":"string", "nullable":false},
1930                {"name":"project_id", "type":"string", "nullable":false},
1931                {"name":"title", "type":"string", "nullable":false}
1932            ], "scopes": [{"pattern":"project:{project_id}"}],
1933            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1934            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
1935        }] });
1936        let mut incremental =
1937            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1938        let mut reference =
1939            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1940        let tables = vec!["tasks".to_owned()];
1941        for index in 0..32 {
1942            let id = format!("task-{}", index % 8);
1943            let values = Map::from_iter([
1944                ("id".to_owned(), Value::from(id.clone())),
1945                (
1946                    "project_id".to_owned(),
1947                    Value::from(format!("p{}", index % 2)),
1948                ),
1949                ("title".to_owned(), Value::from(format!("needle{index}"))),
1950            ]);
1951            let payload = encode_row_json(
1952                incremental.schema.table("tasks").unwrap(),
1953                &id,
1954                &values,
1955                &incremental.encryption,
1956            )
1957            .unwrap();
1958            let change = ssp2::model::Change {
1959                table_index: 0,
1960                row_id: id,
1961                op: if index % 5 == 0 {
1962                    Op::Delete
1963                } else {
1964                    Op::Upsert
1965                },
1966                row_version: Some(index + 1),
1967                scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
1968                row: Some(payload),
1969            };
1970            reference.overlay_dirty.set(true);
1971            for instance in [&mut incremental, &mut reference] {
1972                instance.begin_observation("mirror_test").unwrap();
1973                let mut batch = ChangeAccumulator::default();
1974                instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
1975                instance
1976                    .apply_commit_changes(&tables, std::slice::from_ref(&change))
1977                    .unwrap();
1978                instance.rebuild_overlay_if_dirty();
1979                instance.finish_observation("mirror_test", batch).unwrap();
1980            }
1981            for sql in [
1982                "SELECT * FROM tasks ORDER BY id",
1983                "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
1984            ] {
1985                assert_eq!(
1986                    incremental.query(sql, &[]).unwrap(),
1987                    reference.query(sql, &[]).unwrap()
1988                );
1989            }
1990            assert_eq!(incremental.local_revision(), reference.local_revision());
1991            assert_eq!(
1992                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1993                serde_json::to_value(reference.drain_change_batches()).unwrap()
1994            );
1995        }
1996        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
1997        assert_eq!(reference.overlay_rebuild_count.get(), 32);
1998    }
1999
2000    #[test]
2001    fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
2002        let cases = [
2003            (
2004                "string",
2005                vec![
2006                    json!("1"),
2007                    json!("01"),
2008                    json!(""),
2009                    json!("nul\u{0}id"),
2010                    json!("é"),
2011                ],
2012            ),
2013            (
2014                "integer",
2015                vec![
2016                    json!(i64::MIN),
2017                    json!(-1),
2018                    json!(0),
2019                    json!(1),
2020                    json!(i64::MAX),
2021                ],
2022            ),
2023            ("boolean", vec![json!(false), json!(true)]),
2024            ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
2025        ];
2026        for (kind, ids) in cases {
2027            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2028                "columns":[{"name":"id","type":kind,"nullable":false},
2029                    {"name":"project_id","type":"string","nullable":false}],
2030                "scopes":[{"pattern":"project:{project_id}"}]}]});
2031            let mut client =
2032                SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
2033            for (index, id) in ids.iter().enumerate() {
2034                let table = client.schema.table("tasks").unwrap();
2035                client
2036                    .write_base_row(
2037                        "tasks",
2038                        &vec![
2039                            json_to_column_value(&table.columns[0], Some(id)).unwrap(),
2040                            Some(ColumnValue::String(format!("p{index}"))),
2041                        ],
2042                        1,
2043                    )
2044                    .unwrap();
2045            }
2046            client.rebuild_overlay_if_dirty();
2047            for base in [true, false] {
2048                let full = if base {
2049                    base_table("tasks")
2050                } else {
2051                    visible_table("tasks")
2052                };
2053                let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
2054                for row_id in [
2055                    "1",
2056                    "01",
2057                    "1.0",
2058                    "1.0000000000000002",
2059                    "+1",
2060                    "1e0",
2061                    " 1",
2062                    "-1",
2063                    "0",
2064                    "9223372036854775807",
2065                    "-9223372036854775808",
2066                    "true",
2067                    "false",
2068                    "",
2069                    "nul\u{0}id",
2070                    "é",
2071                    "null",
2072                    "{\"a\":1}",
2073                ] {
2074                    let old_sql =
2075                        format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
2076                    let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
2077                    let old = client
2078                        .conn
2079                        .prepare(&old_sql)
2080                        .unwrap()
2081                        .query_map([row_id], |row| row.get::<_, String>(0))
2082                        .unwrap()
2083                        .collect::<Result<Vec<_>, _>>()
2084                        .unwrap();
2085                    let new = client
2086                        .conn
2087                        .prepare_cached(&new_sql)
2088                        .unwrap()
2089                        .query_map([row_id], |row| row.get::<_, String>(0))
2090                        .unwrap()
2091                        .collect::<Result<Vec<_>, _>>()
2092                        .unwrap();
2093                    assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
2094                    let mut batch = ChangeAccumulator::default();
2095                    assert_eq!(
2096                        client.record_row_scopes(&mut batch, "tasks", row_id, base),
2097                        !old.is_empty()
2098                    );
2099                    let expected = old.first().map(|scope| {
2100                        BTreeMap::from([(
2101                            "tasks".to_owned(),
2102                            Some(BTreeSet::from([format!("project:{scope}")])),
2103                        )])
2104                    });
2105                    assert_eq!(batch.tables, expected.unwrap_or_default());
2106                    client
2107                        .conn
2108                        .execute_batch("SAVEPOINT lookup_delete")
2109                        .unwrap();
2110                    if base {
2111                        client.delete_base_row("tasks", row_id).unwrap();
2112                    } else {
2113                        client.apply_outbox_ops(&[OutboxOp {
2114                            upsert: false,
2115                            table: "tasks".into(),
2116                            row_id: row_id.into(),
2117                            base_version: None,
2118                            values: None,
2119                        }]);
2120                    }
2121                    let remaining: i64 = client
2122                        .conn
2123                        .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
2124                            row.get(0)
2125                        })
2126                        .unwrap();
2127                    assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
2128                    client
2129                        .conn
2130                        .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
2131                        .unwrap();
2132                    client.overlay_dirty.set(false);
2133                }
2134                for (predicate, expected) in [
2135                    ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
2136                    (predicate, "SEARCH"),
2137                ] {
2138                    let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
2139                    let details = client
2140                        .conn
2141                        .prepare(&sql)
2142                        .unwrap()
2143                        .query_map(["1"], |row| row.get::<_, String>(3))
2144                        .unwrap()
2145                        .collect::<Result<Vec<_>, _>>()
2146                        .unwrap();
2147                    assert!(
2148                        details.iter().any(|detail| detail.contains(expected)),
2149                        "{kind}: {details:?}"
2150                    );
2151                }
2152            }
2153        }
2154    }
2155
2156    #[test]
2157    fn primary_keys_reject_every_ineligible_column_type() {
2158        // §2.4: a `rowId` is a string, so a primary key type whose string form
2159        // differs per renderer, or cannot be reproduced by local storage
2160        // comparison, is a schema error rather than a silent lookup miss.
2161        for ty in ["float", "bytes", "crdt", "blob_ref"] {
2162            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2163                "columns":[{"name":"id","type":ty,"nullable":false},
2164                    {"name":"project_id","type":"string","nullable":false}],
2165                "scopes":[{"pattern":"project:{project_id}"}]}]});
2166            let error =
2167                match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
2168                    Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
2169                    Err(error) => error,
2170                };
2171            assert!(
2172                error.contains("has an ineligible column type (§2.4)"),
2173                "{ty}: {error}"
2174            );
2175        }
2176        for ty in ["string", "integer", "boolean", "json"] {
2177            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2178                "columns":[{"name":"id","type":ty,"nullable":false},
2179                    {"name":"project_id","type":"string","nullable":false}],
2180                "scopes":[{"pattern":"project:{project_id}"}]}]});
2181            SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
2182                .unwrap_or_else(|error| panic!("{ty}: {error}"));
2183        }
2184    }
2185
2186    #[test]
2187    fn pending_row_reconciliation_matches_full_fifo_replay() {
2188        for unique in [false, true] {
2189            let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
2190                "name": name, "primaryKey": "id", "columns": [
2191                    {"name":"id", "type":"string", "nullable":false},
2192                    {"name":"project_id", "type":"string", "nullable":false},
2193                    {"name":"title", "type":"string", "nullable":false}],
2194                "scopes": [{"pattern":"project:{project_id}"}],
2195                "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
2196                "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
2197            })))});
2198            let mut incremental =
2199                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2200            let mut reference =
2201                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2202            let tables = vec!["tasks".to_owned(), "notes".to_owned()];
2203            for instance in [&mut incremental, &mut reference] {
2204                for table in &tables {
2205                    for index in 0..12 {
2206                        instance
2207                            .write_base_row(
2208                                table,
2209                                &vec![
2210                                    Some(ColumnValue::String(format!("row{index}"))),
2211                                    Some(ColumnValue::String("p0".into())),
2212                                    Some(ColumnValue::String(format!("base{index}"))),
2213                                ],
2214                                1,
2215                            )
2216                            .unwrap();
2217                    }
2218                }
2219                instance.rebuild_overlay_if_dirty();
2220            }
2221            for index in 0..80 {
2222                let mutations = (0..3)
2223                    .map(|offset| {
2224                        let table = tables[(index + offset) % 2].clone();
2225                        let id = format!("row{}", (index + offset) % 16);
2226                        if (index + offset) % 7 == 0 {
2227                            Mutation::Delete {
2228                                table,
2229                                row_id: id,
2230                                base_version: None,
2231                            }
2232                        } else {
2233                            Mutation::Upsert {
2234                                table,
2235                                values: Map::from_iter([
2236                                    ("id".into(), Value::from(id)),
2237                                    ("project_id".into(), Value::from(format!("p{}", index % 3))),
2238                                    (
2239                                        "title".into(),
2240                                        Value::from(format!("needle{}", (index + offset) % 16)),
2241                                    ),
2242                                ]),
2243                                base_version: None,
2244                            }
2245                        }
2246                    })
2247                    .collect::<Vec<_>>();
2248                for instance in [&mut incremental, &mut reference] {
2249                    instance.mutate(mutations.clone()).unwrap();
2250                }
2251                // Most frames touch only the table without unique constraints.
2252                // Mixed frames must retain full replay for both tables.
2253                let changes = (0..3)
2254                    .map(|offset| {
2255                        let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
2256                        let id = format!("row{}", (index + offset) % 16);
2257                        let values = Map::from_iter([
2258                            ("id".into(), Value::from(id.clone())),
2259                            (
2260                                "project_id".into(),
2261                                Value::from(format!("p{}", (index + 1) % 3)),
2262                            ),
2263                            (
2264                                "title".into(),
2265                                Value::from(format!("remote{index}_{offset}")),
2266                            ),
2267                        ]);
2268                        ssp2::model::Change {
2269                            table_index: table_index as u16,
2270                            row_id: id.clone(),
2271                            op: if (index + offset) % 5 == 0 {
2272                                Op::Delete
2273                            } else {
2274                                Op::Upsert
2275                            },
2276                            row_version: Some(index as i64 + 2),
2277                            scopes: vec![],
2278                            row: Some(
2279                                encode_row_json(
2280                                    incremental.schema.table(&tables[table_index]).unwrap(),
2281                                    &id,
2282                                    &values,
2283                                    &incremental.encryption,
2284                                )
2285                                .unwrap(),
2286                            ),
2287                        }
2288                    })
2289                    .collect::<Vec<_>>();
2290                reference.overlay_dirty.set(true);
2291                incremental.apply_commit_frame(&tables, &changes).unwrap();
2292                reference.apply_commit_frame(&tables, &changes).unwrap();
2293                for table in &tables {
2294                    for sql in [format!("SELECT * FROM {table} ORDER BY id"),
2295                        format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
2296                        format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
2297                        format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
2298                        assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
2299                    }
2300                }
2301                assert_eq!(incremental.local_revision(), reference.local_revision());
2302                assert_eq!(
2303                    serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2304                    serde_json::to_value(reference.drain_change_batches()).unwrap()
2305                );
2306            }
2307            assert_eq!(
2308                incremental.overlay_rebuild_count.get(),
2309                if unique { 81 } else { 21 }
2310            );
2311            assert_eq!(reference.overlay_rebuild_count.get(), 81);
2312        }
2313    }
2314
2315    #[test]
2316    fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
2317        let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
2318            "columns":[{"name":"id", "type":"string", "nullable":false},
2319                {"name":"title", "type":"string", "nullable":false}],
2320            "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
2321        let mut client =
2322            SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
2323        for (id, title) in [("a", "original"), ("b", "occupied")] {
2324            client
2325                .write_base_row(
2326                    "tasks",
2327                    &vec![
2328                        Some(ColumnValue::String(id.into())),
2329                        Some(ColumnValue::String(title.into())),
2330                    ],
2331                    1,
2332                )
2333                .unwrap();
2334        }
2335        client.rebuild_overlay_if_dirty();
2336        let pending = client
2337            .mutate(vec![Mutation::Upsert {
2338                table: "tasks".into(),
2339                values: Map::from_iter([
2340                    ("id".into(), Value::from("a")),
2341                    ("title".into(), Value::from("available")),
2342                ]),
2343                base_version: None,
2344            }])
2345            .unwrap();
2346        assert_eq!(
2347            client
2348                .conn
2349                .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2350                    .get::<_, String>(
2351                    0
2352                ))
2353                .unwrap(),
2354            "available"
2355        );
2356        for (title, expected) in [("available", "original"), ("occupied", "available")] {
2357            let change = ssp2::model::Change {
2358                table_index: 0,
2359                row_id: "b".into(),
2360                op: Op::Upsert,
2361                row_version: Some(2),
2362                scopes: vec![],
2363                row: Some(
2364                    encode_row_json(
2365                        client.schema.table("tasks").unwrap(),
2366                        "b",
2367                        &Map::from_iter([
2368                            ("id".into(), Value::from("b")),
2369                            ("title".into(), Value::from(title)),
2370                        ]),
2371                        &client.encryption,
2372                    )
2373                    .unwrap(),
2374                ),
2375            };
2376            client
2377                .apply_commit_frame(&["tasks".into()], &[change])
2378                .unwrap();
2379            assert_eq!(
2380                client
2381                    .conn
2382                    .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2383                        .get::<_, String>(
2384                        0
2385                    ))
2386                    .unwrap(),
2387                expected
2388            );
2389            assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
2390        }
2391        assert_eq!(client.overlay_rebuild_count.get(), 3);
2392    }
2393
2394    fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
2395        let schema = json!({ "version": 1, "tables": [
2396            { "name":"tasks", "primaryKey":"id", "columns":[
2397                {"name":"id","type":"string","nullable":false},
2398                {"name":"project_id","type":"string","nullable":false}],
2399                "scopes":[{"pattern":"project:{project_id}"}] },
2400            { "name":"catalogue", "primaryKey":"id", "columns":[
2401                {"name":"id","type":"string","nullable":false},
2402                {"name":"project_id","type":"string","nullable":false},
2403                {"name":"title","type":"string","nullable":false}],
2404                "scopes":[{"pattern":"project:{project_id}"}],
2405                "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
2406        ] });
2407        let mut client =
2408            SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
2409        if unchanged_rows > 0 {
2410            client.conn.execute_batch(&format!("WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM n WHERE i<{unchanged_rows}) INSERT INTO _syncular_base_catalogue SELECT 'c'||i,'p1','needle '||i,1 FROM n")).unwrap();
2411        }
2412        client.rebuild_overlay();
2413        client.conn.execute_batch("CREATE TABLE audit(tbl TEXT); CREATE TRIGGER catalogue_changed AFTER DELETE ON catalogue BEGIN INSERT INTO audit VALUES ('catalogue'); END;").unwrap();
2414        let mut elapsed = Vec::new();
2415        for round in 1..=rounds {
2416            let pending = client
2417                .mutate(vec![Mutation::Upsert {
2418                    table: "tasks".into(),
2419                    values: Map::from_iter([
2420                        ("id".into(), json!("t1")),
2421                        ("project_id".into(), json!("p1")),
2422                    ]),
2423                    base_version: None,
2424                }])
2425                .unwrap();
2426            let started = std::time::Instant::now();
2427            client
2428                .write_base_row(
2429                    "tasks",
2430                    &vec![
2431                        Some(ColumnValue::String("t1".into())),
2432                        Some(ColumnValue::String("p1".into())),
2433                    ],
2434                    round,
2435                )
2436                .unwrap();
2437            let mut report = SyncReport::default();
2438            client
2439                .handle_push_results(
2440                    &[Frame::PushResult {
2441                        client_commit_id: pending.clone(),
2442                        status: PushStatus::Applied,
2443                        commit_seq: Some(round),
2444                        results: vec![OpResult::Applied { op_index: 0 }],
2445                    }],
2446                    &HashSet::from([pending.as_str()]),
2447                    &HashMap::new(),
2448                    &mut report,
2449                    Some(&pending),
2450                )
2451                .unwrap();
2452            client.rebuild_overlay_if_dirty();
2453            elapsed.push(started.elapsed().as_nanos());
2454        }
2455        assert_eq!(
2456            client
2457                .conn
2458                .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
2459                .unwrap(),
2460            0,
2461            "unchanged catalogue must never be copied"
2462        );
2463        assert_eq!(
2464            client
2465                .conn
2466                .query_row(
2467                    "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
2468                    [],
2469                    |r| r.get::<_, i64>(0)
2470                )
2471                .unwrap(),
2472            unchanged_rows
2473        );
2474        assert_eq!(
2475            client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
2476            json!(rounds)
2477        );
2478        elapsed
2479    }
2480
2481    #[test]
2482    fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
2483        let elapsed = measure_scoped_overlay(100_000, 1);
2484        eprintln!(
2485            "table-scoped overlay with 100k unchanged rows: {} ns",
2486            elapsed[0]
2487        );
2488    }
2489
2490    #[test]
2491    #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
2492    fn benchmark_scoped_overlay() {
2493        for rows in [0, 100_000, 200_000] {
2494            let mut elapsed = measure_scoped_overlay(rows, 30);
2495            elapsed.sort_unstable();
2496            eprintln!(
2497                "{}",
2498                json!({"unchangedRows": rows, "rounds": elapsed.len(),
2499                "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
2500                "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
2501            );
2502        }
2503    }
2504
2505    #[test]
2506    fn released_context_discards_in_flight_ack_and_server_rows() {
2507        for barrier in ["preflight", "purge", "subscription"] {
2508            let mut client = client();
2509            client.set_meta(LOG_EPOCH_KEY, "epoch");
2510            client
2511                .subscribe(
2512                    "tasks".into(),
2513                    "tasks".into(),
2514                    vec![("project_id".into(), vec!["p1".into()])],
2515                    None,
2516                )
2517                .unwrap();
2518            let commit = client
2519                .mutate(vec![Mutation::Upsert {
2520                    table: "tasks".into(),
2521                    values: Map::from_iter([
2522                        ("id".into(), json!("local")),
2523                        ("project_id".into(), json!("p1")),
2524                    ]),
2525                    base_version: None,
2526                }])
2527                .unwrap();
2528            let prepared = client.prepare_sync_round(false).unwrap();
2529            let payload = encode_row_json(
2530                &client.schema.tables[0],
2531                "server",
2532                &Map::from_iter([
2533                    ("id".into(), json!("server")),
2534                    ("project_id".into(), json!("p1")),
2535                ]),
2536                &client.encryption,
2537            )
2538            .unwrap();
2539            let completed = crate::CompletedSyncRound {
2540                prepared,
2541                exchange: crate::round::ExchangeResult::Reply {
2542                    transport_failed: false,
2543                    downloads: Default::default(),
2544                    response: Ok(Message {
2545                        wire_version: WIRE_VERSION,
2546                        msg_kind: MsgKind::Response,
2547                        frames: vec![
2548                            Frame::RespHeader {
2549                                required_schema_version: None,
2550                                latest_schema_version: None,
2551                                log_epoch: Some("epoch".into()),
2552                                reset_required: Some(false),
2553                            },
2554                            Frame::PushResult {
2555                                client_commit_id: commit.clone(),
2556                                status: PushStatus::Applied,
2557                                commit_seq: Some(1),
2558                                results: vec![OpResult::Applied { op_index: 0 }],
2559                            },
2560                            Frame::SubStart {
2561                                id: "tasks".into(),
2562                                status: SubStatus::Active,
2563                                reason_code: String::new(),
2564                                effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2565                                bootstrap: true,
2566                            },
2567                            Frame::Commit {
2568                                commit_seq: 1,
2569                                created_at_ms: 0,
2570                                actor_id: "actor".into(),
2571                                tables: vec!["tasks".into()],
2572                                changes: vec![ssp2::model::Change {
2573                                    table_index: 0,
2574                                    row_id: "server".into(),
2575                                    op: Op::Upsert,
2576                                    row_version: Some(1),
2577                                    scopes: vec![("project_id".into(), "p1".into())],
2578                                    row: Some(payload),
2579                                }],
2580                            },
2581                            Frame::SubEnd {
2582                                next_cursor: 1,
2583                                bootstrap_state: None,
2584                            },
2585                        ],
2586                    }),
2587                },
2588            };
2589            match barrier {
2590                "preflight" => client.begin_security_preflight(),
2591                "purge" => {
2592                    client
2593                        .purge_local_data(&LocalDataPurgeInput {
2594                            purge_id: "pending-round".into(),
2595                            targets: vec![LocalDataPurgeTarget {
2596                                table: "tasks".into(),
2597                                selectors: BTreeMap::from([(
2598                                    "project_id".into(),
2599                                    vec!["p1".into()],
2600                                )]),
2601                            }],
2602                        })
2603                        .unwrap();
2604                }
2605                "subscription" => {
2606                    client.unsubscribe("tasks");
2607                    client
2608                        .subscribe(
2609                            "tasks".into(),
2610                            "tasks".into(),
2611                            vec![("project_id".into(), vec!["p2".into()])],
2612                            None,
2613                        )
2614                        .unwrap();
2615                }
2616                _ => unreachable!(),
2617            }
2618            let pending_before_reply = client.pending_commit_ids();
2619            let revision = client.local_revision();
2620            let applied = client.apply_sync_round(completed);
2621            assert!(
2622                matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
2623            );
2624            assert_eq!(client.local_revision(), revision);
2625            assert!(client
2626                .conn
2627                .query_row(
2628                    "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
2629                    [],
2630                    |r| r.get::<_, bool>(0)
2631                )
2632                .unwrap());
2633            assert_eq!(client.pending_commit_ids(), pending_before_reply);
2634        }
2635    }
2636
2637    #[test]
2638    fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
2639        for failure in ["visible", "revision", "commit"] {
2640            let mut client = client();
2641            let id = client
2642                .mutate(vec![Mutation::Upsert {
2643                    table: "tasks".into(),
2644                    values: Map::from_iter([
2645                        ("id".into(), Value::from("t1")),
2646                        ("project_id".into(), Value::from("pending")),
2647                    ]),
2648                    base_version: None,
2649                }])
2650                .unwrap();
2651            client.drain_change_batches();
2652            let revision = client.local_revision();
2653            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
2654            let trigger = match failure {
2655                "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
2656                "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"),
2657                _ => "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(),
2658            };
2659            client.conn.execute_batch(&trigger).unwrap();
2660            let change = ssp2::model::Change {
2661                table_index: 0,
2662                row_id: "t1".into(),
2663                op: Op::Upsert,
2664                row_version: Some(2),
2665                scopes: vec![],
2666                row: Some(
2667                    encode_row_json(
2668                        client.schema.table("tasks").unwrap(),
2669                        "t1",
2670                        &Map::from_iter([
2671                            ("id".into(), Value::from("t1")),
2672                            ("project_id".into(), Value::from("remote")),
2673                        ]),
2674                        &client.encryption,
2675                    )
2676                    .unwrap(),
2677                ),
2678            };
2679            assert!(client
2680                .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change))
2681                .is_err());
2682            assert!(client.conn.is_autocommit());
2683            assert!(!client.overlay_dirty.get());
2684            assert_eq!(client.local_revision(), revision);
2685            assert!(client.drain_change_batches().is_empty());
2686            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
2687            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
2688            assert!(client
2689                .query("SELECT * FROM _syncular_base_tasks", &[])
2690                .unwrap()
2691                .is_empty());
2692            client
2693                .conn
2694                .execute_batch("DROP TRIGGER fail_pending")
2695                .unwrap();
2696            client
2697                .apply_commit_frame(&["tasks".into()], &[change])
2698                .unwrap();
2699            assert_eq!(client.local_revision(), revision + 1);
2700            assert_eq!(client.pending_commit_ids(), vec![id]);
2701            // The overlay keeps the pending write visible; the row now carries
2702            // the server row's version it is layered on (§7.1).
2703            let mut expected = before[0].clone();
2704            expected.insert("_syncular_version".to_owned(), Value::from(2));
2705            assert_eq!(
2706                client.query("SELECT * FROM tasks", &[]).unwrap(),
2707                vec![expected]
2708            );
2709            assert_eq!(
2710                client
2711                    .conn
2712                    .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
2713                        .get::<_, String>(0))
2714                    .unwrap(),
2715                "remote"
2716            );
2717        }
2718    }
2719
2720    #[test]
2721    fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
2722        let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
2723            "columns": [
2724                {"name":"id", "type":"string", "nullable":false},
2725                {"name":"project_id", "type":"string", "nullable":false},
2726                {"name":"title", "type":"string", "nullable":false}],
2727            "scopes": [{"pattern":"project:{project_id}"}],
2728            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2729            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
2730        let mut incremental =
2731            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2732        let mut reference =
2733            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2734        for instance in [&mut incremental, &mut reference] {
2735            for index in 0..16 {
2736                instance
2737                    .write_base_row(
2738                        "tasks",
2739                        &vec![
2740                            Some(ColumnValue::String(format!("task{index}"))),
2741                            Some(ColumnValue::String("p0".to_owned())),
2742                            Some(ColumnValue::String(format!("base{index}"))),
2743                        ],
2744                        1,
2745                    )
2746                    .unwrap();
2747            }
2748            instance.rebuild_overlay_if_dirty();
2749            instance.overlay_rebuild_count.set(0);
2750        }
2751        for index in 0..100 {
2752            let mutations = (0..1 + index % 4)
2753                .map(|offset| {
2754                    let id = format!("task{}", (index + offset) % 24);
2755                    if (index + offset) % 7 == 0 {
2756                        Mutation::Delete {
2757                            table: "tasks".to_owned(),
2758                            row_id: id,
2759                            base_version: None,
2760                        }
2761                    } else {
2762                        Mutation::Upsert {
2763                            table: "tasks".to_owned(),
2764                            values: Map::from_iter([
2765                                ("id".to_owned(), Value::from(id)),
2766                                (
2767                                    "project_id".to_owned(),
2768                                    Value::from(format!("p{}", index % 2)),
2769                                ),
2770                                (
2771                                    "title".to_owned(),
2772                                    Value::from(format!("needle{}", (index + offset) % 24)),
2773                                ),
2774                            ]),
2775                            base_version: None,
2776                        }
2777                    }
2778                })
2779                .collect::<Vec<_>>();
2780            reference.overlay_dirty.set(true);
2781            incremental.mutate(mutations.clone()).unwrap();
2782            reference.mutate(mutations).unwrap();
2783            for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2784                "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
2785                assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
2786            }
2787            assert_eq!(incremental.local_revision(), reference.local_revision());
2788            assert_eq!(
2789                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2790                serde_json::to_value(reference.drain_change_batches()).unwrap()
2791            );
2792        }
2793        assert_eq!(incremental.outbox.len(), 100);
2794        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2795        assert_eq!(reference.overlay_rebuild_count.get(), 100);
2796    }
2797
2798    #[test]
2799    fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
2800        for fail_at in ["outbox", "revision"] {
2801            let mut client = client();
2802            let values = Map::from_iter([
2803                ("id".to_owned(), Value::from("t1")),
2804                ("project_id".to_owned(), Value::from("p1")),
2805            ]);
2806            let mutation = Mutation::Upsert {
2807                table: "tasks".to_owned(),
2808                values,
2809                base_version: None,
2810            };
2811            let trigger = if fail_at == "outbox" {
2812                "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
2813            } else {
2814                format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
2815            };
2816            client.conn.execute_batch(&trigger).unwrap();
2817            let revision = client.local_revision();
2818            assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
2819            assert_eq!(client.local_revision(), revision);
2820            assert!(client.outbox.is_empty());
2821            assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2822            assert_eq!(
2823                client
2824                    .conn
2825                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
2826                        .get::<_, i64>(0))
2827                    .unwrap(),
2828                0
2829            );
2830            assert!(client.drain_change_batches().is_empty());
2831            assert!(!client.overlay_dirty.get());
2832            client
2833                .conn
2834                .execute_batch("DROP TRIGGER fail_append")
2835                .unwrap();
2836            client.mutate(vec![mutation]).unwrap();
2837            assert_eq!(client.outbox.len(), 1);
2838            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
2839        }
2840    }
2841
2842    #[test]
2843    fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
2844        let mut client = client();
2845        let tables = vec!["tasks".to_owned(), "missing".to_owned()];
2846        let values = Map::from_iter([
2847            ("id".to_owned(), Value::from("t1")),
2848            ("project_id".to_owned(), Value::from("p1")),
2849        ]);
2850        let payload = encode_row_json(
2851            client.schema.table("tasks").unwrap(),
2852            "t1",
2853            &values,
2854            &client.encryption,
2855        )
2856        .unwrap();
2857        let first = ssp2::model::Change {
2858            table_index: 0,
2859            row_id: "t1".to_owned(),
2860            op: Op::Upsert,
2861            row_version: Some(1),
2862            scopes: vec![("project_id".to_owned(), "p1".to_owned())],
2863            row: Some(payload),
2864        };
2865        let mut invalid = first.clone();
2866        invalid.table_index = 1;
2867        let revision = client.local_revision();
2868        client.begin_observation("failed_mirror").unwrap();
2869        assert!(client
2870            .apply_commit_changes(&tables, &[first, invalid])
2871            .is_err());
2872        client.rollback_observation("failed_mirror");
2873        client.rebuild_overlay_if_dirty();
2874        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2875        assert_eq!(
2876            client
2877                .conn
2878                .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
2879                    .get::<_, i64>(
2880                    0
2881                ))
2882                .unwrap(),
2883            0
2884        );
2885        assert_eq!(client.local_revision(), revision);
2886        assert!(client.drain_change_batches().is_empty());
2887    }
2888
2889    #[test]
2890    fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
2891        for failure in ["journal", "revision", "commit", "later-frame"] {
2892            let mut client = client();
2893            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2894            for id in ["first", "second"] {
2895                client
2896                    .mutate(vec![Mutation::Upsert {
2897                        table: "tasks".to_owned(),
2898                        values: Map::from_iter([
2899                            ("id".to_owned(), Value::from(id)),
2900                            ("project_id".to_owned(), Value::from("p1")),
2901                        ]),
2902                        base_version: None,
2903                    }])
2904                    .unwrap();
2905            }
2906            let (_, meta) = client.build_request(false);
2907            // A malicious or stale response must not drain an unsent local commit.
2908            client
2909                .mutate(vec![Mutation::Upsert {
2910                    table: "tasks".to_owned(),
2911                    values: Map::from_iter([
2912                        ("id".to_owned(), Value::from("later")),
2913                        ("project_id".to_owned(), Value::from("p1")),
2914                    ]),
2915                    base_version: None,
2916                }])
2917                .unwrap();
2918            let ids = client.pending_commit_ids();
2919            let rows = client
2920                .query("SELECT * FROM tasks ORDER BY id", &[])
2921                .unwrap();
2922            let revision = client.local_revision();
2923            client.drain_change_batches();
2924            if failure != "later-frame" {
2925                client.conn.execute_batch(match failure {
2926                    "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",
2927                    "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",
2928                    _ => "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",
2929                }).unwrap();
2930            }
2931            let mut response = Message {
2932                wire_version: WIRE_VERSION,
2933                msg_kind: MsgKind::Response,
2934                frames: vec![Frame::RespHeader {
2935                    required_schema_version: None,
2936                    latest_schema_version: None,
2937                    log_epoch: Some("epoch-1".to_owned()),
2938                    reset_required: Some(false),
2939                }],
2940            };
2941            for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
2942                response.frames.push(Frame::PushResult {
2943                    client_commit_id: id.to_owned(),
2944                    status: PushStatus::Applied,
2945                    commit_seq: Some(1),
2946                    results: vec![OpResult::Applied { op_index: 0 }],
2947                });
2948            }
2949            if failure == "later-frame" {
2950                response.frames.push(Frame::Error {
2951                    code: "sync.invalid_request".to_owned(),
2952                    message: "later error".to_owned(),
2953                    category: "protocol".to_owned(),
2954                    retryable: false,
2955                    recommended_action: "retry".to_owned(),
2956                    details: None,
2957                });
2958            }
2959            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2960            let outcome = client.process_response(&mut transport, response.clone(), &meta);
2961            assert!(
2962                matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
2963            );
2964            if failure == "later-frame" {
2965                assert_eq!(client.pending_commit_ids(), ids[2..]);
2966                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2967                assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
2968                let batches = client.drain_change_batches();
2969                assert_eq!(batches.len(), 1);
2970                assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
2971                assert_eq!(client.local_revision(), revision + 1);
2972                response.frames.pop();
2973            } else {
2974                assert_eq!(client.pending_commit_ids(), ids);
2975                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2976                assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
2977                assert_eq!(client.local_revision(), revision);
2978                assert!(client.drain_change_batches().is_empty());
2979                assert_eq!(
2980                    client
2981                        .query("SELECT * FROM tasks ORDER BY id", &[])
2982                        .unwrap(),
2983                    rows
2984                );
2985                client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
2986            }
2987            for frame in &mut response.frames {
2988                if let Frame::PushResult { status, .. } = frame {
2989                    *status = PushStatus::Cached;
2990                }
2991            }
2992            let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
2993            else {
2994                panic!("retry failed");
2995            };
2996            assert_eq!(
2997                report.applied,
2998                if failure == "later-frame" {
2999                    vec![]
3000                } else {
3001                    ids[..2].to_vec()
3002                }
3003            );
3004            assert_eq!(client.pending_commit_ids(), ids[2..]);
3005            let batches = client.drain_change_batches();
3006            assert_eq!(batches.len(), usize::from(failure != "later-frame"));
3007            if let Some(batch) = batches.first() {
3008                assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
3009            }
3010            assert_eq!(client.local_revision(), revision + 1);
3011        }
3012    }
3013
3014    #[test]
3015    fn failed_push_result_preserves_memory_disk_and_retry_identity() {
3016        for failure in ["revision", "journal", "commit"] {
3017            for status in ["applied", "cached", "rejected", "conflict"] {
3018                let mut client = client();
3019                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3020                for id in ["first", "later"] {
3021                    client
3022                        .mutate(vec![Mutation::Upsert {
3023                            table: "tasks".to_owned(),
3024                            values: Map::from_iter([
3025                                ("id".to_owned(), Value::from(id)),
3026                                ("project_id".to_owned(), Value::from("p1")),
3027                            ]),
3028                            base_version: None,
3029                        }])
3030                        .unwrap();
3031                }
3032                let (_, meta) = client.build_request(false);
3033                let ids = client.pending_commit_ids();
3034                let rows = client
3035                    .query("SELECT * FROM tasks ORDER BY id", &[])
3036                    .unwrap();
3037                let revision = client.local_revision();
3038                client.drain_change_batches();
3039                let (push_status, results) = match status {
3040                    "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
3041                    "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
3042                    "conflict" => (
3043                        PushStatus::Rejected,
3044                        vec![OpResult::Conflict {
3045                            op_index: 0,
3046                            code: "sync.version_conflict".to_owned(),
3047                            message: "conflict".to_owned(),
3048                            server_version: 1,
3049                            server_row: encode_row_json(
3050                                client.schema.table("tasks").unwrap(),
3051                                "first",
3052                                &Map::from_iter([
3053                                    ("id".to_owned(), Value::from("first")),
3054                                    ("project_id".to_owned(), Value::from("p1")),
3055                                ]),
3056                                &client.encryption,
3057                            )
3058                            .unwrap(),
3059                            conflict_columns: Vec::new(),
3060                        }],
3061                    ),
3062                    _ => (
3063                        PushStatus::Rejected,
3064                        vec![OpResult::Error {
3065                            op_index: 0,
3066                            code: "sync.validation_failed".to_owned(),
3067                            message: "rejected".to_owned(),
3068                            retryable: false,
3069                        }],
3070                    ),
3071                };
3072                let response = Message {
3073                    wire_version: WIRE_VERSION,
3074                    msg_kind: MsgKind::Response,
3075                    frames: vec![
3076                        Frame::RespHeader {
3077                            required_schema_version: None,
3078                            latest_schema_version: None,
3079                            log_epoch: Some("epoch-1".to_owned()),
3080                            reset_required: Some(false),
3081                        },
3082                        Frame::PushResult {
3083                            client_commit_id: ids[0].clone(),
3084                            status: push_status,
3085                            commit_seq: Some(1),
3086                            results,
3087                        },
3088                    ],
3089                };
3090                client.conn.execute_batch(match failure {
3091                    "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
3092                    "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
3093                    _ => "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",
3094                }).unwrap();
3095                let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3096                let outcome = client.process_response(&mut transport, response.clone(), &meta);
3097                assert_eq!(
3098                    client.pending_commit_ids(),
3099                    ids,
3100                    "{failure}/{status}: pending identity"
3101                );
3102                assert_eq!(
3103                    client.local_revision(),
3104                    revision,
3105                    "{failure}/{status}: revision"
3106                );
3107                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
3108                assert!(client.conflicts().is_empty());
3109                assert!(client.rejections().is_empty());
3110                assert!(client.drain_change_batches().is_empty());
3111                assert_eq!(
3112                    client
3113                        .query("SELECT * FROM tasks ORDER BY id", &[])
3114                        .unwrap(),
3115                    rows
3116                );
3117                assert!(
3118                    matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
3119                    "{failure}/{status}: failed round"
3120                );
3121                client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
3122                assert!(matches!(
3123                    client.process_response(&mut transport, response, &meta),
3124                    SyncOutcome::Ok(_)
3125                ));
3126                assert_eq!(client.pending_commit_ids(), ids[1..]);
3127                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
3128                assert_eq!(client.drain_change_batches().len(), 1);
3129            }
3130        }
3131    }
3132
3133    #[test]
3134    fn mixed_push_results_reconcile_and_prune_once_per_response() {
3135        let mut client = client();
3136        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3137        for index in 0..4 {
3138            client
3139                .mutate(vec![Mutation::Upsert {
3140                    table: "tasks".to_owned(),
3141                    values: Map::from_iter([
3142                        ("id".to_owned(), Value::from(format!("task-{index}"))),
3143                        ("project_id".to_owned(), Value::from("project-1")),
3144                    ]),
3145                    base_version: None,
3146                }])
3147                .expect("queue commit");
3148        }
3149
3150        let (_, request_meta) = client.build_request(false);
3151        let ids = &request_meta.pushed_ids;
3152        assert_eq!(ids.len(), 4);
3153        let response = Message {
3154            wire_version: WIRE_VERSION,
3155            msg_kind: MsgKind::Response,
3156            frames: vec![
3157                Frame::RespHeader {
3158                    required_schema_version: None,
3159                    latest_schema_version: None,
3160                    log_epoch: Some("epoch-1".to_owned()),
3161                    reset_required: Some(false),
3162                },
3163                Frame::PushResult {
3164                    client_commit_id: ids[0].clone(),
3165                    status: PushStatus::Applied,
3166                    commit_seq: Some(1),
3167                    results: vec![OpResult::Applied { op_index: 0 }],
3168                },
3169                Frame::PushResult {
3170                    client_commit_id: ids[1].clone(),
3171                    status: PushStatus::Cached,
3172                    commit_seq: Some(2),
3173                    results: vec![OpResult::Applied { op_index: 0 }],
3174                },
3175                Frame::PushResult {
3176                    client_commit_id: ids[2].clone(),
3177                    status: PushStatus::Rejected,
3178                    commit_seq: None,
3179                    results: vec![OpResult::Error {
3180                        op_index: 0,
3181                        code: "sync.validation_failed".to_owned(),
3182                        message: "rejected".to_owned(),
3183                        retryable: false,
3184                    }],
3185                },
3186                Frame::PushResult {
3187                    client_commit_id: ids[3].clone(),
3188                    status: PushStatus::Rejected,
3189                    commit_seq: None,
3190                    results: vec![OpResult::Error {
3191                        op_index: 0,
3192                        code: "sync.idempotency_cache_miss".to_owned(),
3193                        message: "retry".to_owned(),
3194                        retryable: true,
3195                    }],
3196                },
3197            ],
3198        };
3199        let mut transport =
3200            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
3201
3202        client.overlay_rebuild_count.set(0);
3203        client.outcome_prune_count.set(0);
3204        let outcome = client.process_response(&mut transport, response, &request_meta);
3205        let SyncOutcome::Ok(report) = outcome else {
3206            panic!("mixed push-result response failed");
3207        };
3208
3209        assert_eq!(report.applied, ids[..2]);
3210        assert_eq!(report.rejected, ids[2..3]);
3211        assert_eq!(report.retryable, ids[3..4]);
3212        assert_eq!(client.pending_commit_ids(), ids[3..4]);
3213        assert_eq!(
3214            client
3215                .query("SELECT id FROM tasks ORDER BY id", &[])
3216                .expect("query visible overlay"),
3217            vec![Map::from_iter([("id".to_owned(), Value::from("task-3"))])]
3218        );
3219        assert_eq!(client.overlay_rebuild_count.get(), 1);
3220        assert_eq!(client.outcome_prune_count.get(), 1);
3221    }
3222
3223    #[test]
3224    fn secondary_unique_collision_preserves_existing_synced_row() {
3225        let client = SyncClient::new(
3226            "unique-upsert-test".to_owned(),
3227            &json!({
3228                "version": 1,
3229                "tables": [{
3230                    "name": "tasks",
3231                    "primaryKey": "id",
3232                    "columns": [
3233                        { "name": "id", "type": "string", "nullable": false },
3234                        { "name": "project_id", "type": "string", "nullable": false },
3235                        { "name": "title", "type": "string", "nullable": false }
3236                    ],
3237                    "scopes": [{ "pattern": "project:{project_id}" }],
3238                    "indexes": [{
3239                        "name": "tasks_by_project_title",
3240                        "columns": ["project_id", "title"],
3241                        "unique": true
3242                    }]
3243                }]
3244            }),
3245            ClientLimits::default(),
3246        )
3247        .expect("test client");
3248        let table = client.schema.table("tasks").expect("tasks table");
3249        let sql = client.insert_row_sql(&base_table("tasks"), table);
3250
3251        client
3252            .conn
3253            .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
3254            .expect("insert first row");
3255        client
3256            .conn
3257            .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
3258            .expect("update same primary key");
3259        client
3260            .conn
3261            .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
3262            .expect("insert second row");
3263        assert!(client
3264            .conn
3265            .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
3266            .is_err());
3267
3268        let rows = client
3269            .conn
3270            .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
3271            .expect("prepare rows")
3272            .query_map([], |row| {
3273                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3274            })
3275            .expect("query rows")
3276            .collect::<Result<Vec<_>, _>>()
3277            .expect("collect rows");
3278        assert_eq!(
3279            rows,
3280            vec![
3281                ("t1".to_owned(), "updated".to_owned()),
3282                ("t2".to_owned(), "original".to_owned())
3283            ]
3284        );
3285    }
3286
3287    #[test]
3288    fn reopening_active_subscriptions_emits_a_catch_up_intent() {
3289        let path = std::env::temp_dir().join(format!(
3290            "syncular-startup-intent-{}.db",
3291            uuid::Uuid::new_v4()
3292        ));
3293        let schema = json!({
3294            "version": 1,
3295            "tables": [{
3296                "name": "tasks",
3297                "primaryKey": "id",
3298                "columns": [
3299                    { "name": "id", "type": "string", "nullable": false },
3300                    { "name": "project_id", "type": "string", "nullable": false }
3301                ],
3302                "scopes": [{ "pattern": "project:{project_id}" }]
3303            }]
3304        });
3305
3306        {
3307            let mut first = SyncClient::open_path_with_identity(
3308                None,
3309                &schema,
3310                ClientLimits::default(),
3311                path.to_str().expect("UTF-8 temp path"),
3312            )
3313            .expect("first open");
3314            first
3315                .set_window(
3316                    &WindowBase {
3317                        table: "tasks".to_owned(),
3318                        variable: "project_id".to_owned(),
3319                        fixed_scopes: Vec::new(),
3320                        params: None,
3321                    },
3322                    &["persisted".to_owned()],
3323                )
3324                .expect("persist window");
3325        }
3326
3327        let mut reopened = SyncClient::open_path_with_identity(
3328            None,
3329            &schema,
3330            ClientLimits::default(),
3331            path.to_str().expect("UTF-8 temp path"),
3332        )
3333        .expect("reopen");
3334        assert!(reopened.sync_needed());
3335        assert!(matches!(
3336            reopened.drain_sync_intents().as_slice(),
3337            [SyncIntent::Interactive]
3338        ));
3339        drop(reopened);
3340        std::fs::remove_file(path).expect("remove temp database");
3341    }
3342
3343    #[test]
3344    fn reopening_preserves_immutable_subscription_identity_and_progress() {
3345        let path = std::env::temp_dir().join(format!(
3346            "syncular-subscription-identity-{}.db",
3347            uuid::Uuid::new_v4()
3348        ));
3349        let schema = json!({
3350            "version": 1,
3351            "tables": [
3352                {
3353                    "name": "tasks",
3354                    "primaryKey": "id",
3355                    "columns": [
3356                        { "name": "id", "type": "string", "nullable": false },
3357                        { "name": "project_id", "type": "string", "nullable": false }
3358                    ],
3359                    "scopes": [{ "pattern": "project:{project_id}" }]
3360                },
3361                {
3362                    "name": "docs",
3363                    "primaryKey": "id",
3364                    "columns": [
3365                        { "name": "id", "type": "string", "nullable": false },
3366                        { "name": "org_id", "type": "string", "nullable": false },
3367                        { "name": "project_id", "type": "string", "nullable": false }
3368                    ],
3369                    "scopes": [
3370                        { "pattern": "org:{org_id}" },
3371                        { "pattern": "project:{projectId}", "column": "project_id" }
3372                    ]
3373                }
3374            ]
3375        });
3376
3377        {
3378            let mut first = SyncClient::open_path_with_identity(
3379                None,
3380                &schema,
3381                ClientLimits::default(),
3382                path.to_str().expect("UTF-8 temp path"),
3383            )
3384            .expect("first open");
3385            first
3386                .subscribe(
3387                    "stable-subscription".to_owned(),
3388                    "tasks".to_owned(),
3389                    vec![(
3390                        "project_id".to_owned(),
3391                        vec!["p2".to_owned(), "p1".to_owned()],
3392                    )],
3393                    Some(r#"{"view":"v1"}"#.to_owned()),
3394                )
3395                .expect("persist subscription");
3396            let persisted = {
3397                let subscription = first
3398                    .subs
3399                    .iter_mut()
3400                    .find(|subscription| subscription.id == "stable-subscription")
3401                    .expect("subscription");
3402                subscription.cursor = 41;
3403                subscription.bootstrap_state = Some("resume-token".to_owned());
3404                subscription.effective = Some(vec![(
3405                    "project_id".to_owned(),
3406                    vec!["p1".to_owned(), "p2".to_owned()],
3407                )]);
3408                subscription.synced_once = true;
3409                subscription.clone()
3410            };
3411            first.persist_sub(&persisted).unwrap();
3412        }
3413
3414        let mut reopened = SyncClient::open_path_with_identity(
3415            None,
3416            &schema,
3417            ClientLimits::default(),
3418            path.to_str().expect("UTF-8 temp path"),
3419        )
3420        .expect("reopen");
3421        let progress = reopened
3422            .subscription_state("stable-subscription")
3423            .expect("persisted state");
3424        assert_eq!(progress.cursor, 41);
3425        assert!(progress.has_resume_token);
3426
3427        reopened
3428            .subscribe(
3429                "stable-subscription".to_owned(),
3430                "tasks".to_owned(),
3431                vec![(
3432                    "project_id".to_owned(),
3433                    vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
3434                )],
3435                Some(r#"{"view":"v1"}"#.to_owned()),
3436            )
3437            .expect("canonical intent is idempotent");
3438        assert_eq!(
3439            reopened
3440                .subscription_state("stable-subscription")
3441                .expect("unchanged state")
3442                .cursor,
3443            progress.cursor
3444        );
3445
3446        for (table, scopes, params) in [
3447            (
3448                "tasks",
3449                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3450                Some(r#"{"view":"v1"}"#.to_owned()),
3451            ),
3452            (
3453                "tasks",
3454                vec![(
3455                    "project_id".to_owned(),
3456                    vec!["p2".to_owned(), "p1".to_owned()],
3457                )],
3458                Some(r#"{"view":"v2"}"#.to_owned()),
3459            ),
3460            (
3461                "docs",
3462                vec![
3463                    ("org_id".to_owned(), vec!["o1".to_owned()]),
3464                    ("projectId".to_owned(), vec!["p1".to_owned()]),
3465                ],
3466                Some(r#"{"view":"v1"}"#.to_owned()),
3467            ),
3468        ] {
3469            let error = reopened
3470                .subscribe(
3471                    "stable-subscription".to_owned(),
3472                    table.to_owned(),
3473                    scopes,
3474                    params,
3475                )
3476                .expect_err("identity rebind must fail");
3477            assert!(error.starts_with("client.subscription_intent_mismatch:"));
3478            assert_eq!(
3479                reopened
3480                    .subscription_state("stable-subscription")
3481                    .expect("unchanged state")
3482                    .cursor,
3483                progress.cursor
3484            );
3485        }
3486
3487        drop(reopened);
3488        std::fs::remove_file(path).expect("remove temp database");
3489    }
3490
3491    #[test]
3492    fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
3493        let path = std::env::temp_dir().join(format!(
3494            "syncular-satisfied-schema-floor-{}.db",
3495            uuid::Uuid::new_v4()
3496        ));
3497        let schema = json!({
3498            "version": 23,
3499            "tables": [{
3500                "name": "tasks",
3501                "primaryKey": "id",
3502                "columns": [
3503                    { "name": "id", "type": "string", "nullable": false },
3504                    { "name": "project_id", "type": "string", "nullable": false }
3505                ],
3506                "scopes": [{ "pattern": "project:{project_id}" }]
3507            }]
3508        });
3509
3510        {
3511            let mut first = SyncClient::open_path_with_identity(
3512                None,
3513                &schema,
3514                ClientLimits::default(),
3515                path.to_str().expect("UTF-8 temp path"),
3516            )
3517            .expect("first open");
3518            first
3519                .subscribe(
3520                    "tasks".to_owned(),
3521                    "tasks".to_owned(),
3522                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3523                    None,
3524                )
3525                .expect("persist subscription");
3526            first.set_schema_floor(Some(SchemaFloor {
3527                required_schema_version: Some(22),
3528                latest_schema_version: Some(22),
3529            }));
3530        }
3531
3532        let mut reopened = SyncClient::open_path_with_identity(
3533            None,
3534            &schema,
3535            ClientLimits::default(),
3536            path.to_str().expect("UTF-8 temp path"),
3537        )
3538        .expect("reopen");
3539        assert!(reopened.schema_floor().is_none());
3540        assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
3541        assert!(reopened.sync_needed());
3542        assert!(matches!(
3543            reopened.drain_sync_intents().as_slice(),
3544            [SyncIntent::Interactive]
3545        ));
3546        drop(reopened);
3547        std::fs::remove_file(path).expect("remove temp database");
3548    }
3549
3550    #[test]
3551    fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
3552        let path = std::env::temp_dir().join(format!(
3553            "syncular-unsatisfied-schema-floor-{}.db",
3554            uuid::Uuid::new_v4()
3555        ));
3556        let schema = json!({
3557            "version": 1,
3558            "tables": [{
3559                "name": "tasks",
3560                "primaryKey": "id",
3561                "columns": [
3562                    { "name": "id", "type": "string", "nullable": false },
3563                    { "name": "project_id", "type": "string", "nullable": false }
3564                ],
3565                "scopes": [{ "pattern": "project:{project_id}" }]
3566            }]
3567        });
3568
3569        {
3570            let mut first = SyncClient::open_path_with_identity(
3571                None,
3572                &schema,
3573                ClientLimits::default(),
3574                path.to_str().expect("UTF-8 temp path"),
3575            )
3576            .expect("first open");
3577            first
3578                .subscribe(
3579                    "tasks".to_owned(),
3580                    "tasks".to_owned(),
3581                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3582                    None,
3583                )
3584                .expect("persist subscription");
3585            first.set_schema_floor(Some(SchemaFloor {
3586                required_schema_version: Some(2),
3587                latest_schema_version: Some(2),
3588            }));
3589        }
3590
3591        let reopened = SyncClient::open_path_with_identity(
3592            None,
3593            &schema,
3594            ClientLimits::default(),
3595            path.to_str().expect("UTF-8 temp path"),
3596        )
3597        .expect("reopen");
3598        assert_eq!(
3599            reopened.schema_floor(),
3600            Some(&SchemaFloor {
3601                required_schema_version: Some(2),
3602                latest_schema_version: Some(2),
3603            })
3604        );
3605        assert!(!reopened.sync_needed());
3606        drop(reopened);
3607        std::fs::remove_file(path).expect("remove temp database");
3608    }
3609
3610    #[test]
3611    fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
3612        let path = std::env::temp_dir().join(format!(
3613            "syncular-indexed-column-bump-{}.db",
3614            uuid::Uuid::new_v4()
3615        ));
3616        let old_schema = json!({
3617            "version": 1,
3618            "tables": [
3619                {
3620                    "name": "tasks",
3621                    "primaryKey": "id",
3622                    "columns": [
3623                        { "name": "id", "type": "string", "nullable": false },
3624                        { "name": "project_id", "type": "string", "nullable": false }
3625                    ],
3626                    "scopes": [{ "pattern": "project:{project_id}" }]
3627                },
3628                {
3629                    "name": "legacy",
3630                    "primaryKey": "id",
3631                    "columns": [
3632                        { "name": "id", "type": "string", "nullable": false },
3633                        { "name": "project_id", "type": "string", "nullable": false }
3634                    ],
3635                    "scopes": [{ "pattern": "project:{project_id}" }]
3636                }
3637            ]
3638        });
3639        {
3640            let mut first = SyncClient::open_path_with_identity(
3641                None,
3642                &old_schema,
3643                ClientLimits::default(),
3644                path.to_str().expect("UTF-8 temp path"),
3645            )
3646            .expect("open old schema");
3647            first
3648                .subscribe(
3649                    "legacy-sub".to_owned(),
3650                    "legacy".to_owned(),
3651                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3652                    None,
3653                )
3654                .expect("persist legacy subscription");
3655        }
3656
3657        let new_schema = json!({
3658            "version": 2,
3659            "tables": [{
3660                "name": "tasks",
3661                "primaryKey": "id",
3662                "columns": [
3663                    { "name": "id", "type": "string", "nullable": false },
3664                    { "name": "project_id", "type": "string", "nullable": false },
3665                    { "name": "facility_membership_id", "type": "string", "nullable": true }
3666                ],
3667                "scopes": [{ "pattern": "project:{project_id}" }],
3668                "indexes": [{
3669                    "name": "tasks_by_membership",
3670                    "columns": ["project_id", "facility_membership_id"],
3671                    "unique": false
3672                }]
3673            }]
3674        });
3675        let upgraded = SyncClient::open_path_with_identity(
3676            None,
3677            &new_schema,
3678            ClientLimits::default(),
3679            path.to_str().expect("UTF-8 temp path"),
3680        )
3681        .expect("open upgraded schema");
3682        let columns = upgraded
3683            .conn
3684            .prepare("PRAGMA table_info(tasks)")
3685            .expect("prepare columns")
3686            .query_map([], |row| row.get::<_, String>(1))
3687            .expect("query columns")
3688            .collect::<Result<Vec<_>, _>>()
3689            .expect("collect columns");
3690        assert!(columns.contains(&"facility_membership_id".to_owned()));
3691        let index_count: i64 = upgraded
3692            .conn
3693            .query_row(
3694                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
3695                [],
3696                |row| row.get(0),
3697            )
3698            .expect("query index");
3699        assert_eq!(index_count, 1);
3700        assert!(upgraded.subscription_state("legacy-sub").is_none());
3701        drop(upgraded);
3702        std::fs::remove_file(path).expect("remove temp database");
3703    }
3704
3705    #[test]
3706    fn migrates_pre_envelope_outcome_journal_additively() {
3707        let path = std::env::temp_dir().join(format!(
3708            "syncular-outcome-migration-{}.db",
3709            uuid::Uuid::new_v4()
3710        ));
3711        let conn = Connection::open(&path).expect("open legacy database");
3712        conn.execute_batch(
3713            "CREATE TABLE _syncular_commit_outcomes (
3714               seq INTEGER PRIMARY KEY AUTOINCREMENT,
3715               client_commit_id TEXT NOT NULL UNIQUE,
3716               status TEXT NOT NULL,
3717               recorded_at_ms INTEGER NOT NULL,
3718               results_json TEXT NOT NULL,
3719               resolution TEXT NOT NULL DEFAULT 'active',
3720               resolved_at_ms INTEGER,
3721               replacement_client_commit_id TEXT);",
3722        )
3723        .expect("create legacy outcome journal");
3724        drop(conn);
3725        let schema = json!({
3726            "version": 1,
3727            "tables": [{
3728                "name": "tasks",
3729                "primaryKey": "id",
3730                "columns": [
3731                    { "name": "id", "type": "string", "nullable": false },
3732                    { "name": "project_id", "type": "string", "nullable": false }
3733                ],
3734                "scopes": [{ "pattern": "project:{project_id}" }]
3735            }]
3736        });
3737        let client = SyncClient::open_path(
3738            "migration-native".to_owned(),
3739            &schema,
3740            ClientLimits::default(),
3741            path.to_str().expect("UTF-8 temp path"),
3742        )
3743        .expect("migrate database");
3744        let has_operations = client
3745            .conn
3746            .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
3747            .expect("prepare table info")
3748            .query_map([], |row| row.get::<_, String>(1))
3749            .expect("query table info")
3750            .filter_map(Result::ok)
3751            .any(|column| column == "operations_json");
3752        assert!(has_operations);
3753        drop(client);
3754        std::fs::remove_file(path).expect("remove temp database");
3755    }
3756
3757    #[test]
3758    fn retained_rejection_survives_file_reopen_and_security_purge() {
3759        let path =
3760            std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
3761        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
3762            { "name": "id", "type": "string", "nullable": false },
3763            { "name": "project_id", "type": "string", "nullable": false },
3764            { "name": "title", "type": "string", "nullable": false }
3765        ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
3766        {
3767            let mut first = SyncClient::open_path(
3768                "retained-native".to_owned(),
3769                &schema,
3770                ClientLimits::default(),
3771                path.to_str().unwrap(),
3772            )
3773            .unwrap();
3774            first
3775                .write_base_row(
3776                    "tasks",
3777                    &vec![
3778                        Some(ColumnValue::String("t1".into())),
3779                        Some(ColumnValue::String("p1".into())),
3780                        Some(ColumnValue::String("server".into())),
3781                    ],
3782                    1,
3783                )
3784                .unwrap();
3785            first.rebuild_overlay();
3786            let operation = OutboxOp {
3787                table: "tasks".into(),
3788                row_id: "t1".into(),
3789                upsert: true,
3790                base_version: Some(1),
3791                values: Some(Map::from_iter([
3792                    ("id".into(), json!("t1")),
3793                    ("title".into(), json!("mine")),
3794                ])),
3795            };
3796            first
3797                .retain_failed_commit("retained", std::slice::from_ref(&operation))
3798                .unwrap();
3799            first
3800                .persist_commit_outcome(
3801                    "retained",
3802                    CommitOutcomeStatus::Rejected,
3803                    &[],
3804                    Some(&[operation]),
3805                )
3806                .unwrap();
3807            first.rebuild_overlay();
3808        }
3809        {
3810            let mut reopened = SyncClient::open_path(
3811                "retained-native".to_owned(),
3812                &schema,
3813                ClientLimits::default(),
3814                path.to_str().unwrap(),
3815            )
3816            .unwrap();
3817            let row = reopened
3818                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
3819                .unwrap();
3820            assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
3821            assert_eq!(
3822                reopened
3823                    .commit_outcome("retained")
3824                    .unwrap()
3825                    .unwrap()
3826                    .retained_rows
3827                    .unwrap()[0]
3828                    .server_row
3829                    .as_ref()
3830                    .unwrap()["title"],
3831                json!("server")
3832            );
3833            reopened
3834                .conn
3835                .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
3836                .unwrap();
3837            reopened.rebuild_overlay();
3838            assert!(reopened
3839                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
3840                .is_none());
3841            let retained = reopened.commit_outcome("retained").unwrap().unwrap();
3842            assert!(retained.retained_rows.is_some());
3843            assert_eq!(
3844                retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
3845                json!("mine")
3846            );
3847            reopened
3848                .purge_local_data(&LocalDataPurgeInput {
3849                    purge_id: "protected-purge".into(),
3850                    targets: vec![LocalDataPurgeTarget {
3851                        table: "tasks".into(),
3852                        selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
3853                    }],
3854                })
3855                .unwrap();
3856            assert!(reopened
3857                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
3858                .is_none());
3859            assert!(reopened
3860                .commit_outcome("retained")
3861                .unwrap()
3862                .unwrap()
3863                .retained_rows
3864                .is_none());
3865        }
3866        std::fs::remove_file(path).unwrap();
3867    }
3868
3869    #[test]
3870    fn durable_conflict_outcome_and_resolution_survive_reopen() {
3871        let path = std::env::temp_dir().join(format!(
3872            "syncular-durable-outcome-{}.db",
3873            uuid::Uuid::new_v4()
3874        ));
3875        let schema = json!({
3876            "version": 1,
3877            "tables": [{
3878                "name": "tasks",
3879                "primaryKey": "id",
3880                "columns": [
3881                    { "name": "id", "type": "string", "nullable": false },
3882                    { "name": "project_id", "type": "string", "nullable": false }
3883                ],
3884                "scopes": [{ "pattern": "project:{project_id}" }]
3885            }]
3886        });
3887        let conflict = ConflictRecord {
3888            client_commit_id: "losing-commit".to_owned(),
3889            op_index: 0,
3890            table: "tasks".to_owned(),
3891            row_id: "t1".to_owned(),
3892            code: "sync.version_conflict".to_owned(),
3893            message: "stale base version".to_owned(),
3894            server_version: 2,
3895            server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
3896            conflict_columns: vec!["project_id".to_owned()],
3897            operation: Some(CommitOperation {
3898                table: "tasks".to_owned(),
3899                row_id: "t1".to_owned(),
3900                op: "upsert".to_owned(),
3901                base_version: Some(1),
3902                values: None,
3903            }),
3904        };
3905        let failed_operations = vec![
3906            OutboxOp {
3907                upsert: true,
3908                table: "tasks".to_owned(),
3909                row_id: "t1".to_owned(),
3910                base_version: Some(1),
3911                values: None,
3912            },
3913            OutboxOp {
3914                upsert: true,
3915                table: "tasks".to_owned(),
3916                row_id: "status-event-1".to_owned(),
3917                base_version: Some(0),
3918                values: None,
3919            },
3920        ];
3921
3922        {
3923            let mut first = SyncClient::open_path(
3924                "durable-native".to_owned(),
3925                &schema,
3926                ClientLimits::default(),
3927                path.to_str().expect("UTF-8 temp path"),
3928            )
3929            .expect("first open");
3930            first
3931                .begin_observation("test_outcome")
3932                .expect("begin outcome");
3933            first
3934                .persist_commit_outcome(
3935                    "losing-commit",
3936                    CommitOutcomeStatus::Conflict,
3937                    &[CommitOperationOutcome::Conflict {
3938                        conflict: conflict.clone(),
3939                    }],
3940                    Some(&failed_operations),
3941                )
3942                .expect("persist outcome");
3943            first.conflicts.push(conflict);
3944            first
3945                .finish_observation(
3946                    "test_outcome",
3947                    ChangeAccumulator {
3948                        conflicts: true,
3949                        outcomes: true,
3950                        ..ChangeAccumulator::default()
3951                    },
3952                )
3953                .expect("commit outcome");
3954        }
3955
3956        {
3957            let mut reopened = SyncClient::open_path(
3958                "durable-native".to_owned(),
3959                &schema,
3960                ClientLimits::default(),
3961                path.to_str().expect("UTF-8 temp path"),
3962            )
3963            .expect("reopen");
3964            assert_eq!(reopened.conflicts().len(), 1);
3965            assert_eq!(
3966                reopened.conflicts()[0].conflict_columns,
3967                vec!["project_id".to_owned()],
3968                "conflictColumns survives the journal round trip"
3969            );
3970            let outcome = reopened
3971                .commit_outcome("losing-commit")
3972                .expect("read outcome")
3973                .expect("outcome");
3974            assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
3975            let operations = outcome.operations.expect("aggregate envelope");
3976            assert_eq!(operations.len(), 2);
3977            assert_eq!(operations[1].row_id, "status-event-1");
3978            let resolved = reopened
3979                .resolve_commit_outcome(ResolveCommitOutcomeInput {
3980                    client_commit_id: "losing-commit".to_owned(),
3981                    resolution: CommitOutcomeResolution::ResolvedKeepServer,
3982                    replacement_client_commit_id: None,
3983                })
3984                .expect("resolve");
3985            assert_eq!(
3986                resolved.resolution,
3987                CommitOutcomeResolution::ResolvedKeepServer
3988            );
3989            assert!(reopened.conflicts().is_empty());
3990        }
3991
3992        let reopened = SyncClient::open_path(
3993            "durable-native".to_owned(),
3994            &schema,
3995            ClientLimits::default(),
3996            path.to_str().expect("UTF-8 temp path"),
3997        )
3998        .expect("second reopen");
3999        assert!(reopened.conflicts().is_empty());
4000        assert_eq!(
4001            reopened
4002                .commit_outcome("losing-commit")
4003                .expect("read outcome")
4004                .expect("outcome")
4005                .resolution,
4006            CommitOutcomeResolution::ResolvedKeepServer
4007        );
4008        drop(reopened);
4009        std::fs::remove_file(path).expect("remove temp database");
4010    }
4011
4012    #[test]
4013    fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
4014        let path =
4015            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
4016        let schema = json!({
4017            "version": 1,
4018            "tables": [{
4019                "name": "tasks",
4020                "primaryKey": "id",
4021                "columns": [
4022                    { "name": "id", "type": "string", "nullable": false },
4023                    { "name": "project_id", "type": "string", "nullable": false }
4024                ],
4025                "scopes": [{ "pattern": "project:{project_id}" }]
4026            }]
4027        });
4028        let mut client = SyncClient::open_path_with_identity(
4029            Some("sidecar-client".to_owned()),
4030            &schema,
4031            ClientLimits::default(),
4032            path.to_str().expect("UTF-8 temp path"),
4033        )
4034        .expect("open owner");
4035        let base = WindowBase {
4036            table: "tasks".to_owned(),
4037            variable: "project_id".to_owned(),
4038            fixed_scopes: Vec::new(),
4039            params: None,
4040        };
4041        client
4042            .set_window(&base, &["one".to_owned()])
4043            .expect("set window");
4044        client
4045            .mutate(vec![Mutation::Upsert {
4046                table: "tasks".to_owned(),
4047                values: Map::from_iter([
4048                    ("id".to_owned(), Value::from("t1")),
4049                    ("project_id".to_owned(), Value::from("one")),
4050                ]),
4051                base_version: None,
4052            }])
4053            .expect("local mutate");
4054
4055        let coverage = [WindowCoverage {
4056            base,
4057            units: vec!["one".to_owned(), "missing".to_owned()],
4058        }];
4059        let owner = client
4060            .query_snapshot(
4061                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
4062                &[],
4063                &coverage,
4064                None,
4065            )
4066            .expect("owner snapshot");
4067        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
4068        let sidecar = reader
4069            .query_snapshot(
4070                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
4071                &[],
4072                &coverage,
4073            )
4074            .expect("sidecar snapshot");
4075
4076        assert_eq!(sidecar.revision, owner.revision);
4077        assert_eq!(sidecar.rows, owner.rows);
4078        assert_eq!(
4079            serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
4080            serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
4081        );
4082        assert_eq!(sidecar.revision, "2");
4083        assert_eq!(sidecar.rows[0]["id"], "t1");
4084        assert_eq!(sidecar.rows[0]["server_version"], -1);
4085        assert!(!sidecar.coverage.complete);
4086        assert_eq!(sidecar.coverage.pending.len(), 1);
4087        assert_eq!(sidecar.coverage.missing.len(), 1);
4088
4089        drop(reader);
4090        drop(client);
4091        std::fs::remove_file(path).expect("remove temp database");
4092    }
4093
4094    #[test]
4095    fn owned_query_failures_classify_order_bound_and_clear() {
4096        let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
4097            rusqlite::ffi::Error::new(266),
4098            Some("private SQLite prose".to_owned()),
4099        ));
4100        assert_eq!(io.code, Some("client.storage_io"));
4101        assert_eq!(io.sqlite_code, Some(266));
4102        assert_eq!(io.message, "local SQLite storage I/O failed");
4103        let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
4104            rusqlite::ffi::Error::new(26),
4105            None,
4106        ));
4107        assert_eq!(corrupt.code, Some("client.storage_corrupt"));
4108
4109        let mut client = client();
4110        let generic = QueryReadFailure::from("private query prose".to_owned());
4111        client.record_query_read(
4112            QueryOwner {
4113                id: "first",
4114                tables: &["tasks", "tasks"],
4115            },
4116            Some(&generic),
4117        );
4118        client.record_query_read(
4119            QueryOwner {
4120                id: "second",
4121                tables: &["tasks"],
4122            },
4123            Some(&generic),
4124        );
4125        client.record_query_read(
4126            QueryOwner {
4127                id: "first",
4128                tables: &["docs"],
4129            },
4130            Some(&io),
4131        );
4132        let failures = &client
4133            .diagnostics_snapshot(&Default::default())
4134            .expect("diagnostics")
4135            .query_failures;
4136        assert_eq!(
4137            failures
4138                .iter()
4139                .map(|entry| entry.id.as_str())
4140                .collect::<Vec<_>>(),
4141            vec!["second", "first"]
4142        );
4143        assert_eq!(failures[1].tables, vec!["docs"]);
4144        assert_eq!(failures[1].code, "client.storage_io");
4145
4146        for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
4147            client.record_query_read(
4148                QueryOwner {
4149                    id: &format!("query-{index}"),
4150                    tables: &["tasks"],
4151                },
4152                Some(&generic),
4153            );
4154        }
4155        let failures = &client
4156            .diagnostics_snapshot(&Default::default())
4157            .expect("bounded diagnostics")
4158            .query_failures;
4159        assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
4160        assert_eq!(failures.last().expect("last failure").id, "query-256");
4161
4162        client
4163            .query_snapshot(
4164                "SELECT id FROM tasks",
4165                &[],
4166                &[],
4167                Some(QueryOwner {
4168                    id: "query-256",
4169                    tables: &["tasks"],
4170                }),
4171            )
4172            .expect("successful owned read");
4173        assert!(client
4174            .diagnostics_snapshot(&Default::default())
4175            .expect("cleared diagnostics")
4176            .query_failures
4177            .iter()
4178            .all(|entry| entry.id != "query-256"));
4179    }
4180
4181    #[test]
4182    fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
4183        let mut client = client();
4184        let image = Connection::open_in_memory().unwrap();
4185        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
4186            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4187            INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
4188            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4189            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
4190        let table = client.schema.table("tasks").unwrap().clone();
4191        let scopes = vec![("project_id".into(), vec!["p1".into()])];
4192        assert!(client
4193            .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
4194            .is_err());
4195        assert!(client.conn.is_autocommit());
4196        assert_eq!(
4197            client
4198                .query("SELECT count(*) AS n FROM tasks", &[])
4199                .unwrap()[0]["n"],
4200            1024
4201        );
4202        assert_eq!(client.drain_change_batches().len(), 1);
4203        image
4204            .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
4205            .unwrap();
4206        assert!(matches!(
4207            client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
4208            Ok(2050)
4209        ));
4210        assert_eq!(
4211            client
4212                .query("SELECT count(*) AS n FROM tasks", &[])
4213                .unwrap()[0]["n"],
4214            2050
4215        );
4216        assert_eq!(client.drain_change_batches().len(), 3);
4217    }
4218
4219    #[test]
4220    fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
4221        let mut client = client();
4222        let base = WindowBase {
4223            table: "tasks".into(),
4224            variable: "project_id".into(),
4225            fixed_scopes: Vec::new(),
4226            params: None,
4227        };
4228        client
4229            .set_window(&base, &["p1".into(), "p2".into()])
4230            .unwrap();
4231        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4232            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
4233            INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
4234            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
4235            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
4236        client.drain_change_batches();
4237        assert!(client.set_window(&base, &["p2".into()]).is_err());
4238        assert!(client.conn.is_autocommit());
4239        assert_eq!(
4240            client
4241                .query("SELECT count(*) AS n FROM tasks", &[])
4242                .unwrap()[0]["n"],
4243            1027
4244        );
4245        assert_eq!(client.window_state(&base).units, vec!["p2"]);
4246        assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
4247        assert_eq!(client.drain_change_batches().len(), 1);
4248        client
4249            .conn
4250            .execute_batch("DROP TRIGGER interrupt_evict")
4251            .unwrap();
4252        client.drain_pending_evictions().unwrap();
4253        assert!(client.load_pending_evictions().unwrap().is_empty());
4254        assert_eq!(
4255            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
4256            "held"
4257        );
4258        assert_eq!(client.drain_change_batches().len(), 2);
4259    }
4260
4261    #[test]
4262    fn eviction_marker_failures_roll_back_window_changes() {
4263        let mut client = client();
4264        let base = WindowBase {
4265            table: "tasks".into(),
4266            variable: "project_id".into(),
4267            fixed_scopes: Vec::new(),
4268            params: None,
4269        };
4270        client.set_window(&base, &["p1".into()]).unwrap();
4271        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4272            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
4273            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
4274            CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4275        let revision = client.local_revision();
4276        assert!(client.set_window(&base, &[]).is_err());
4277        assert!(client.conn.is_autocommit());
4278        assert_eq!(client.local_revision(), revision);
4279        assert_eq!(
4280            client
4281                .query("SELECT count(*) AS n FROM tasks", &[])
4282                .unwrap()[0]["n"],
4283            2050
4284        );
4285        assert_eq!(client.window_state(&base).units, vec!["p1"]);
4286        client.conn.execute_batch("DROP TRIGGER fail_marker;
4287            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
4288        assert!(client.set_window(&base, &[]).is_err());
4289        client.conn.execute_batch("DROP TRIGGER interrupt_evict;
4290            CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4291        assert!(client.drain_pending_evictions().is_err());
4292        assert!(client.conn.is_autocommit());
4293        assert_eq!(
4294            client
4295                .query("SELECT count(*) AS n FROM tasks", &[])
4296                .unwrap()[0]["n"],
4297            2
4298        );
4299        assert!(client.set_window(&base, &["p1".into()]).is_err());
4300        assert!(client.conn.is_autocommit());
4301        assert!(client.window_state(&base).units.is_empty());
4302        assert!(client.subs.is_empty());
4303        assert_eq!(
4304            client
4305                .query(
4306                    "SELECT count(*) AS n FROM _syncular_window_pending_evict",
4307                    &[]
4308                )
4309                .unwrap()[0]["n"],
4310            1
4311        );
4312        client
4313            .conn
4314            .execute_batch("DROP TRIGGER fail_marker")
4315            .unwrap();
4316        client.set_window(&base, &["p1".into()]).unwrap();
4317        assert_eq!(client.window_state(&base).units, vec!["p1"]);
4318    }
4319
4320    #[test]
4321    fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
4322        let mut client = client();
4323        client
4324            .conn
4325            .execute(
4326                "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
4327                [],
4328            )
4329            .unwrap();
4330        assert_eq!(
4331            client.drain_pending_evictions().unwrap_err(),
4332            "sync.local_corrupt: invalid pending eviction scopes"
4333        );
4334        assert!(client.conn.is_autocommit());
4335        assert!(client.enqueue_startup_sync_if_needed().is_err());
4336    }
4337
4338    #[test]
4339    fn image_chunks_reject_null_primary_keys() {
4340        let mut client = client();
4341        let image = Connection::open_in_memory().unwrap();
4342        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4343            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
4344            INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
4345            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4346            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
4347        let table = client.schema.table("tasks").unwrap().clone();
4348        assert!(client
4349            .apply_sqlite_image(
4350                &image,
4351                &table,
4352                true,
4353                &[("project_id".into(), vec!["p1".into()])],
4354                1025,
4355                7,
4356                "digest"
4357            )
4358            .is_err());
4359        assert!(client.conn.is_autocommit());
4360        assert_eq!(
4361            client
4362                .query("SELECT count(*) AS n FROM tasks", &[])
4363                .unwrap()[0]["n"],
4364            0
4365        );
4366    }
4367
4368    #[test]
4369    fn import_chunks_with_unique_constraints_match_full_pending_replay() {
4370        for images in [false, true] {
4371            let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4372                "name":name,"primaryKey":"id","columns":[
4373                    {"name":"id","type":"string","nullable":false},
4374                    {"name":"project_id","type":"string","nullable":false},
4375                    {"name":"title","type":"string","nullable":false}],
4376                "scopes":[{"pattern":"project:{project_id}"}],
4377                "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
4378                "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4379            })))});
4380            let mut actual =
4381                SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
4382            let mut reference =
4383                SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
4384            let scopes = vec![("project_id".into(), vec!["p1".into()])];
4385            for client in [&mut actual, &mut reference] {
4386                client
4387                    .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
4388                    .unwrap();
4389                client.subs[0].effective = Some(scopes.clone());
4390                for (table, id, title) in [
4391                    ("tasks", "a", "original"),
4392                    ("tasks", "b", "occupied"),
4393                    ("tasks", "c", "deleted"),
4394                    ("notes", "n", "untouched"),
4395                ] {
4396                    client
4397                        .write_base_row(
4398                            table,
4399                            &vec![
4400                                Some(ColumnValue::String(id.into())),
4401                                Some(ColumnValue::String("p1".into())),
4402                                Some(ColumnValue::String(title.into())),
4403                            ],
4404                            1,
4405                        )
4406                        .unwrap();
4407                }
4408                client.rebuild_overlay_if_dirty();
4409                client
4410                    .mutate(vec![
4411                        Mutation::Upsert {
4412                            table: "tasks".into(),
4413                            values: Map::from_iter([
4414                                ("id".into(), Value::from("a")),
4415                                ("project_id".into(), Value::from("p1")),
4416                                ("title".into(), Value::from("available")),
4417                            ]),
4418                            base_version: None,
4419                        },
4420                        Mutation::Delete {
4421                            table: "tasks".into(),
4422                            row_id: "c".into(),
4423                            base_version: None,
4424                        },
4425                        Mutation::Upsert {
4426                            table: "tasks".into(),
4427                            values: Map::from_iter([
4428                                ("id".into(), Value::from("local")),
4429                                ("project_id".into(), Value::from("p1")),
4430                                ("title".into(), Value::from("local value")),
4431                            ]),
4432                            base_version: None,
4433                        },
4434                        Mutation::Upsert {
4435                            table: "notes".into(),
4436                            values: Map::from_iter([
4437                                ("id".into(), Value::from("n")),
4438                                ("project_id".into(), Value::from("p1")),
4439                                ("title".into(), Value::from("pending note")),
4440                            ]),
4441                            base_version: None,
4442                        },
4443                    ])
4444                    .unwrap();
4445            }
4446            let pending = actual.pending_commit_ids();
4447            actual.overlay_rebuild_count.set(0);
4448            for (id, title) in [
4449                ("b", "available"),
4450                ("b", "occupied"),
4451                ("a", "server original"),
4452                ("d", "local value"),
4453                ("d", "released"),
4454                ("c", "redelivered"),
4455            ] {
4456                let table = actual.schema.table("tasks").unwrap().clone();
4457                let values = vec![
4458                    Some(ColumnValue::String(id.into())),
4459                    Some(ColumnValue::String("p1".into())),
4460                    Some(ColumnValue::String(title.into())),
4461                ];
4462                if images {
4463                    let image = Connection::open_in_memory().unwrap();
4464                    image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4465                        CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4466                        INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4467                    image
4468                        .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
4469                        .unwrap();
4470                    assert!(matches!(
4471                        actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
4472                        Ok(1)
4473                    ));
4474                } else {
4475                    let segment = RowsSegment {
4476                        table: "tasks".into(),
4477                        schema_version: 1,
4478                        columns: table.wire_columns.clone(),
4479                        blocks: vec![vec![ssp2::segment::SegmentRow {
4480                            server_version: 2,
4481                            values: values.clone(),
4482                        }]],
4483                    };
4484                    assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
4485                }
4486                reference.write_base_row("tasks", &values, 2).unwrap();
4487                reference.rebuild_overlay();
4488                for table in ["tasks", "notes"] {
4489                    for sql in [format!("SELECT * FROM {table} ORDER BY id"),format!("SELECT _syncular_source_id,title FROM {table}_fts ORDER BY _syncular_source_id"),format!("SELECT source_id FROM _syncular_fts_{table}_fts ORDER BY source_id")] {
4490                        assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
4491                    }
4492                }
4493                assert_eq!(actual.pending_commit_ids(), pending);
4494            }
4495            assert_eq!(
4496                actual.overlay_rebuild_count.get(),
4497                0,
4498                "each chunk must leave unrelated tables alone"
4499            );
4500            let before = actual
4501                .query("SELECT * FROM tasks ORDER BY id", &[])
4502                .unwrap();
4503            let fts_before = actual
4504                .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4505                .unwrap();
4506            let revision = actual.local_revision();
4507            actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
4508            let table = actual.schema.table("tasks").unwrap().clone();
4509            let segment = RowsSegment {
4510                table: "tasks".into(),
4511                schema_version: 1,
4512                columns: table.wire_columns.clone(),
4513                blocks: vec![vec![ssp2::segment::SegmentRow {
4514                    server_version: 3,
4515                    values: vec![
4516                        Some(ColumnValue::String("b".into())),
4517                        Some(ColumnValue::String("p1".into())),
4518                        Some(ColumnValue::String("fresh".into())),
4519                    ],
4520                }]],
4521            };
4522            assert!(actual.apply_segment(0, &segment, false).is_err());
4523            assert!(actual.conn.is_autocommit());
4524            assert_eq!(
4525                actual
4526                    .query("SELECT * FROM tasks ORDER BY id", &[])
4527                    .unwrap(),
4528                before
4529            );
4530            assert_eq!(
4531                actual
4532                    .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4533                    .unwrap(),
4534                fts_before
4535            );
4536            assert_eq!(actual.local_revision(), revision);
4537            assert_eq!(actual.pending_commit_ids(), pending);
4538        }
4539    }
4540
4541    #[test]
4542    fn import_reconciliation_preserves_typed_pending_primary_keys() {
4543        for (kind, a, b) in [
4544            ("string", json!("a"), json!("b")),
4545            ("integer", json!(1), json!(2)),
4546            ("boolean", json!(true), json!(false)),
4547            ("json", json!("1"), json!("2")),
4548        ] {
4549            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[{"name":"id","type":kind,"nullable":false},{"name":"title","type":"string","nullable":false}],"scopes":[],"indexes":[{"name":"unique_title","columns":["title"],"unique":true}]}]});
4550            for images in [false, true] {
4551                let mut client =
4552                    SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
4553                let table = client.schema.table("tasks").unwrap().clone();
4554                client
4555                    .subscribe("tasks".into(), "tasks".into(), vec![], None)
4556                    .unwrap();
4557                for (id, title) in [(&a, "original"), (&b, "occupied")] {
4558                    client
4559                        .write_base_row(
4560                            "tasks",
4561                            &vec![
4562                                json_to_column_value(&table.columns[0], Some(id)).unwrap(),
4563                                Some(ColumnValue::String(title.into())),
4564                            ],
4565                            1,
4566                        )
4567                        .unwrap();
4568                }
4569                client.rebuild_overlay_if_dirty();
4570                client
4571                    .mutate(vec![Mutation::Upsert {
4572                        table: "tasks".into(),
4573                        values: Map::from_iter([
4574                            ("id".into(), a.clone()),
4575                            ("title".into(), json!("available")),
4576                        ]),
4577                        base_version: None,
4578                    }])
4579                    .unwrap();
4580                client.overlay_rebuild_count.set(0);
4581                for (title, expected) in [("available", "original"), ("occupied", "available")] {
4582                    let values = vec![
4583                        json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
4584                        Some(ColumnValue::String(title.into())),
4585                    ];
4586                    if images {
4587                        let image = Connection::open_in_memory().unwrap();
4588                        image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4589                            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4590                            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4591                        image
4592                            .execute(
4593                                "INSERT INTO tasks VALUES (?1,?2,2)",
4594                                rusqlite::params![RowParam::Cell(&values[0]), title],
4595                            )
4596                            .unwrap();
4597                        assert!(
4598                            matches!(
4599                                client.apply_sqlite_image(
4600                                    &image,
4601                                    &table,
4602                                    false,
4603                                    &[],
4604                                    1,
4605                                    7,
4606                                    "digest"
4607                                ),
4608                                Ok(1)
4609                            ),
4610                            "{kind}, images={images}"
4611                        );
4612                    } else {
4613                        let segment = RowsSegment {
4614                            table: "tasks".into(),
4615                            schema_version: 1,
4616                            columns: table.wire_columns.clone(),
4617                            blocks: vec![vec![ssp2::segment::SegmentRow {
4618                                server_version: 2,
4619                                values,
4620                            }]],
4621                        };
4622                        assert!(
4623                            matches!(client.apply_segment(0, &segment, false), Ok(1)),
4624                            "{kind}"
4625                        );
4626                    }
4627                    let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
4628                    let actual: String = client
4629                        .conn
4630                        .query_row(
4631                            "SELECT title FROM tasks WHERE id=?1",
4632                            [RowParam::Cell(&key)],
4633                            |row| row.get(0),
4634                        )
4635                        .unwrap();
4636                    assert_eq!(actual, expected, "{kind}, images={images}");
4637                }
4638                assert_eq!(client.overlay_rebuild_count.get(), 0);
4639            }
4640        }
4641    }
4642
4643    fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
4644        let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4645            "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
4646            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
4647            "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4648        })))});
4649        let mut client =
4650            SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
4651        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<8192) INSERT INTO _syncular_base_notes SELECT printf('%08d',x),'p2','note-'||x,1 FROM n").unwrap();
4652        client.rebuild_overlay();
4653        client
4654            .mutate(vec![Mutation::Upsert {
4655                table: "tasks".into(),
4656                values: Map::from_iter([
4657                    ("id".into(), json!("offline")),
4658                    ("project_id".into(), json!("p1")),
4659                    ("title".into(), json!("pending edit")),
4660                ]),
4661                base_version: None,
4662            }])
4663            .unwrap();
4664        let image = Connection::open_in_memory().unwrap();
4665        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4666            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
4667        image.execute("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<?1) INSERT INTO tasks SELECT printf('%08d',x),'p1','item-'||x,1 FROM n",[rows]).unwrap();
4668        image
4669            .execute(
4670                "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
4671                [rows],
4672            )
4673            .unwrap();
4674        client.overlay_rebuild_count.set(0);
4675        let table = client.schema.table("tasks").unwrap().clone();
4676        (client, image, table)
4677    }
4678
4679    #[test]
4680    fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
4681        let (mut client, image, table) = unique_import_fixture(1);
4682        image
4683            .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
4684            .unwrap();
4685        client
4686            .conn
4687            .execute_batch("PRAGMA max_page_count = 1")
4688            .unwrap();
4689        let effective = [("project_id".into(), vec!["p1".into()])];
4690        assert!(client
4691            .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
4692            .is_err());
4693        let failure = client.storage_failure.borrow().clone().unwrap();
4694        assert_eq!(failure.code, Some("client.storage_full"));
4695        assert_eq!(failure.sqlite_code, Some(13));
4696        assert!(!failure.message.contains("rollback"));
4697        assert!(client.conn.is_autocommit());
4698        client
4699            .conn
4700            .execute_batch("PRAGMA max_page_count = 1073741823")
4701            .unwrap();
4702        assert!(matches!(
4703            client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
4704            Ok(1)
4705        ));
4706        assert!(client.conn.is_autocommit());
4707    }
4708
4709    #[test]
4710    fn large_unique_import_copies_no_unrelated_tables() {
4711        let (mut client, image, table) = unique_import_fixture(10240);
4712        let pending = client.pending_commit_ids();
4713        client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
4714            CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
4715        assert!(matches!(
4716            client.apply_sqlite_image(
4717                &image,
4718                &table,
4719                true,
4720                &[("project_id".into(), vec!["p1".into()])],
4721                10240,
4722                7,
4723                "digest"
4724            ),
4725            Ok(10240)
4726        ));
4727        assert_eq!(client.overlay_rebuild_count.get(), 0);
4728        assert_eq!(client.pending_commit_ids(), pending);
4729        assert_eq!(
4730            client
4731                .query("SELECT count(*) AS n FROM tasks", &[])
4732                .unwrap()[0]["n"],
4733            10241
4734        );
4735        assert_eq!(
4736            client
4737                .query("SELECT count(*) AS n FROM tasks_fts", &[])
4738                .unwrap()[0]["n"],
4739            10241
4740        );
4741        assert_eq!(
4742            client
4743                .query("SELECT count(*) AS n FROM notes_fts", &[])
4744                .unwrap()[0]["n"],
4745            8192
4746        );
4747    }
4748
4749    #[test]
4750    #[ignore = "manual scaling benchmark; see bench/README.md"]
4751    fn benchmark_unique_image_import() {
4752        let mut samples = Vec::new();
4753        for rows in [10240, 51200, 102400] {
4754            for trial in 0..4 {
4755                let (mut client, image, table) = unique_import_fixture(rows);
4756                let start = std::time::Instant::now();
4757                assert!(
4758                    matches!(client.apply_sqlite_image(&image,&table,true,&[("project_id".into(),vec!["p1".into()])],rows as i64,7,"digest"),Ok(n) if n as usize == rows)
4759                );
4760                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
4761                assert_eq!(
4762                    client
4763                        .query("SELECT count(*) AS n FROM tasks", &[])
4764                        .unwrap()[0]["n"],
4765                    rows + 1
4766                );
4767                assert_eq!(
4768                    client
4769                        .query("SELECT count(*) AS n FROM tasks_fts", &[])
4770                        .unwrap()[0]["n"],
4771                    rows + 1
4772                );
4773                assert_eq!(
4774                    client
4775                        .query("SELECT count(*) AS n FROM notes_fts", &[])
4776                        .unwrap()[0]["n"],
4777                    8192
4778                );
4779                assert_eq!(client.pending_commit_ids().len(), 1);
4780                if trial > 0 {
4781                    samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
4782                }
4783            }
4784        }
4785        let artifact = json!({"boundary":"Rust core; in-memory SQLite; one pending edit; secondary unique indexes and FTS; 8192 unrelated rows; one warmup per size","optimized":!cfg!(debug_assertions),"sqliteVersion":rusqlite::version(),"os":std::env::consts::OS,"arch":std::env::consts::ARCH,"sourceHash":bytes_to_hex(&Sha256::digest(include_str!("client.rs").as_bytes())),"samples":samples});
4786        let output = serde_json::to_string_pretty(&artifact).unwrap();
4787        if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
4788            use std::io::Write;
4789            let mut file = std::fs::OpenOptions::new()
4790                .write(true)
4791                .create_new(true)
4792                .open(path)
4793                .unwrap();
4794            file.write_all(output.as_bytes()).unwrap();
4795        } else {
4796            println!("{output}");
4797        }
4798    }
4799
4800    #[test]
4801    fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
4802        let schema = json!({
4803            "version": 1,
4804            "tables": [{
4805                "name": "catalogue_codes",
4806                "primaryKey": "id",
4807                "columns": [
4808                    { "name": "id", "type": "string", "nullable": false },
4809                    { "name": "release_id", "type": "string", "nullable": false },
4810                    { "name": "code", "type": "string", "nullable": false },
4811                    { "name": "title", "type": "string", "nullable": false }
4812                ],
4813                "scopes": [{ "pattern": "release:{release_id}" }],
4814                "ftsIndexes": [{
4815                    "name": "catalogue_codes_fts",
4816                    "columns": ["code", "title"],
4817                    "tokenize": "unicode61 remove_diacritics 2"
4818                }]
4819            }]
4820        });
4821        let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
4822            .expect("FTS5 client");
4823        let insert_trigger: String = client
4824            .conn
4825            .query_row(
4826                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
4827                [],
4828                |row| row.get(0),
4829            )
4830            .expect("insert trigger");
4831        let replace_guard: String = client
4832            .conn
4833            .query_row(
4834                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
4835                [],
4836                |row| row.get(0),
4837            )
4838            .expect("replace guard");
4839        assert!(!insert_trigger.contains("DELETE FROM"));
4840        assert!(replace_guard.contains("BEFORE INSERT"));
4841        assert!(replace_guard.contains("WHEN EXISTS"));
4842        let search = |client: &SyncClient, query: &str| {
4843            client
4844                .query(
4845                    "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",
4846                    &[Value::from(query)],
4847                )
4848                .expect("FTS query")
4849        };
4850
4851        client
4852            .mutate(vec![Mutation::Upsert {
4853                table: "catalogue_codes".to_owned(),
4854                values: Map::from_iter([
4855                    ("id".to_owned(), Value::from("c1")),
4856                    ("release_id".to_owned(), Value::from("r1")),
4857                    ("code".to_owned(), Value::from("A01")),
4858                    ("title".to_owned(), Value::from("Cholera")),
4859                ]),
4860                base_version: None,
4861            }])
4862            .expect("insert code");
4863        assert_eq!(search(&client, "cholera").len(), 1);
4864        let prior = client
4865            .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4866            .unwrap();
4867        client
4868            .conn
4869            .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
4870            .unwrap();
4871        client.create_synced_tables().unwrap();
4872        assert_eq!(
4873            client
4874                .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4875                .unwrap(),
4876            prior
4877        );
4878        let plan = client.query("EXPLAIN QUERY PLAN SELECT rowid FROM catalogue_codes_fts WHERE rowid = (SELECT id FROM _syncular_fts_catalogue_codes_fts WHERE source_id = 'c1')", &[]).unwrap();
4879        let rendered = serde_json::to_string(&plan).unwrap();
4880        assert!(rendered.contains("COVERING INDEX"));
4881        assert!(rendered.contains("INDEX 0:="));
4882        client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
4883        assert_eq!(search(&client, "cholera").len(), 1);
4884
4885        client
4886            .mutate(vec![Mutation::Upsert {
4887                table: "catalogue_codes".to_owned(),
4888                values: Map::from_iter([
4889                    ("id".to_owned(), Value::from("c1")),
4890                    ("release_id".to_owned(), Value::from("r1")),
4891                    ("code".to_owned(), Value::from("A01")),
4892                    ("title".to_owned(), Value::from("Enteric infection")),
4893                ]),
4894                base_version: None,
4895            }])
4896            .expect("update code");
4897        assert!(search(&client, "cholera").is_empty());
4898        assert_eq!(search(&client, "enteric").len(), 1);
4899
4900        client
4901            .mutate(vec![Mutation::Delete {
4902                table: "catalogue_codes".to_owned(),
4903                row_id: "c1".to_owned(),
4904                base_version: None,
4905            }])
4906            .expect("delete code");
4907        assert!(search(&client, "enteric").is_empty());
4908    }
4909
4910    #[test]
4911    fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
4912        let schema = json!({
4913            "version": 1,
4914            "tables": [{
4915                "name": "patient_notes",
4916                "primaryKey": "id",
4917                "columns": [
4918                    { "name": "id", "type": "string", "nullable": false },
4919                    { "name": "practice_id", "type": "string", "nullable": false },
4920                    { "name": "encryption_key_id", "type": "string", "nullable": false },
4921                    { "name": "title", "type": "string", "nullable": false }
4922                ],
4923                "scopes": [{ "pattern": "practice:{practice_id}" }],
4924                "ftsIndexes": [{
4925                    "name": "patient_notes_fts",
4926                    "columns": ["title"],
4927                    "tokenize": "unicode61 remove_diacritics 2"
4928                }]
4929            }]
4930        });
4931        let mut client = SyncClient::new(
4932            "local-purge-test".to_owned(),
4933            &schema,
4934            ClientLimits::default(),
4935        )
4936        .expect("local purge client");
4937        client
4938            .conn
4939            .execute(
4940                "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
4941                 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
4942                 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
4943                [],
4944            )
4945            .expect("seed base rows");
4946        client.overlay_dirty.set(true);
4947        client.rebuild_overlay_if_dirty();
4948
4949        let note = |id: &str, key_id: &str, title: &str| {
4950            Map::from_iter([
4951                ("id".to_owned(), Value::from(id)),
4952                ("practice_id".to_owned(), Value::from("practice-1")),
4953                ("encryption_key_id".to_owned(), Value::from(key_id)),
4954                ("title".to_owned(), Value::from(title)),
4955            ])
4956        };
4957        let doomed = client
4958            .mutate(vec![
4959                Mutation::Upsert {
4960                    table: "patient_notes".to_owned(),
4961                    values: note("target", "key-revoked", "Target changed"),
4962                    base_version: None,
4963                },
4964                Mutation::Upsert {
4965                    table: "patient_notes".to_owned(),
4966                    values: note("unrelated", "key-held", "Unrelated changed"),
4967                    base_version: None,
4968                },
4969            ])
4970            .expect("doomed commit");
4971        let kept = client
4972            .mutate(vec![Mutation::Upsert {
4973                table: "patient_notes".to_owned(),
4974                values: note("kept", "key-held", "Kept optimistic"),
4975                base_version: None,
4976            }])
4977            .expect("kept commit");
4978        client.drain_change_batches();
4979
4980        let input = LocalDataPurgeInput {
4981            purge_id: "purge-001".to_owned(),
4982            targets: vec![LocalDataPurgeTarget {
4983                table: "patient_notes".to_owned(),
4984                selectors: BTreeMap::from([(
4985                    "encryption_key_id".to_owned(),
4986                    vec!["key-revoked".to_owned()],
4987                )]),
4988            }],
4989        };
4990        assert_eq!(
4991            client.purge_local_data(&input).expect("apply purge"),
4992            LocalDataPurgeResult {
4993                already_applied: false,
4994                purged_rows: 1,
4995                dropped_commits: 1,
4996            }
4997        );
4998        let rows = client
4999            .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
5000            .expect("visible rows");
5001        assert_eq!(rows.len(), 2);
5002        assert_eq!(rows[0]["id"], "kept");
5003        assert_eq!(rows[1]["id"], "unrelated");
5004        assert_eq!(rows[1]["title"], "Unrelated original");
5005        let fts = client
5006            .query(
5007                "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'",
5008                &[],
5009            )
5010            .expect("fts query");
5011        assert!(fts.is_empty());
5012        assert_eq!(client.outbox.len(), 1);
5013        assert_eq!(client.outbox[0].client_commit_id, kept);
5014        let outcome = client
5015            .commit_outcome(&doomed)
5016            .expect("read doomed outcome")
5017            .expect("doomed outcome");
5018        assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
5019        match &outcome.results[0] {
5020            CommitOperationOutcome::Error { rejection } => {
5021                assert_eq!(rejection.code, "client.local_data_purged");
5022                assert_eq!(rejection.client_commit_id, doomed);
5023            }
5024            other => panic!("expected local purge rejection, got {other:?}"),
5025        }
5026        assert_eq!(client.drain_change_batches().len(), 1);
5027        assert_eq!(
5028            client.purge_local_data(&input).expect("retry purge"),
5029            LocalDataPurgeResult {
5030                already_applied: true,
5031                purged_rows: 0,
5032                dropped_commits: 0,
5033            }
5034        );
5035        let conflicting = LocalDataPurgeInput {
5036            purge_id: input.purge_id.clone(),
5037            targets: vec![LocalDataPurgeTarget {
5038                table: "patient_notes".to_owned(),
5039                selectors: BTreeMap::from([(
5040                    "encryption_key_id".to_owned(),
5041                    vec!["key-held".to_owned()],
5042                )]),
5043            }],
5044        };
5045        assert!(client
5046            .purge_local_data(&conflicting)
5047            .expect_err("id collision must fail")
5048            .contains("already used with a different plan"));
5049    }
5050}
5051
5052impl SubState {
5053    fn name(self) -> &'static str {
5054        match self {
5055            SubState::Active => "active",
5056            SubState::Revoked => "revoked",
5057            SubState::Failed => "failed",
5058        }
5059    }
5060
5061    fn parse(value: &str) -> Self {
5062        match value {
5063            "revoked" => Self::Revoked,
5064            "failed" => Self::Failed,
5065            _ => Self::Active,
5066        }
5067    }
5068}
5069
5070#[derive(Debug, Clone)]
5071struct Subscription {
5072    id: String,
5073    table: String,
5074    requested: Vec<(String, Vec<String>)>,
5075    params: Option<String>,
5076    cursor: i64,
5077    /// §4.7 resume token, round-tripped opaquely.
5078    bootstrap_state: Option<String>,
5079    state: SubState,
5080    reason_code: Option<String>,
5081    /// Last effective scopes echoed while active (§3.3: persisted for the
5082    /// purge contract; each active echo replaces it).
5083    effective: Option<Vec<(String, Vec<String>)>>,
5084    synced_once: bool,
5085}
5086
5087#[derive(Debug, Clone)]
5088struct OutboxOp {
5089    upsert: bool,
5090    table: String,
5091    row_id: String,
5092    base_version: Option<i64>,
5093    /// Schema-agnostic local form (§0): driver JSON values, encoded with
5094    /// the current codec at send time. A key's presence IS the §6.1 sparse
5095    /// presence set — both the overlay and the wire payload derive from it.
5096    values: Option<Map<String, Value>>,
5097}
5098
5099impl From<&OutboxOp> for CommitOperation {
5100    fn from(operation: &OutboxOp) -> Self {
5101        Self {
5102            table: operation.table.clone(),
5103            row_id: operation.row_id.clone(),
5104            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
5105            base_version: operation.base_version,
5106            values: operation.values.clone(),
5107        }
5108    }
5109}
5110
5111#[derive(Debug, Clone)]
5112struct OutboxCommit {
5113    client_commit_id: String,
5114    ops: Vec<OutboxOp>,
5115}
5116
5117/// One commit's `(table, value keys)` per upsert operation — the classification
5118/// shape the §7.4.4 send-time drop and the RFC 0005 D6 audit share, so the two
5119/// can never disagree about which commits cannot re-encode.
5120fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
5121    commit
5122        .ops
5123        .iter()
5124        .filter(|operation| operation.upsert)
5125        .map(|operation| {
5126            (
5127                operation.table.as_str(),
5128                operation
5129                    .values
5130                    .as_ref()
5131                    .map(|values| values.keys().map(String::as_str).collect())
5132                    .unwrap_or_default(),
5133            )
5134        })
5135        .collect()
5136}
5137
5138type FailedCommit = (OutboxCommit, Map<String, Value>);
5139
5140#[derive(Debug, Clone)]
5141struct CompiledLocalDataPurgeTarget {
5142    table: String,
5143    selectors: Vec<(String, Vec<String>)>,
5144}
5145
5146struct StoredCommitOutcomeRow {
5147    sequence: i64,
5148    client_commit_id: String,
5149    status: String,
5150    recorded_at_ms: i64,
5151    results_json: String,
5152    operations_json: Option<String>,
5153    resolution: String,
5154    resolved_at_ms: Option<i64>,
5155    replacement_client_commit_id: Option<String>,
5156}
5157
5158/// Section outcome distinguishing the §5.6 subscription-local fail-closed
5159/// path from a round-aborting failure (§1.4 rule 5).
5160enum SectionError {
5161    FailClosed,
5162    Abort(String, String),
5163}
5164
5165pub(crate) struct RequestMeta {
5166    pushed_ids: Vec<String>,
5167    /// Subscription id → the request carried `cursor < 0` and no resume
5168    /// token (§5.6 first-page detection: a *fresh* bootstrap).
5169    fresh: Vec<(String, bool)>,
5170    pub(crate) accept: u8,
5171    /// §6.1 splitBatch: outbox commits held back from THIS request because
5172    /// the running operation count reached the push cap — the next round
5173    /// pushes them (`sync_needed` stays set while any remain).
5174    deferred_commits: usize,
5175}
5176
5177#[derive(Default)]
5178struct ChangeAccumulator {
5179    /// None means table-wide; Some is the exact scoped domain.
5180    tables: BTreeMap<String, Option<BTreeSet<String>>>,
5181    windows: BTreeMap<(String, String), BTreeSet<String>>,
5182    status: bool,
5183    conflicts: bool,
5184    rejections: bool,
5185    outcomes: bool,
5186}
5187
5188impl ChangeAccumulator {
5189    fn table(&mut self, table: &str) {
5190        self.tables.insert(table.to_owned(), None);
5191    }
5192
5193    fn scope(&mut self, table: &str, key: String) {
5194        match self.tables.get_mut(table) {
5195            Some(None) => {}
5196            Some(Some(keys)) => {
5197                keys.insert(key);
5198            }
5199            None => {
5200                self.tables
5201                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
5202            }
5203        }
5204    }
5205
5206    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
5207        self.windows
5208            .entry((base_key.to_owned(), table.to_owned()))
5209            .or_default()
5210            .insert(unit.to_owned());
5211    }
5212
5213    fn touched(&self) -> bool {
5214        !self.tables.is_empty()
5215            || !self.windows.is_empty()
5216            || self.status
5217            || self.conflicts
5218            || self.rejections
5219            || self.outcomes
5220    }
5221}
5222
5223/// §6.1: the server caps total operations per request (reference default
5224/// 500) and rejects the whole batch with `sync.too_many_operations`; the
5225/// client "splits and retries". Splitting happens at build time: commits are
5226/// included IN ORDER until the operation budget is spent, the rest wait for
5227/// the next round.
5228const PUSH_OPS_PER_REQUEST: usize = 500;
5229const MAX_LOCAL_PURGE_TARGETS: usize = 64;
5230const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
5231const MAX_LOCAL_PURGE_VALUES: usize = 128;
5232const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
5233pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
5234
5235/// Dirty visible tables; `None` means a whole-replica recovery/reset.
5236struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
5237
5238impl OverlayDirty {
5239    fn clean() -> Self {
5240        Self(RefCell::new(Some(BTreeSet::new())))
5241    }
5242    fn get(&self) -> bool {
5243        self.0
5244            .borrow()
5245            .as_ref()
5246            .is_none_or(|tables| !tables.is_empty())
5247    }
5248    fn set(&self, dirty: bool) {
5249        *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
5250    }
5251    fn table(&self, table: &str) {
5252        if let Some(tables) = self.0.borrow_mut().as_mut() {
5253            tables.insert(table.to_owned());
5254        }
5255    }
5256    fn snapshot(&self) -> Option<BTreeSet<String>> {
5257        self.0.borrow().clone()
5258    }
5259    fn restore(&self, tables: Option<BTreeSet<String>>) {
5260        *self.0.borrow_mut() = tables;
5261    }
5262}
5263
5264pub struct SyncClient {
5265    #[cfg(feature = "bench-internals")]
5266    benchmark_phases: Recorder,
5267    conn: Connection,
5268    schema: ClientSchema,
5269    client_id: String,
5270    limits: ClientLimits,
5271    subs: Vec<Subscription>,
5272    outbox: Vec<OutboxCommit>,
5273    failed_commits: Vec<FailedCommit>,
5274    retain_failed_commits: bool,
5275    conflicts: Vec<ConflictRecord>,
5276    rejections: Vec<RejectionRecord>,
5277    schema_floor: Option<SchemaFloor>,
5278    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
5279    lease_state: Option<LeaseState>,
5280    /// §1.6: the schema-floor response stops syncing until an upgrade.
5281    stopped: bool,
5282    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
5283    upgrading: bool,
5284    /// §8.4 coalesced sync-needed signal.
5285    sync_needed: bool,
5286    /// §8.8: how sync rounds treat the realtime binding, and its exact
5287    /// availability state with the evidence behind a lost or refused one.
5288    realtime_policy: RealtimePolicy,
5289    realtime_state: RealtimeState,
5290    realtime_reason_code: Option<String>,
5291    realtime_retry_delay_ms: Option<u64>,
5292    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
5293    presence: HashMap<String, HashMap<String, PresencePeer>>,
5294    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
5295    /// host may pin it (conformance runs on a virtual clock).
5296    now_ms: Option<i64>,
5297    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
5298    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
5299    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
5300    encryption: crate::values::EncryptionConfig,
5301    /// Fail-closed host bootstrap gate. While set, command hosts permit only
5302    /// status/lifecycle inspection and an exact authorized local purge.
5303    security_preflight: bool,
5304    /// RFC 0005 D2/D8: the previous-version capture config. `None` (default)
5305    /// means the feature is off; the descriptor write and the container orphan
5306    /// sweep still happen.
5307    previous_version: Option<PreviousVersionContextConfig>,
5308    /// Per-table primary-key upsert SQL, built once per (full table name) —
5309    /// the row write path runs per row during bootstrap (§5.6), so the SQL
5310    /// string (and, via `prepare_cached`, its compiled statement) is reused
5311    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
5312    /// schema reset (the column lists may have changed).
5313    insert_sql: RefCell<HashMap<String, String>>,
5314    /// §7.1: reconcile only tables whose base/outbox changed. Whole-replica
5315    /// reset and restart explicitly invalidate every table.
5316    overlay_dirty: OverlayDirty,
5317    // First SQLite failure of the current operation, before savepoint cleanup.
5318    storage_failure: RefCell<Option<QueryReadFailure>>,
5319    /// Test-only structural performance signal: response processing must not
5320    /// turn a batch of acknowledgements into one full overlay rebuild each.
5321    #[cfg(test)]
5322    overlay_rebuild_count: Cell<usize>,
5323    /// Test-only structural performance signal: outcome retention is enforced
5324    /// once per response rather than once per acknowledgement.
5325    #[cfg(test)]
5326    outcome_prune_count: Cell<usize>,
5327    /// Exact observer-transaction output drained by command/FFI hosts.
5328    progress: ProgressObserver,
5329    change_queue: VecDeque<ClientChangeBatch>,
5330    sync_intent_queue: VecDeque<SyncIntent>,
5331    /// Explicit exponential retry policy for transient transport failures.
5332    retry_delay_ms: u64,
5333    /// §7.6: the background retry the current round scheduled, if any.
5334    round_retry_delay_ms: Option<u64>,
5335    active_round: Option<uuid::Uuid>,
5336    last_round: Option<DiagnosticLastRound>,
5337    last_change: Option<DiagnosticLastChange>,
5338    /// §7.6: latest failed owned snapshot read per owner id, oldest first.
5339    query_failures: Vec<DiagnosticQueryFailure>,
5340}
5341
5342pub(crate) fn quote_ident(name: &str) -> String {
5343    format!("\"{}\"", name.replace('"', "\"\""))
5344}
5345/// §7.4.1 `_syncular_meta` helpers. Free functions so RFC 0005's
5346/// previous-version module reads and writes the SAME records as the client
5347/// instead of growing a parallel meta layer.
5348pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
5349    conn.query_row(
5350        "SELECT value FROM _syncular_meta WHERE key = ?1",
5351        rusqlite::params![key],
5352        |row| row.get::<_, String>(0),
5353    )
5354    .ok()
5355}
5356
5357pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
5358    let _ = conn.execute(
5359        "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
5360        rusqlite::params![key, value],
5361    );
5362}
5363
5364pub(crate) fn meta_delete(conn: &Connection, key: &str) {
5365    let _ = conn.execute(
5366        "DELETE FROM _syncular_meta WHERE key = ?1",
5367        rusqlite::params![key],
5368    );
5369}
5370
5371/// Seek by the existing key while retaining the exact legacy text match.
5372fn row_id_predicate(table: &TableSchema) -> String {
5373    let key = quote_ident(&table.primary_key);
5374    let text_match = format!("CAST({key} AS TEXT) = ?1");
5375    match table.columns[table.pk_index].ty {
5376        ColumnType::String | ColumnType::Json => {
5377            format!("{key} = ?1 AND {text_match}")
5378        }
5379        // Native columns have no declared SQLite affinity. Match the stored
5380        // integer bind type explicitly. Unary + removes expression affinity so
5381        // SQLite can seek the typeless key; the text check preserves exact IDs.
5382        ColumnType::Integer | ColumnType::Boolean => {
5383            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
5384        }
5385        // REAL-to-text conversion can round distinct values to the same text.
5386        // Narrowing those matches by numeric equality would change behavior.
5387        _ => text_match,
5388    }
5389}
5390
5391fn is_local_operation_code_like(value: &str) -> bool {
5392    let bytes = value.as_bytes();
5393    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
5394        && bytes
5395            .iter()
5396            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
5397}
5398
5399fn base_table(name: &str) -> String {
5400    quote_ident(&format!("_syncular_base_{name}"))
5401}
5402
5403/// §4.8: a stable, server-opaque key for a window base — table + variable +
5404/// canonical fixed scopes. Two `set_window` calls with the same base
5405/// address the same registry rows.
5406/// §4.8 deferred eviction record: (sub id, table, effective scope map).
5407type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
5408
5409fn window_base_key(base: &WindowBase) -> String {
5410    format!(
5411        "{}\0{}\0{}",
5412        base.table,
5413        base.variable,
5414        canonical_scope_json(&base.fixed_scopes)
5415    )
5416}
5417
5418/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
5419fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
5420    let mut scopes = base.fixed_scopes.clone();
5421    scopes.retain(|(k, _)| k != &base.variable);
5422    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
5423    scopes
5424}
5425
5426/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
5427/// echoed not interpreted by the server, so the exact hash is client
5428/// convention; SHA-256 matches the SPEC's worked example.
5429fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
5430    let canonical = canonical_scope_json(&unit_scopes(base, unit));
5431    let digest = Sha256::digest(canonical.as_bytes());
5432    let hex = bytes_to_hex(&digest);
5433    format!("w:{}:{}", base.table, &hex[..16])
5434}
5435
5436fn visible_table(name: &str) -> String {
5437    quote_ident(name)
5438}
5439
5440const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
5441
5442/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
5443/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
5444fn is_synced_table_name(name: &str) -> bool {
5445    if name.starts_with("sqlite_") {
5446        return false;
5447    }
5448    if name.starts_with("_syncular_base_") {
5449        return true; // the base half of a synced table pair
5450    }
5451    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
5452    !name.starts_with("_syncular_")
5453}
5454
5455/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
5456fn blob_id_for(bytes: &[u8]) -> String {
5457    let digest = Sha256::digest(bytes);
5458    format!("sha256:{}", bytes_to_hex(&digest))
5459}
5460
5461/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
5462/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
5463/// per row on the §5.6 bootstrap path), scalars bind owned.
5464enum RowParam<'a> {
5465    Cell(&'a Option<ColumnValue>),
5466    Version(i64),
5467}
5468
5469impl rusqlite::ToSql for RowParam<'_> {
5470    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
5471        Ok(match self {
5472            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
5473            RowParam::Cell(cell) => match cell {
5474                None => ToSqlOutput::Owned(SqlValue::Null),
5475                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
5476                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
5477                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
5478                Some(ColumnValue::Boolean(b)) => {
5479                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
5480                }
5481                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
5482                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
5483                }
5484                // §5.10: crdt bytes store as BLOB, like bytes.
5485                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
5486                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
5487                }
5488            },
5489        })
5490    }
5491}
5492
5493// Retain a typed primary key across the current import chunk without copying row payloads.
5494fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
5495    match param.to_sql().map_err(|error| error.to_string())? {
5496        ToSqlOutput::Owned(value) => Ok(value),
5497        ToSqlOutput::Borrowed(value) => {
5498            SqlValue::try_from(value).map_err(|error| error.to_string())
5499        }
5500        _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
5501    }
5502}
5503
5504/// §5.3 image cell → bind parameter, strict per the declared column type
5505/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
5506/// nullable). Mismatches are image-producer violations. Returns the cell
5507/// borrowed when the stored representation already matches what the row
5508/// codec would write, or the normalized scalar (boolean → 0/1, float from
5509/// INTEGER → REAL) otherwise — the local write is byte-identical to the
5510/// old convert-then-insert path without allocating per cell.
5511fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
5512    use ssp2::segment::ColumnType;
5513    let mismatch = || {
5514        Err(format!(
5515            "image column {:?} holds a value of the wrong type",
5516            column.name
5517        ))
5518    };
5519    match value {
5520        ValueRef::Null => {
5521            if !column.nullable {
5522                return Err(format!(
5523                    "image column {:?} is NULL but not nullable",
5524                    column.name
5525                ));
5526            }
5527            Ok(ToSqlOutput::Owned(SqlValue::Null))
5528        }
5529        ValueRef::Integer(i) => match column.ty {
5530            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
5531            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
5532            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
5533            _ => mismatch(),
5534        },
5535        ValueRef::Real(_) => match column.ty {
5536            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
5537            _ => mismatch(),
5538        },
5539        ValueRef::Text(t) => {
5540            std::str::from_utf8(t)
5541                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
5542            match column.ty {
5543                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
5544                    Ok(ToSqlOutput::Borrowed(value))
5545                }
5546                _ => mismatch(),
5547            }
5548        }
5549        ValueRef::Blob(_) => match column.ty {
5550            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
5551            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
5552            _ => mismatch(),
5553        },
5554    }
5555}
5556
5557pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
5558    use rusqlite::types::ValueRef;
5559    match value {
5560        ValueRef::Null => Value::Null,
5561        ValueRef::Integer(i) => match column.ty {
5562            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
5563            ssp2::segment::ColumnType::Float => {
5564                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
5565            }
5566            _ => Value::from(i),
5567        },
5568        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5569        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5570        ValueRef::Blob(b) => {
5571            let mut map = Map::new();
5572            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5573            Value::Object(map)
5574        }
5575    }
5576}
5577
5578/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
5579/// Objects are accepted in lossless `{"$bytes": hex}` and
5580/// `{"$bigint": decimal}` envelope forms.
5581fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
5582    Ok(match value {
5583        Value::Null => SqlValue::Null,
5584        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
5585        Value::Number(n) => {
5586            if let Some(i) = n.as_i64() {
5587                SqlValue::Integer(i)
5588            } else if let Some(f) = n.as_f64() {
5589                SqlValue::Real(f)
5590            } else {
5591                return Err(format!("query param number {n} is out of range"));
5592            }
5593        }
5594        Value::String(s) => SqlValue::Text(s.clone()),
5595        Value::Object(_) => {
5596            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
5597                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
5598            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
5599                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
5600                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
5601                })?)
5602            } else {
5603                return Err(
5604                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
5605                        .to_owned(),
5606                );
5607            }
5608        }
5609        Value::Array(_) => return Err("query array params are not supported".to_owned()),
5610    })
5611}
5612
5613/// Map a rusqlite value with no schema column to consult (arbitrary query
5614/// output): integers/reals/text pass through by stored affinity, blobs ride
5615/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
5616/// schema column type to recover booleans/floats/json.
5617fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
5618    use rusqlite::types::ValueRef;
5619    match value {
5620        ValueRef::Null => Value::Null,
5621        ValueRef::Integer(i) => {
5622            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
5623            // ordinary UI-sized integers ergonomic and envelope only values
5624            // beyond JavaScript's safe range.
5625            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
5626            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
5627                Value::from(i)
5628            } else {
5629                let mut map = Map::new();
5630                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
5631                Value::Object(map)
5632            }
5633        }
5634        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5635        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5636        ValueRef::Blob(b) => {
5637            let mut map = Map::new();
5638            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5639            Value::Object(map)
5640        }
5641    }
5642}
5643
5644/// §7.5: SQLite failures classify by result code into [`QueryReadFailure`].
5645fn query_connection(
5646    conn: &Connection,
5647    sql: &str,
5648    params: &[QueryValue],
5649) -> Result<Vec<QueryRow>, QueryReadFailure> {
5650    crate::query_guard::assert_read_only_query(sql)?;
5651    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
5652    let bound: Vec<SqlValue> = params
5653        .iter()
5654        .map(json_param_to_sql)
5655        .collect::<Result<_, _>>()?;
5656    let mut stmt = conn.prepare(&lowered_sql)?;
5657    let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
5658    let bound_refs: Vec<&dyn rusqlite::ToSql> =
5659        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
5660    let mut sql_rows = stmt.query(bound_refs.as_slice())?;
5661    let mut out = Vec::new();
5662    while let Some(row) = sql_rows.next()? {
5663        let mut record = Map::new();
5664        for (i, name) in column_names.iter().enumerate() {
5665            let value = row.get_ref(i)?;
5666            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
5667        }
5668        out.push(record);
5669    }
5670    Ok(out)
5671}
5672
5673fn persisted_window_state(
5674    conn: &Connection,
5675    base: &WindowBase,
5676) -> Result<WindowState, QueryReadFailure> {
5677    let mut stmt = conn.prepare(
5678        "SELECT windows.unit, subscriptions.state_json
5679           FROM _syncular_windows AS windows
5680           JOIN _syncular_subscriptions AS subscriptions
5681             ON subscriptions.id = windows.sub_id
5682          WHERE windows.base = ?1
5683          ORDER BY windows.unit ASC",
5684    )?;
5685    let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
5686        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5687    })?;
5688    let mut units = Vec::new();
5689    let mut pending = Vec::new();
5690    for row in rows {
5691        let (unit, raw) = row?;
5692        let state: Value = serde_json::from_str(&raw)
5693            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
5694        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
5695            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
5696            || state
5697                .get("bootstrapState")
5698                .is_some_and(|value| !value.is_null());
5699        if is_pending {
5700            pending.push(unit.clone());
5701        }
5702        units.push(unit);
5703    }
5704    Ok(WindowState { units, pending })
5705}
5706
5707fn snapshot_connection(
5708    conn: &Connection,
5709    sql: &str,
5710    params: &[Value],
5711    coverage: &[WindowCoverage],
5712) -> Result<QuerySnapshot, QueryReadFailure> {
5713    conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
5714    let result: Result<QuerySnapshot, QueryReadFailure> = (|| {
5715        let revision = conn
5716            .query_row(
5717                "SELECT value FROM _syncular_meta WHERE key = ?1",
5718                rusqlite::params![LOCAL_REVISION_KEY],
5719                |row| row.get::<_, String>(0),
5720            )
5721            .ok()
5722            .and_then(|value| value.parse::<u64>().ok())
5723            .unwrap_or(0);
5724        let rows = query_connection(conn, sql, params)?;
5725        let mut pending = Vec::new();
5726        let mut missing = Vec::new();
5727        for requested in coverage {
5728            let base_key = window_base_key(&requested.base);
5729            let state = persisted_window_state(conn, &requested.base)?;
5730            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
5731                let reference = WindowUnitRef {
5732                    base_key: base_key.clone(),
5733                    unit: unit.clone(),
5734                };
5735                if !state.units.iter().any(|held| held == &unit) {
5736                    missing.push(reference);
5737                } else if state.pending.iter().any(|held| held == &unit) {
5738                    pending.push(reference);
5739                }
5740            }
5741        }
5742        Ok(QuerySnapshot {
5743            revision: revision.to_string(),
5744            rows,
5745            coverage: CoverageSnapshot {
5746                complete: pending.is_empty() && missing.is_empty(),
5747                pending,
5748                missing,
5749            },
5750        })
5751    })();
5752    match result {
5753        Ok(snapshot) => {
5754            conn.execute_batch("RELEASE syncular_snapshot_read")?;
5755            Ok(snapshot)
5756        }
5757        Err(mut error) => {
5758            if let Err(rollback) = conn
5759                .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
5760            {
5761                error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
5762            }
5763            Err(error)
5764        }
5765    }
5766}
5767
5768/// A long-lived read-only SQLite sidecar for latency-critical native views.
5769/// Network rounds stay serialized on the mutable core owner, while atomic
5770/// query snapshots use this independent connection and therefore never queue
5771/// behind HTTP/WebSocket latency.
5772pub struct FileQuerySnapshotReader {
5773    path: String,
5774    conn: Option<Connection>,
5775}
5776
5777impl FileQuerySnapshotReader {
5778    #[must_use]
5779    pub fn new(path: impl Into<String>) -> Self {
5780        Self {
5781            path: path.into(),
5782            conn: None,
5783        }
5784    }
5785
5786    fn connection(&mut self) -> Result<&Connection, String> {
5787        if self.conn.is_none() {
5788            let conn = Connection::open_with_flags(
5789                &self.path,
5790                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
5791            )
5792            .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
5793            conn.busy_timeout(std::time::Duration::from_millis(250))
5794                .map_err(|error| error.to_string())?;
5795            self.conn = Some(conn);
5796        }
5797        self.conn
5798            .as_ref()
5799            .ok_or_else(|| "read sidecar connection missing".to_owned())
5800    }
5801
5802    /// Hosts report an owned failure, and the first success after it, to the
5803    /// owning core through [`SyncClient::record_query_read`] (§7.6).
5804    pub fn query_snapshot(
5805        &mut self,
5806        sql: &str,
5807        params: &[Value],
5808        coverage: &[WindowCoverage],
5809    ) -> Result<QuerySnapshot, QueryReadFailure> {
5810        snapshot_connection(self.connection()?, sql, params, coverage)
5811    }
5812}
5813
5814impl SyncClient {
5815    pub fn new_with_identity(
5816        client_id: Option<String>,
5817        schema_json: &Value,
5818        limits: ClientLimits,
5819    ) -> Result<Self, String> {
5820        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5821        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5822        Self::with_connection(resolved, schema_json, limits, conn)
5823    }
5824
5825    pub fn new(
5826        client_id: String,
5827        schema_json: &Value,
5828        limits: ClientLimits,
5829    ) -> Result<Self, String> {
5830        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5831        Self::with_connection(client_id, schema_json, limits, conn)
5832    }
5833
5834    /// Build a client backed by an on-disk SQLite database at `path` — the
5835    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
5836    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
5837    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
5838    /// of the command router's dependency set (the router only holds a path).
5839    pub fn open_path(
5840        client_id: String,
5841        schema_json: &Value,
5842        limits: ClientLimits,
5843        path: &str,
5844    ) -> Result<Self, String> {
5845        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5846        Self::with_connection(client_id, schema_json, limits, conn)
5847    }
5848
5849    pub fn open_path_with_identity(
5850        client_id: Option<String>,
5851        schema_json: &Value,
5852        limits: ClientLimits,
5853        path: &str,
5854    ) -> Result<Self, String> {
5855        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5856        // File-backed native clients use an independent read connection for
5857        // latency-critical snapshots. WAL is SQLite's intended reader/writer
5858        // concurrency mode: a view read never holds a rollback-journal lock
5859        // that delays the mutable client's next commit, and a short busy
5860        // timeout absorbs the tiny checkpoint/schema-lock windows.
5861        conn.busy_timeout(std::time::Duration::from_millis(250))
5862            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
5863        conn.pragma_update(None, "journal_mode", "WAL")
5864            .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
5865        let persisted = conn
5866            .query_row(
5867                "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
5868                [],
5869                |row| row.get::<_, String>(0),
5870            )
5871            .ok();
5872        let resolved = persisted
5873            .clone()
5874            .or(client_id.clone())
5875            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5876        if let (Some(existing), Some(requested)) = (persisted, client_id) {
5877            if existing != requested {
5878                return Err(format!(
5879                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
5880                ));
5881            }
5882        }
5883        Self::with_connection(resolved, schema_json, limits, conn)
5884    }
5885
5886    #[must_use]
5887    pub fn client_id(&self) -> &str {
5888        &self.client_id
5889    }
5890
5891    /// Build a client over a caller-supplied rusqlite connection — the seam a
5892    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
5893    /// with an on-disk database (`Connection::open(path)`) rather than the
5894    /// default `:memory:`. The connection MUST be fresh (no pre-existing
5895    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
5896    /// the same file across process restarts reuses the persisted rows.
5897    pub fn with_connection(
5898        client_id: String,
5899        schema_json: &Value,
5900        limits: ClientLimits,
5901        conn: Connection,
5902    ) -> Result<Self, String> {
5903        if limits.outcome_retention_max_entries == Some(0) {
5904            return Err(
5905                "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
5906            );
5907        }
5908        let schema = parse_schema_json(schema_json)?;
5909        // RFC 0005 D8: resolve the previous-version config before the opening
5910        // reset runs — the capture happens inside it. Bad bounds fail loud.
5911        let previous_version = match limits.previous_version_context {
5912            Some(config) => {
5913                config.validate()?;
5914                Some(config)
5915            }
5916            None => None,
5917        };
5918        let mut client = SyncClient {
5919            #[cfg(feature = "bench-internals")]
5920            benchmark_phases: Recorder::default(),
5921            conn,
5922            schema,
5923            client_id,
5924            limits,
5925            subs: Vec::new(),
5926            outbox: Vec::new(),
5927            failed_commits: Vec::new(),
5928            retain_failed_commits: false,
5929            conflicts: Vec::new(),
5930            rejections: Vec::new(),
5931            schema_floor: None,
5932            lease_state: None,
5933            stopped: false,
5934            upgrading: false,
5935            sync_needed: false,
5936            realtime_policy: RealtimePolicy::Optional,
5937            realtime_state: RealtimeState::Disconnected,
5938            realtime_reason_code: None,
5939            realtime_retry_delay_ms: None,
5940            presence: HashMap::new(),
5941            now_ms: None,
5942            encryption: crate::values::EncryptionConfig::default(),
5943            security_preflight: false,
5944            previous_version,
5945            insert_sql: RefCell::new(HashMap::new()),
5946            overlay_dirty: OverlayDirty::clean(),
5947            storage_failure: RefCell::new(None),
5948            #[cfg(test)]
5949            overlay_rebuild_count: Cell::new(0),
5950            #[cfg(test)]
5951            outcome_prune_count: Cell::new(0),
5952            progress: ProgressObserver::default(),
5953            change_queue: VecDeque::new(),
5954            sync_intent_queue: VecDeque::new(),
5955            retry_delay_ms: 250,
5956            round_retry_delay_ms: None,
5957            active_round: None,
5958            last_round: None,
5959            last_change: None,
5960            query_failures: Vec::new(),
5961        };
5962        // The row write path leans on the prepared-statement cache (two
5963        // insert statements per synced table, plus the bookkeeping
5964        // statements); size it so a multi-table schema never thrashes.
5965        client
5966            .conn
5967            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
5968        // Protected bookkeeping must exist before inspecting the persisted
5969        // schema marker. New app indexes may reference columns that only
5970        // exist after a version-bump reset.
5971        client.create_bookkeeping_tables()?;
5972        match client.get_meta(CLIENT_ID_KEY) {
5973            Some(existing) if existing != client.client_id => {
5974                return Err(format!(
5975                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
5976                    client.client_id
5977                ));
5978            }
5979            None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
5980            _ => {}
5981        }
5982        client.restore_persisted_state()?;
5983        client.failed_commits = client.load_failed_commits()?;
5984        let marker = client
5985            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
5986            .and_then(|value| value.parse::<i32>().ok());
5987        match marker {
5988            None => {
5989                client.create_synced_tables()?;
5990                client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
5991                // D1: the descriptor is written beside every marker write.
5992                set_local_schema_descriptor(&client.conn, &client.schema);
5993                client.save_subscription_scope_schema();
5994            }
5995            Some(version) if version == client.schema.version => {
5996                client.create_synced_tables()?;
5997                // RFC 0005 D1: the same-version open backfills the descriptor
5998                // for a database first opened by an unaware binary, so the
5999                // NEXT bump can capture without needing a schema bump first.
6000                set_local_schema_descriptor(&client.conn, &client.schema);
6001                client.save_subscription_scope_schema();
6002            }
6003            Some(_) => client.run_schema_reset()?,
6004        }
6005        // RFC 0005 D9: the reset sweep cannot see a container whose capture
6006        // committed in its OWN file while the replica's savepoint never
6007        // released — a crash at that seam leaves a container beside a matching
6008        // OLD marker, and the next open at that version runs no reset. Discard
6009        // it before anything can read it. No-op when no container exists.
6010        client.reconcile_previous_version_at_boot()?;
6011        client.clear_satisfied_persisted_schema_floor();
6012        client.prune_unknown_subscriptions(false)?;
6013        if marker == Some(client.schema.version)
6014            && (!client.outbox.is_empty() || !client.failed_commits.is_empty())
6015        {
6016            // Reconstruct the visible optimistic overlay from the durable base
6017            // plus outbox instead of trusting a process-interrupted mirror.
6018            client.overlay_dirty.set(true);
6019            client.rebuild_overlay();
6020        }
6021        // Every persisted active subscription needs one catch-up pull on open:
6022        // realtime only covers changes after connection, while an idempotent
6023        // setWindow correctly creates no fresh command effect. Pending outbox
6024        // work has the same restart requirement. The core owns this intent so
6025        // native hosts never poll or require an application-issued sync().
6026        client.enqueue_startup_sync_if_needed()?;
6027        Ok(client)
6028    }
6029
6030    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
6031    /// against this instead of system time (conformance virtual clock).
6032    pub fn set_now_ms(&mut self, now_ms: i64) {
6033        self.now_ms = Some(now_ms);
6034    }
6035
6036    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
6037    /// The command router parses these from the `create` command's
6038    /// `encryption` config (keys as `{$bytes: hex}`).
6039    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
6040        self.encryption = encryption;
6041    }
6042
6043    #[must_use]
6044    pub fn security_lifecycle(&self) -> &'static str {
6045        if self.security_preflight {
6046            "preflight"
6047        } else {
6048            "active"
6049        }
6050    }
6051
6052    #[must_use]
6053    pub fn security_preflight(&self) -> bool {
6054        self.security_preflight
6055    }
6056
6057    /// Quarantine this replica: enter the fail-closed gate, release all
6058    /// core-owned key material, and record the gate in the database so
6059    /// reopening re-enters preflight until `activate_security` clears it.
6060    ///
6061    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
6062    /// which must NOT leave that durable mark.
6063    pub fn begin_security_preflight(&mut self) {
6064        self.seal_security_on_teardown();
6065        // Persist the quarantine so it survives handle teardown and restart:
6066        // reopening this replica re-enters preflight until activation clears it.
6067        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
6068    }
6069
6070    /// Release core-owned key material as a host tears the client down,
6071    /// WITHOUT recording a quarantine.
6072    ///
6073    /// Shutting an activated client down is not a quarantine event. Persisting
6074    /// the gate here marks every cleanly closed replica as pending, and the
6075    /// next plain `create` is then refused permanently: the reopen path
6076    /// restores the flag from the marker and the create guard rejects it. The
6077    /// in-memory flag still closes the gate for anything still holding this
6078    /// instance, which is all a teardown barrier needs.
6079    pub fn seal_security_on_teardown(&mut self) {
6080        self.cancel_sync_round();
6081        self.security_preflight = true;
6082        self.encryption = crate::values::EncryptionConfig::default();
6083        self.sync_intent_queue.clear();
6084    }
6085
6086    /// Install the post-authentication keyring and release the host loop.
6087    pub fn activate_security(
6088        &mut self,
6089        encryption: crate::values::EncryptionConfig,
6090    ) -> Result<(), String> {
6091        if !self.security_preflight {
6092            return Err(
6093                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
6094            );
6095        }
6096        self.enqueue_startup_sync_if_needed()?;
6097        self.encryption = encryption;
6098        self.security_preflight = false;
6099        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
6100        Ok(())
6101    }
6102
6103    fn clock_now_ms(&self) -> i64 {
6104        self.now_ms.unwrap_or_else(|| {
6105            std::time::SystemTime::now()
6106                .duration_since(std::time::UNIX_EPOCH)
6107                .map(|d| d.as_millis() as i64)
6108                .unwrap_or(0)
6109        })
6110    }
6111
6112    fn create_bookkeeping_tables(&self) -> Result<(), String> {
6113        // Durable client bookkeeping (outbox + subscription + meta).
6114        self.conn
6115            .execute_batch(
6116                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
6117                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
6118                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
6119                 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
6120                   seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
6121                   operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
6122                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
6123                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
6124                   client_commit_id TEXT NOT NULL UNIQUE,
6125                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
6126                   recorded_at_ms INTEGER NOT NULL,
6127                   results_json TEXT NOT NULL,
6128                   operations_json TEXT,
6129                   resolution TEXT NOT NULL DEFAULT 'active'
6130                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
6131                   resolved_at_ms INTEGER,
6132                   replacement_client_commit_id TEXT);
6133                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
6134                   ON _syncular_commit_outcomes(resolution, seq);
6135                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
6136                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
6137                 CREATE TABLE IF NOT EXISTS _syncular_meta (
6138                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
6139                 CREATE TABLE IF NOT EXISTS _syncular_windows (
6140                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
6141                   PRIMARY KEY (base, unit));
6142                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
6143                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
6144                   effective_scopes TEXT NOT NULL);
6145                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
6146                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
6147                   PRIMARY KEY(commit_id, blob_id));
6148                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
6149                   ON _syncular_blob_commit_refs(blob_id);",
6150            )
6151            .map_err(|e| e.to_string())?;
6152        // Migrate an outcome journal created before failed aggregate
6153        // envelopes were retained. Historical rows intentionally stay NULL.
6154        let _ = self
6155            .conn
6156            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
6157        if self.get_meta(LOCAL_REVISION_KEY).is_none() {
6158            self.set_meta(LOCAL_REVISION_KEY, "0");
6159        }
6160        // §5.9.7 blob cache. Mutable upload state stays off the body row so
6161        // completing a large upload does not rewrite its BLOB record.
6162        if self.schema_has_blobs() {
6163            let exists = self
6164                .conn
6165                .query_row(
6166                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
6167                    [],
6168                    |row| row.get::<_, bool>(0),
6169                )
6170                .map_err(|e| e.to_string())?;
6171            if exists {
6172                let mut statement = self
6173                    .conn
6174                    .prepare("PRAGMA table_info(_syncular_blobs)")
6175                    .map_err(|e| e.to_string())?;
6176                let mut columns = statement
6177                    .query_map([], |row| row.get::<_, String>(1))
6178                    .map_err(|e| e.to_string())?
6179                    .collect::<Result<Vec<_>, _>>()
6180                    .map_err(|e| e.to_string())?;
6181                columns.sort();
6182                let mut expected = vec![
6183                    "blob_id",
6184                    "byte_length",
6185                    "bytes",
6186                    "created_at_ms",
6187                    "media_type",
6188                ];
6189                expected.sort_unstable();
6190                if columns != expected {
6191                    return Err(
6192                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
6193                    );
6194                }
6195            } else {
6196                self.conn
6197                    .execute_batch(
6198                        "CREATE TABLE _syncular_blobs(
6199                           blob_id TEXT PRIMARY KEY,
6200                           bytes BLOB NOT NULL,
6201                           byte_length INTEGER NOT NULL,
6202                           media_type TEXT,
6203                           created_at_ms INTEGER NOT NULL);",
6204                    )
6205                    .map_err(|e| e.to_string())?;
6206            }
6207            self.conn
6208                .execute_batch(
6209                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
6210                       blob_id TEXT PRIMARY KEY,
6211                       media_type TEXT,
6212                       created_at_ms INTEGER NOT NULL);",
6213                )
6214                .map_err(|e| e.to_string())?;
6215        }
6216        Ok(())
6217    }
6218
6219    /// True iff any synced table declares a `blob_ref` column (§5.9).
6220    fn schema_has_blobs(&self) -> bool {
6221        self.schema
6222            .tables
6223            .iter()
6224            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
6225    }
6226
6227    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
6228
6229    fn get_meta(&self, key: &str) -> Option<String> {
6230        meta_get(&self.conn, key)
6231    }
6232
6233    fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
6234        self.conn
6235            .query_row(
6236                "SELECT value FROM _syncular_meta WHERE key = ?1",
6237                rusqlite::params![key],
6238                |row| row.get::<_, String>(0),
6239            )
6240            .optional()
6241            .map_err(|_| {
6242                "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
6243            })
6244    }
6245
6246    fn set_meta(&self, key: &str, value: &str) {
6247        meta_set(&self.conn, key, value);
6248    }
6249
6250    fn delete_meta(&self, key: &str) {
6251        meta_delete(&self.conn, key);
6252    }
6253
6254    fn restore_persisted_state(&mut self) -> Result<(), String> {
6255        self.subs = {
6256            let mut stmt = self
6257                .conn
6258                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
6259                .map_err(|error| error.to_string())?;
6260            let rows = stmt
6261                .query_map([], |row| {
6262                    Ok((
6263                        row.get::<_, String>(0)?,
6264                        row.get::<_, String>(1)?,
6265                        row.get::<_, String>(2)?,
6266                    ))
6267                })
6268                .map_err(|error| error.to_string())?;
6269            let mut subscriptions = Vec::new();
6270            for row in rows {
6271                let (id, table, raw) = row.map_err(|error| error.to_string())?;
6272                let state: Value = serde_json::from_str(&raw)
6273                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
6274                let requested = json_to_scope_map(
6275                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
6276                )?;
6277                let effective = state
6278                    .get("effectiveScopes")
6279                    .filter(|value| !value.is_null())
6280                    .map(json_to_scope_map)
6281                    .transpose()?;
6282                subscriptions.push(Subscription {
6283                    id,
6284                    table,
6285                    requested,
6286                    params: state
6287                        .get("params")
6288                        .and_then(Value::as_str)
6289                        .map(str::to_owned),
6290                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
6291                    bootstrap_state: state
6292                        .get("bootstrapState")
6293                        .and_then(Value::as_str)
6294                        .map(str::to_owned),
6295                    state: SubState::parse(
6296                        state
6297                            .get("status")
6298                            .and_then(Value::as_str)
6299                            .unwrap_or("active"),
6300                    ),
6301                    reason_code: state
6302                        .get("reasonCode")
6303                        .and_then(Value::as_str)
6304                        .map(str::to_owned),
6305                    effective,
6306                    synced_once: state
6307                        .get("syncedOnce")
6308                        .and_then(Value::as_bool)
6309                        .unwrap_or(false),
6310                });
6311            }
6312            subscriptions
6313        };
6314
6315        self.outbox = {
6316            let mut stmt = self
6317                .conn
6318                .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
6319                .map_err(|error| error.to_string())?;
6320            let rows = stmt
6321                .query_map([], |row| {
6322                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6323                })
6324                .map_err(|error| error.to_string())?;
6325            let mut commits = Vec::new();
6326            for row in rows {
6327                let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
6328                let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
6329                    format!("invalid persisted outbox {client_commit_id:?}: {error}")
6330                })?;
6331                let mut ops = Vec::with_capacity(entries.len());
6332                for entry in entries {
6333                    let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
6334                    ops.push(OutboxOp {
6335                        upsert: op == "upsert",
6336                        table: entry
6337                            .get("table")
6338                            .and_then(Value::as_str)
6339                            .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
6340                            .to_owned(),
6341                        row_id: entry
6342                            .get("rowId")
6343                            .and_then(Value::as_str)
6344                            .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
6345                            .to_owned(),
6346                        base_version: entry.get("baseVersion").and_then(Value::as_i64),
6347                        // A legacy entry's `changedFields` key is ignored:
6348                        // the values map is the presence set (§6.1).
6349                        values: entry.get("values").and_then(Value::as_object).cloned(),
6350                    });
6351                }
6352                commits.push(OutboxCommit {
6353                    client_commit_id,
6354                    ops,
6355                });
6356            }
6357            commits
6358        };
6359
6360        self.prune_commit_outcomes()?;
6361        let active = self.commit_outcomes(CommitOutcomeQuery {
6362            active_only: true,
6363            ..CommitOutcomeQuery::default()
6364        })?;
6365        self.conflicts = active
6366            .iter()
6367            .flat_map(|outcome| outcome.results.iter())
6368            .filter_map(|result| match result {
6369                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
6370                _ => None,
6371            })
6372            .collect();
6373        self.rejections = active
6374            .iter()
6375            .flat_map(|outcome| outcome.results.iter())
6376            .filter_map(|result| match result {
6377                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
6378                _ => None,
6379            })
6380            .collect();
6381
6382        self.lease_state = self
6383            .get_meta(LEASE_STATE_KEY)
6384            .map(|raw| serde_json::from_str(&raw))
6385            .transpose()
6386            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
6387        self.schema_floor = self
6388            .get_meta(SCHEMA_FLOOR_KEY)
6389            .map(|raw| serde_json::from_str(&raw))
6390            .transpose()
6391            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
6392        self.stopped = self.schema_floor.is_some();
6393        // A replica left in unactivated preflight reopens gated: the quarantine
6394        // decision persists with the data across handle teardown and restart.
6395        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
6396            self.security_preflight = true;
6397        }
6398        Ok(())
6399    }
6400
6401    fn save_subscription_scope_schema(&self) {
6402        let declarations: BTreeMap<_, BTreeMap<_, _>> = self
6403            .schema
6404            .tables
6405            .iter()
6406            .map(|table| {
6407                (
6408                    table.name.clone(),
6409                    table
6410                        .scope_variables
6411                        .iter()
6412                        .map(|scope| {
6413                            (
6414                                scope.variable.clone(),
6415                                [scope.prefix.clone(), scope.column.clone()],
6416                            )
6417                        })
6418                        .collect(),
6419                )
6420            })
6421            .collect();
6422        self.set_meta(
6423            "subscriptionScopeSchema",
6424            &serde_json::to_string(&declarations).expect("scope schema serialization"),
6425        );
6426    }
6427
6428    /// Remove registrations with incompatible table/key/prefix/column meaning.
6429    fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
6430        let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
6431            self.get_meta("subscriptionScopeSchema")
6432                .map(|raw| serde_json::from_str(&raw))
6433                .transpose()
6434                .map_err(|error| format!("invalid persisted scope schema: {error}"))?
6435        } else {
6436            None
6437        };
6438        let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
6439            let Some(table) = self.schema.table(table_name) else {
6440                return false;
6441            };
6442            if requested
6443                .iter()
6444                .any(|(variable, _)| table.scope_column(variable).is_none())
6445            {
6446                return false;
6447            }
6448            if !bump {
6449                return true;
6450            }
6451            let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
6452                return false;
6453            };
6454            let variables: BTreeSet<_> = if requested.is_empty() {
6455                old.keys()
6456                    .cloned()
6457                    .chain(
6458                        table
6459                            .scope_variables
6460                            .iter()
6461                            .map(|scope| scope.variable.clone()),
6462                    )
6463                    .collect()
6464            } else {
6465                requested
6466                    .iter()
6467                    .map(|(variable, _)| variable.clone())
6468                    .collect()
6469            };
6470            variables.iter().all(|variable| {
6471                let current = table
6472                    .scope_variables
6473                    .iter()
6474                    .find(|scope| &scope.variable == variable);
6475                match (old.get(variable), current) {
6476                    (Some(old), Some(current)) => {
6477                        old == &[current.prefix.clone(), current.column.clone()]
6478                    }
6479                    _ => false,
6480                }
6481            })
6482        };
6483        let mut stale_ids: BTreeSet<_> = self
6484            .subs
6485            .iter()
6486            .filter(|sub| !compatible(&sub.table, &sub.requested))
6487            .map(|sub| sub.id.clone())
6488            .collect();
6489        for (id, table, effective) in self.load_pending_evictions()? {
6490            if !compatible(&table, &effective) {
6491                stale_ids.insert(id);
6492            }
6493        }
6494        for id in &stale_ids {
6495            for sql in [
6496                "DELETE FROM _syncular_windows WHERE sub_id = ?1",
6497                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
6498                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
6499            ] {
6500                self.conn
6501                    .execute(sql, rusqlite::params![id])
6502                    .map_err(|error| error.to_string())?;
6503            }
6504        }
6505        self.subs.retain(|sub| !stale_ids.contains(&sub.id));
6506        Ok(())
6507    }
6508
6509    #[must_use]
6510    pub fn local_revision(&self) -> u64 {
6511        self.get_meta(LOCAL_REVISION_KEY)
6512            .and_then(|value| value.parse().ok())
6513            .unwrap_or(0)
6514    }
6515
6516    #[must_use]
6517    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
6518        SyncStatusSnapshot {
6519            current_schema_version: self.schema.version,
6520            outbox: self.outbox.len(),
6521            upgrading: self.upgrading,
6522            lease_state: self.lease_state.clone(),
6523            schema_floor: self.schema_floor.clone(),
6524            sync_needed: self.sync_needed,
6525            previous_version_context: self.previous_version_status(),
6526        }
6527    }
6528
6529    /// RFC 0005 A2: container presence for a host that must act on it (Diego's
6530    /// rollback precondition), read without creating the file.
6531    fn previous_version_status(&self) -> PreviousVersionStatus {
6532        let Some(replica_path) = self.previous_version_replica_path() else {
6533            return PreviousVersionStatus {
6534                present: false,
6535                created_at_ms: None,
6536            };
6537        };
6538        match crate::previous_version::read_previous_version_record(&replica_path) {
6539            Ok(Some(record)) => PreviousVersionStatus {
6540                present: true,
6541                created_at_ms: Some(record.created_at_ms),
6542            },
6543            _ => PreviousVersionStatus {
6544                present: std::path::Path::new(
6545                    &crate::previous_version::previous_version_container_path(&replica_path),
6546                )
6547                .exists(),
6548                created_at_ms: None,
6549            },
6550        }
6551    }
6552
6553    /// RFC 0005 D7: read captured rows from the previous local schema. `state`
6554    /// is always `previousVersion`; the container never makes
6555    /// `query_snapshot()` coverage complete. Lease stop/expiry, scope
6556    /// revocation, coverage completion and the TTL DISCARD the container and
6557    /// close the read with their own reason.
6558    pub fn previous_version_snapshot(
6559        &self,
6560        spec: &PreviousVersionReadSpec,
6561    ) -> Result<PreviousVersionSnapshot, String> {
6562        let now_ms = self.clock_now_ms();
6563        crate::previous_version::previous_version_snapshot(
6564            &self.conn,
6565            self.previous_version_replica_path().as_deref(),
6566            self.previous_version.as_ref(),
6567            PreviousVersionLifecycle {
6568                lease_inactive: self.previous_version_lease_inactive(now_ms),
6569                scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
6570                coverage_complete: self.previous_version_coverage_complete(),
6571            },
6572            now_ms,
6573            self.schema.version,
6574            spec,
6575        )
6576    }
6577
6578    /// RFC 0005 D6: the advisory pre-reset compatibility audit.
6579    #[must_use]
6580    pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
6581        crate::previous_version::stored_previous_version_audit(&self.conn)
6582    }
6583
6584    /// RFC 0005 A2/D9: the executable downgrade step. Drop the container file
6585    /// and both metadata records, and report whether anything was present.
6586    /// Idempotent: absence is a successful no-op.
6587    pub fn previous_version_discard(
6588        &self,
6589    ) -> crate::previous_version::PreviousVersionDiscardOutcome {
6590        self.drop_previous_version()
6591    }
6592
6593    /// The module's discard has no failure path (it removes the file even when
6594    /// the container cannot be opened), so the only error a caller could ever
6595    /// see is a bug; report absence rather than claiming a discard happened.
6596    fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
6597        let absent = crate::previous_version::PreviousVersionDiscardOutcome {
6598            present: false,
6599            discarded: false,
6600        };
6601        let Some(replica_path) = self.previous_version_replica_path() else {
6602            return absent;
6603        };
6604        crate::previous_version::discard_previous_version(&self.conn, &replica_path)
6605            .unwrap_or(absent)
6606    }
6607
6608    /// RFC 0005 D7: `leaseState.errorCode` or an expired lease.
6609    fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
6610        let Some(lease) = &self.lease_state else {
6611            return false;
6612        };
6613        if lease.error_code.is_some() {
6614            return true;
6615        }
6616        lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
6617    }
6618
6619    /// RFC 0005 D7: §7.4.5 replacement coverage — every ACTIVE subscription
6620    /// has `cursor >= 0` and no resume token. An empty active set is not
6621    /// completion (mirrors the TS predicate exactly).
6622    fn previous_version_coverage_complete(&self) -> bool {
6623        let active = self
6624            .subs
6625            .iter()
6626            .filter(|sub| sub.state == SubState::Active)
6627            .collect::<Vec<_>>();
6628        if active.is_empty() {
6629            return false;
6630        }
6631        active
6632            .iter()
6633            .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
6634    }
6635
6636    /// RFC 0005 D7: lifetime triggers evaluated once per sync round, after the
6637    /// apply. A container that no longer has a reason to exist is discarded so
6638    /// the rows do not outlive the window they were kept for.
6639    fn previous_version_lifetime_check(&self) {
6640        let Some(config) = self.previous_version.filter(|config| config.enabled) else {
6641            return;
6642        };
6643        let now_ms = self.clock_now_ms();
6644        if self.previous_version_lease_inactive(now_ms) {
6645            let _ = self.drop_previous_version();
6646            return;
6647        }
6648        let Some(replica_path) = self.previous_version_replica_path() else {
6649            return;
6650        };
6651        let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
6652        else {
6653            return;
6654        };
6655        if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
6656            let _ = self.drop_previous_version();
6657            return;
6658        }
6659        if now_ms - record.created_at_ms > config.max_age_ms {
6660            let _ = self.drop_previous_version();
6661            return;
6662        }
6663        if self.previous_version_coverage_complete() {
6664            let _ = self.drop_previous_version();
6665        }
6666    }
6667
6668    pub fn diagnostics_snapshot(
6669        &self,
6670        request: &ClientDiagnosticsRequest,
6671    ) -> Result<ClientDiagnosticsSnapshot, String> {
6672        if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
6673            return Err(format!(
6674                "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
6675            ));
6676        }
6677        let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
6678        for sub in &self.subs {
6679            let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
6680            let complete =
6681                sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
6682            let state = match sub.state {
6683                SubState::Revoked => "revoked",
6684                SubState::Failed => "failed",
6685                SubState::Active if reset => "reset",
6686                SubState::Active if complete => "complete",
6687                SubState::Active => "bootstrapping",
6688            };
6689            subscriptions.insert(
6690                sub.id.clone(),
6691                DiagnosticSubscription {
6692                    id: sub.id.clone(),
6693                    table: sub.table.clone(),
6694                    state: state.to_owned(),
6695                    complete,
6696                    cursor: Some(sub.cursor),
6697                    reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
6698                },
6699            );
6700        }
6701        for expected in &request.expected_subscriptions {
6702            if expected.id.is_empty() || expected.table.is_empty() {
6703                return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
6704            }
6705            if subscriptions
6706                .get(&expected.id)
6707                .is_some_and(|registered| registered.table != expected.table)
6708            {
6709                subscriptions.insert(
6710                    expected.id.clone(),
6711                    DiagnosticSubscription {
6712                        id: expected.id.clone(),
6713                        table: expected.table.clone(),
6714                        state: "failed".to_owned(),
6715                        complete: false,
6716                        cursor: None,
6717                        reason_code: Some("client.subscription_intent_mismatch".to_owned()),
6718                    },
6719                );
6720            } else {
6721                subscriptions.entry(expected.id.clone()).or_insert_with(|| {
6722                    DiagnosticSubscription {
6723                        id: expected.id.clone(),
6724                        table: expected.table.clone(),
6725                        state: "unregistered".to_owned(),
6726                        complete: false,
6727                        cursor: None,
6728                        reason_code: None,
6729                    }
6730                });
6731            }
6732        }
6733        let mut ordered_subscriptions = Vec::new();
6734        let mut included = BTreeSet::new();
6735        for expected in &request.expected_subscriptions {
6736            if included.insert(expected.id.clone()) {
6737                if let Some(subscription) = subscriptions.get(&expected.id) {
6738                    ordered_subscriptions.push(subscription.clone());
6739                }
6740            }
6741        }
6742        for (id, subscription) in &subscriptions {
6743            if included.insert(id.clone()) {
6744                ordered_subscriptions.push(subscription.clone());
6745            }
6746        }
6747        let subscriptions_truncated =
6748            ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
6749        ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
6750        let captured_at_ms = self.clock_now_ms();
6751        let lease = if let Some(error_code) = self
6752            .lease_state
6753            .as_ref()
6754            .and_then(|state| state.error_code.clone())
6755        {
6756            ClientDiagnosticsLease {
6757                state: "stopped".to_owned(),
6758                expires_at_ms: self
6759                    .lease_state
6760                    .as_ref()
6761                    .and_then(|state| state.expires_at_ms),
6762                error_code: Some(Self::diagnostic_code(&error_code)),
6763            }
6764        } else if let Some(expires_at_ms) = self
6765            .lease_state
6766            .as_ref()
6767            .and_then(|state| state.expires_at_ms)
6768        {
6769            ClientDiagnosticsLease {
6770                state: if expires_at_ms <= captured_at_ms {
6771                    "expired".to_owned()
6772                } else {
6773                    "active".to_owned()
6774                },
6775                expires_at_ms: Some(expires_at_ms),
6776                error_code: None,
6777            }
6778        } else {
6779            ClientDiagnosticsLease {
6780                state: "none".to_owned(),
6781                expires_at_ms: None,
6782                error_code: None,
6783            }
6784        };
6785        let connectivity = match self.last_round.as_ref() {
6786            Some(round) if round.status == "succeeded" => "online",
6787            Some(round)
6788                if round.status == "failed"
6789                    && round
6790                        .error_code
6791                        .as_deref()
6792                        .is_some_and(Self::retryable_transport_code) =>
6793            {
6794                "offline"
6795            }
6796            _ => "unknown",
6797        };
6798        Ok(ClientDiagnosticsSnapshot {
6799            version: CLIENT_DIAGNOSTICS_VERSION,
6800            captured_at_ms,
6801            host: ClientDiagnosticsHost {
6802                kind: "direct".to_owned(),
6803                role: "single".to_owned(),
6804                connectivity: connectivity.to_owned(),
6805                realtime: self.realtime_state,
6806                realtime_policy: self.realtime_policy,
6807                realtime_reason_code: self.realtime_reason_code.clone(),
6808                realtime_retry_delay_ms: self.realtime_retry_delay_ms,
6809            },
6810            security_lifecycle: self.security_lifecycle().to_owned(),
6811            schema: ClientDiagnosticsSchema {
6812                current_version: self.schema.version,
6813                upgrading: self.upgrading,
6814                required_version: self
6815                    .schema_floor
6816                    .as_ref()
6817                    .and_then(|floor| floor.required_schema_version),
6818                latest_version: self
6819                    .schema_floor
6820                    .as_ref()
6821                    .and_then(|floor| floor.latest_schema_version),
6822            },
6823            replica: ClientDiagnosticsReplica {
6824                local_revision: self.local_revision().to_string(),
6825                sync_needed: self.sync_needed,
6826                pending_outbox: self.outbox.len(),
6827            },
6828            lease,
6829            subscriptions: ordered_subscriptions,
6830            subscriptions_truncated,
6831            last_round: self.last_round.clone(),
6832            last_change: self.last_change.clone(),
6833            storage: self.diagnostics_storage(),
6834            query_failures: self.query_failures.clone(),
6835        })
6836    }
6837
6838    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
6839        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
6840            let page_count: i64 = self
6841                .conn
6842                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
6843            let page_size: i64 = self
6844                .conn
6845                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
6846            // Reuse compiled aggregates; every invocation still reads current storage.
6847            let outbox_bytes: i64 = self
6848                .conn
6849                .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
6850                .query_row([], |row| row.get(0))?;
6851            let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
6852                "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
6853            )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
6854            let blob_bytes = if self.schema_has_blobs() {
6855                self.conn
6856                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
6857                    .query_row([], |row| row.get(0))?
6858            } else {
6859                0
6860            };
6861            let pressure = self
6862                .limits
6863                .blob_cache_max_bytes
6864                .is_some_and(|limit| blob_bytes > limit);
6865            Ok(ClientDiagnosticsStorage {
6866                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
6867                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
6868                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
6869                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
6870                retained_outcome_entries: Some(outcome_entries.max(0)),
6871                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
6872                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
6873            })
6874        };
6875        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
6876            status: "unreadable".to_owned(),
6877            database_bytes_approx: None,
6878            pending_outbox_bytes_approx: None,
6879            retained_outcome_bytes_approx: None,
6880            retained_outcome_entries: None,
6881            blob_cache_bytes_approx: None,
6882            pressure_reason_code: None,
6883        })
6884    }
6885
6886    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
6887        self.change_queue.drain(..).collect()
6888    }
6889
6890    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
6891        self.sync_intent_queue.drain(..).collect()
6892    }
6893
6894    fn schedule_background_retry(&mut self) -> u64 {
6895        let delay_ms = self.retry_delay_ms;
6896        self.sync_intent_queue
6897            .push_back(SyncIntent::Background { delay_ms });
6898        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
6899        self.round_retry_delay_ms = Some(delay_ms);
6900        delay_ms
6901    }
6902
6903    fn reset_background_retry(&mut self) {
6904        self.retry_delay_ms = 250;
6905    }
6906
6907    fn retryable_transport_code(code: &str) -> bool {
6908        code == "transport.failed"
6909            || code == "transport.unavailable"
6910            || code == "sync.transport_failed"
6911    }
6912
6913    /// A failed round worth a background retry: a transport failure, or a
6914    /// §10.2 code the catalog marks retryable (clients may hardcode the
6915    /// catalog metadata, §10.1). Matches the TypeScript core, which retries
6916    /// on the server's `retryable` flag.
6917    fn retryable_failure_code(code: &str) -> bool {
6918        Self::retryable_transport_code(code)
6919            || matches!(
6920                code,
6921                "sync.auth_required"
6922                    | "sync.auth_lease_required"
6923                    | "sync.auth_lease_revoked"
6924                    | "sync.internal_error"
6925                    | "sync.idempotency_cache_miss"
6926                    | "sync.schema_not_ready"
6927                    | "sync.segment_expired"
6928                    | "sync.rate_limited"
6929                    | "sync.websocket_connection_limit"
6930            )
6931    }
6932
6933    fn diagnostic_code(code: &str) -> String {
6934        let valid = !code.is_empty()
6935            && code.len() <= 96
6936            && code.contains('.')
6937            && code
6938                .bytes()
6939                .next()
6940                .is_some_and(|byte| byte.is_ascii_lowercase())
6941            && code.bytes().all(|byte| {
6942                byte.is_ascii_lowercase()
6943                    || byte.is_ascii_digit()
6944                    || matches!(byte, b'.' | b'_' | b'-')
6945            });
6946        if valid {
6947            code.to_owned()
6948        } else {
6949            "client.unknown_failure".to_owned()
6950        }
6951    }
6952
6953    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
6954        if self.sync_needed != value {
6955            if self.begin_observation("syncular_status").is_ok() {
6956                self.sync_needed = value;
6957                let batch = ChangeAccumulator {
6958                    status: true,
6959                    ..ChangeAccumulator::default()
6960                };
6961                if self.finish_observation("syncular_status", batch).is_err() {
6962                    self.rollback_observation("syncular_status");
6963                }
6964            } else {
6965                self.sync_needed = value;
6966            }
6967        }
6968        if value && interactive {
6969            self.sync_intent_queue.push_back(SyncIntent::Interactive);
6970        }
6971    }
6972
6973    fn sqlite_failure(
6974        storage_failure: &RefCell<Option<QueryReadFailure>>,
6975        error: rusqlite::Error,
6976    ) -> String {
6977        let message = error.to_string();
6978        let failure = QueryReadFailure::from(error);
6979        let code = failure.code;
6980        if code.is_some() && storage_failure.borrow().is_none() {
6981            *storage_failure.borrow_mut() = Some(failure);
6982        }
6983        match code {
6984            Some(code) => format!("{code}: {message}"),
6985            None => message,
6986        }
6987    }
6988
6989    fn begin_observation(&self, name: &str) -> Result<(), String> {
6990        self.conn
6991            .execute_batch(&format!("SAVEPOINT {name}"))
6992            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
6993    }
6994
6995    fn rollback_observation(&self, name: &str) {
6996        if let Err(error) = self
6997            .conn
6998            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
6999        {
7000            if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
7001                if failure.rollback_failure.is_none() {
7002                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
7003                }
7004            }
7005        }
7006    }
7007
7008    fn finish_observation(
7009        &mut self,
7010        name: &str,
7011        mut batch: ChangeAccumulator,
7012    ) -> Result<(), String> {
7013        #[cfg(feature = "bench-internals")]
7014        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
7015        if self.failed_commits.iter().any(|(commit, _)| {
7016            commit
7017                .ops
7018                .iter()
7019                .any(|operation| batch.tables.contains_key(&operation.table))
7020        }) {
7021            batch.outcomes = true;
7022        }
7023        if !batch.touched() {
7024            self.conn
7025                .execute_batch(&format!("RELEASE {name}"))
7026                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7027            return Ok(());
7028        }
7029        let revision = self
7030            .local_revision()
7031            .checked_add(1)
7032            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
7033        self.conn
7034            .execute(
7035                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
7036                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
7037            )
7038            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7039        let status = batch.status.then(|| self.status_snapshot());
7040        let event = ClientChangeBatch {
7041            revision: revision.to_string(),
7042            tables: batch
7043                .tables
7044                .into_iter()
7045                .map(|(table, scope_keys)| TableChange {
7046                    table,
7047                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
7048                })
7049                .collect(),
7050            windows: batch
7051                .windows
7052                .into_iter()
7053                .map(|((base_key, table), units)| WindowChange {
7054                    base_key,
7055                    table,
7056                    units: units.into_iter().collect(),
7057                })
7058                .collect(),
7059            status,
7060            conflicts_changed: batch.conflicts,
7061            rejections_changed: batch.rejections,
7062            outcomes_changed: batch.outcomes,
7063        };
7064        self.conn
7065            .execute_batch(&format!("RELEASE {name}"))
7066            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7067        let mut diagnostic_tables = event
7068            .tables
7069            .iter()
7070            .map(|entry| entry.table.clone())
7071            .collect::<BTreeSet<_>>()
7072            .into_iter()
7073            .collect::<Vec<_>>();
7074        let mut diagnostic_windows = event
7075            .windows
7076            .iter()
7077            .map(|entry| entry.table.clone())
7078            .collect::<BTreeSet<_>>()
7079            .into_iter()
7080            .collect::<Vec<_>>();
7081        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
7082            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
7083        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
7084        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
7085        self.last_change = Some(DiagnosticLastChange {
7086            revision: event.revision.clone(),
7087            recorded_at_ms: self.clock_now_ms(),
7088            tables: diagnostic_tables,
7089            windows: diagnostic_windows,
7090            domains_truncated,
7091            status_changed: event.status.is_some(),
7092            conflicts_changed: event.conflicts_changed,
7093            rejections_changed: event.rejections_changed,
7094            outcomes_changed: event.outcomes_changed,
7095        });
7096        self.change_queue.push_back(event);
7097        Ok(())
7098    }
7099
7100    fn record_scope_map(
7101        &self,
7102        batch: &mut ChangeAccumulator,
7103        table_name: &str,
7104        scopes: &[(String, Vec<String>)],
7105    ) {
7106        let Some(table) = self.schema.table(table_name) else {
7107            return;
7108        };
7109        for (variable, values) in scopes {
7110            let Some(scope) = table
7111                .scope_variables
7112                .iter()
7113                .find(|scope| &scope.variable == variable)
7114            else {
7115                continue;
7116            };
7117            for value in values {
7118                batch.scope(table_name, format!("{}:{value}", scope.prefix));
7119            }
7120        }
7121    }
7122
7123    /// Record a row's current scope keys from the base or visible table.
7124    fn record_row_scopes(
7125        &self,
7126        batch: &mut ChangeAccumulator,
7127        table_name: &str,
7128        row_id: &str,
7129        base: bool,
7130    ) -> bool {
7131        let Some(table) = self.schema.table(table_name) else {
7132            return false;
7133        };
7134        if table.scope_variables.is_empty() {
7135            return false;
7136        }
7137        let columns = table
7138            .scope_variables
7139            .iter()
7140            .map(|scope| quote_ident(&scope.column))
7141            .collect::<Vec<_>>()
7142            .join(", ");
7143        let full_table = if base {
7144            base_table(table_name)
7145        } else {
7146            visible_table(table_name)
7147        };
7148        let sql = format!(
7149            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
7150            row_id_predicate(table)
7151        );
7152        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
7153            return false;
7154        };
7155        let values = stmt.query_row(rusqlite::params![row_id], |row| {
7156            let mut values = Vec::with_capacity(table.scope_variables.len());
7157            for index in 0..table.scope_variables.len() {
7158                values.push(row.get::<_, Option<String>>(index)?);
7159            }
7160            Ok(values)
7161        });
7162        let Ok(values) = values else {
7163            return false;
7164        };
7165        let mut recorded = false;
7166        for (scope, value) in table.scope_variables.iter().zip(values) {
7167            if let Some(value) = value {
7168                batch.scope(table_name, format!("{}:{value}", scope.prefix));
7169                recorded = true;
7170            }
7171        }
7172        recorded
7173    }
7174
7175    fn record_commit_changes(
7176        &self,
7177        batch: &mut ChangeAccumulator,
7178        tables: &[String],
7179        changes: &[ssp2::model::Change],
7180    ) {
7181        #[cfg(feature = "bench-internals")]
7182        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
7183        for change in changes {
7184            let Some(table_name) = tables.get(change.table_index as usize) else {
7185                continue;
7186            };
7187            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
7188            if let Some(table) = self.schema.table(table_name) {
7189                for (variable, value) in &change.scopes {
7190                    if let Some(scope) = table
7191                        .scope_variables
7192                        .iter()
7193                        .find(|scope| &scope.variable == variable)
7194                    {
7195                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
7196                        precise = true;
7197                    }
7198                }
7199            }
7200            if !precise {
7201                batch.table(table_name);
7202            }
7203        }
7204    }
7205
7206    fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
7207        if effective.is_empty() {
7208            return false;
7209        }
7210        let Some(table) = self.schema.table(table_name) else {
7211            return false;
7212        };
7213        let mut clauses = Vec::new();
7214        let mut params = Vec::new();
7215        for (variable, values) in effective {
7216            let Some(column) = table.scope_column(variable) else {
7217                return false;
7218            };
7219            if values.is_empty() {
7220                return false;
7221            }
7222            let placeholders = values
7223                .iter()
7224                .map(|value| {
7225                    params.push(SqlValue::Text(value.clone()));
7226                    "?"
7227                })
7228                .collect::<Vec<_>>()
7229                .join(", ");
7230            clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
7231        }
7232        let sql = format!(
7233            "SELECT 1 FROM {} WHERE {} LIMIT 1",
7234            base_table(table_name),
7235            clauses.join(" AND ")
7236        );
7237        self.conn
7238            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
7239            .is_ok()
7240    }
7241
7242    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
7243    pub fn upgrading(&self) -> bool {
7244        self.upgrading
7245    }
7246
7247    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
7248    /// keeping this client's local database (identity, outbox, tables). The
7249    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
7250    /// version changed. Mirrors the TS client's boot-time detection —
7251    /// the Rust core has no persistent restart, so recreation IS the boot.
7252    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
7253        let new_schema = parse_schema_json(schema_json)?;
7254        let marker: Option<i32> = self
7255            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7256            .and_then(|v| v.parse().ok());
7257        self.schema = new_schema;
7258        self.create_bookkeeping_tables()?;
7259        if marker != Some(self.schema.version) {
7260            self.run_schema_reset()?;
7261        }
7262        self.prune_unknown_subscriptions(false)?;
7263        self.save_subscription_scope_schema();
7264        // The conformance recreate is the in-memory equivalent of reopening a
7265        // durable client. Apply the same startup catch-up contract even when
7266        // the schema itself did not change.
7267        self.enqueue_startup_sync_if_needed()?;
7268        Ok(())
7269    }
7270
7271    fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
7272        let startup_work = !self.stopped
7273            && (!self.outbox.is_empty()
7274                || !self.load_pending_evictions()?.is_empty()
7275                || self.subs.iter().any(|sub| sub.state == SubState::Active));
7276        if startup_work {
7277            self.sync_needed = true;
7278            self.sync_intent_queue.push_back(SyncIntent::Interactive);
7279        }
7280        Ok(())
7281    }
7282
7283    /// A native client persists schema-floor stops across process restarts.
7284    /// Once the running app already satisfies that floor, the persisted stop
7285    /// is only stale evidence from an older server round (for example, the
7286    /// server was restarted after catching up to an app that was ahead).
7287    /// Clear it and let the normal startup pull re-negotiate. If the server is
7288    /// still incompatible it will return the floor again in that first round.
7289    fn clear_satisfied_persisted_schema_floor(&mut self) {
7290        let satisfied = self
7291            .schema_floor
7292            .as_ref()
7293            .and_then(|floor| floor.required_schema_version)
7294            .is_some_and(|required| self.schema.version >= required);
7295        if !satisfied {
7296            return;
7297        }
7298        self.schema_floor = None;
7299        self.stopped = false;
7300        self.delete_meta(SCHEMA_FLOOR_KEY);
7301    }
7302
7303    /// RFC 0005 D1: discard an orphan or stale container at boot. The reset
7304    /// path already swept it; this covers a same-version or fresh-install open,
7305    /// which runs no reset and therefore no sweep — plus the D7 TTL, which only
7306    /// an aware binary can apply.
7307    fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
7308        let Some(replica_path) = self.previous_version_replica_path() else {
7309            return Ok(());
7310        };
7311        let max_age_ms = self
7312            .previous_version
7313            .filter(|config| config.enabled)
7314            .map(|config| config.max_age_ms);
7315        reconcile_previous_version_at_boot(
7316            &self.conn,
7317            &replica_path,
7318            self.schema.version,
7319            max_age_ms,
7320            self.clock_now_ms(),
7321        )
7322    }
7323
7324    /// RFC 0005 D3: the sibling container path, or `None` when the replica has
7325    /// no file — an in-memory replica returns an empty path, and a `file:` URI
7326    /// has no plain sibling path — so the feature resolves as off there.
7327    fn previous_version_replica_path(&self) -> Option<String> {
7328        let path = self.conn.path()?;
7329        if path.is_empty() || path.starts_with("file:") {
7330            return None;
7331        }
7332        Some(path.to_owned())
7333    }
7334
7335    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
7336    /// and leaseState. Drops/recreates every synced table from the new
7337    /// schema, resets subscription sync-state (keeping registrations), clears
7338    /// the schema-floor stop state, rewrites the marker, drops outbox commits
7339    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
7340    fn run_schema_reset(&mut self) -> Result<(), String> {
7341        self.begin_observation("syncular_schema_reset")?;
7342        let prior_subs = self.subs.clone();
7343        let mut batch = ChangeAccumulator::default();
7344        let result = self.run_schema_reset_observed(&mut batch, true, true);
7345        if let Err(error) = result {
7346            self.rollback_observation("syncular_schema_reset");
7347            self.subs = prior_subs;
7348            return Err(error);
7349        }
7350        if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
7351            self.rollback_observation("syncular_schema_reset");
7352            self.subs = prior_subs;
7353            return Err(error);
7354        }
7355        Ok(())
7356    }
7357
7358    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
7359        let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
7360        self.begin_observation("syncular_log_epoch_reset")?;
7361        let prior_subs = self.subs.clone();
7362        let mut batch = ChangeAccumulator::default();
7363        let result = self.run_schema_reset_observed(&mut batch, false, false);
7364        if let Err(error) = result {
7365            self.rollback_observation("syncular_log_epoch_reset");
7366            self.subs = prior_subs;
7367            return Err(error);
7368        }
7369        self.set_meta(LOG_EPOCH_KEY, log_epoch);
7370        self.sync_needed = true;
7371        batch.status = true;
7372        if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
7373            self.rollback_observation("syncular_log_epoch_reset");
7374            self.subs = prior_subs;
7375            return Err(error);
7376        }
7377        self.sync_intent_queue.push_back(SyncIntent::Interactive);
7378        Ok(resets)
7379    }
7380
7381    fn run_schema_reset_observed(
7382        &mut self,
7383        batch: &mut ChangeAccumulator,
7384        drop_incompatible: bool,
7385        capture: bool,
7386    ) -> Result<(), String> {
7387        self.cancel_sync_round();
7388        self.upgrading = true;
7389        batch.status = true;
7390        // RFC 0005 D6: classify the pending outbox against the NEW compiled
7391        // schema BEFORE the wipe, and record the advisory audit. It drops
7392        // nothing — the §7.4.4 send-time drop stays the only path that removes
7393        // a commit. The classification is shared with that drop so the two can
7394        // never disagree about what is incompatible.
7395        if self.previous_version.is_some_and(|config| config.enabled) {
7396            let previous_version = self
7397                .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7398                .and_then(|value| value.parse::<i32>().ok())
7399                .unwrap_or(self.schema.version);
7400            let pending = self
7401                .outbox
7402                .iter()
7403                .map(|commit| PendingCommitAudit {
7404                    commit_id: &commit.client_commit_id,
7405                    operations: commit_audit_operations(commit),
7406                })
7407                .collect::<Vec<_>>();
7408            crate::previous_version::write_previous_version_audit(
7409                &self.conn,
7410                &crate::previous_version::build_previous_version_audit(
7411                    &self.schema,
7412                    &pending,
7413                    previous_version,
7414                    self.schema.version,
7415                    self.clock_now_ms(),
7416                ),
7417            );
7418        }
7419        // RFC 0005 D5: the container lives in its own FILE, so the reset can
7420        // never reach it. Sweep first (unconditional — also on the log-epoch
7421        // reset, which captures nothing), then capture BEFORE the wipe drops
7422        // any row. A refused capture records its reason and leaves no file.
7423        if let Some(replica_path) = self.previous_version_replica_path() {
7424            sweep_previous_version_container(&self.conn, &replica_path)?;
7425            if capture {
7426                if let Some(config) = self.previous_version.filter(|config| config.enabled) {
7427                    let previous_version = self
7428                        .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7429                        .and_then(|value| value.parse::<i32>().ok())
7430                        .unwrap_or(self.schema.version);
7431                    capture_previous_version_from_replica(
7432                        &self.conn,
7433                        &replica_path,
7434                        previous_version,
7435                        self.schema.version,
7436                        &config,
7437                        self.clock_now_ms(),
7438                    )?;
7439                }
7440            }
7441        }
7442        for table in &self.schema.tables {
7443            batch.table(&table.name);
7444        }
7445        for (base_key, unit, table) in self.load_registered_window_units() {
7446            batch.window(&base_key, &table, &unit);
7447        }
7448        if capture {
7449            self.prune_unknown_subscriptions(true)?;
7450        }
7451        // The per-table insert SQL is derived from the OLD column lists.
7452        self.insert_sql.borrow_mut().clear();
7453        self.overlay_dirty.set(true);
7454        // Drop every synced table (base + visible) that currently exists —
7455        // discovered from sqlite_master so a bump that adds/removes tables is
7456        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
7457        // and the blob cache) are preserved; base tables are `_syncular_base_*`
7458        // so they are matched explicitly, not by the bookkeeping filter.
7459        // Drop virtual tables first so SQLite removes every FTS shadow table
7460        // atomically instead of the generic discovery tearing it apart.
7461        let virtual_tables: Vec<String> = {
7462            let mut stmt = self
7463                .conn
7464                .prepare(
7465                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
7466                )
7467                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7468            let rows = stmt
7469                .query_map([], |row| row.get::<_, String>(0))
7470                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7471            rows.filter_map(Result::ok)
7472                .filter(|name| is_synced_table_name(name))
7473                .collect()
7474        };
7475        for name in virtual_tables {
7476            self.conn
7477                .execute_batch(&format!(
7478                    "DROP TABLE IF EXISTS {}",
7479                    quote_ident(&format!("_syncular_fts_{name}"))
7480                ))
7481                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7482            self.conn
7483                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
7484                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7485        }
7486        let existing: Vec<String> = {
7487            let mut stmt = self
7488                .conn
7489                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
7490                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7491            let rows = stmt
7492                .query_map([], |row| row.get::<_, String>(0))
7493                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7494            rows.filter_map(Result::ok)
7495                .filter(|name| is_synced_table_name(name))
7496                .collect()
7497        };
7498        for name in &existing {
7499            if let Some(table) = name.strip_prefix("_syncular_base_") {
7500                batch.table(table);
7501            } else if !name.starts_with("_syncular_") {
7502                batch.table(name);
7503            }
7504        }
7505        for name in existing {
7506            self.conn
7507                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
7508                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7509        }
7510        // Recreate the synced tables from the NEW schema.
7511        self.create_synced_tables()?;
7512        // Reset every subscription's sync-state, keeping the registration.
7513        for sub in &mut self.subs {
7514            sub.cursor = -1;
7515            sub.bootstrap_state = None;
7516            sub.effective = None;
7517            sub.state = SubState::Active;
7518            sub.reason_code = None;
7519            sub.synced_once = false;
7520        }
7521        let subs = self.subs.clone();
7522        for sub in &subs {
7523            self.persist_sub(sub)?;
7524        }
7525        // The stop state is over: this client now ships a servable schema.
7526        self.stopped = false;
7527        self.schema_floor = None;
7528        self.delete_meta(SCHEMA_FLOOR_KEY);
7529        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
7530        self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
7531        // D1: the descriptor follows every marker write. A crash in between
7532        // self-heals on the next same-version open, which backfills it.
7533        set_local_schema_descriptor(&self.conn, &self.schema);
7534        self.save_subscription_scope_schema();
7535        // §7.4.4: drop outbox commits that cannot re-encode under the new
7536        // schema (a referenced column/table the bump removed), surfacing each
7537        // as a `sync.outbox_incompatible` rejection.
7538        if drop_incompatible && self.drop_incompatible_outbox()? {
7539            batch.rejections = true;
7540            batch.status = true;
7541            batch.outcomes = true;
7542        }
7543        // Re-apply the surviving outbox optimistically over the empty tables.
7544        self.rebuild_overlay();
7545        Ok(())
7546    }
7547
7548    /// §7.4.4: a persisted upsert whose values reference a column the current
7549    /// schema lacks (or a removed table) cannot be encoded. Drop the commit
7550    /// and raise a client-local `sync.outbox_incompatible` rejection.
7551    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
7552        let incompatible = self
7553            .outbox
7554            .iter()
7555            .filter(|commit| {
7556                first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
7557            })
7558            .cloned()
7559            .collect::<Vec<_>>();
7560        if incompatible.is_empty() {
7561            return Ok(false);
7562        }
7563        let mut rejections = Vec::new();
7564        for commit in &incompatible {
7565            let results = commit
7566                .ops
7567                .iter()
7568                .enumerate()
7569                .map(|(op_index, operation)| {
7570                    let rejection = RejectionRecord {
7571                        client_commit_id: commit.client_commit_id.clone(),
7572                        op_index: op_index as i32,
7573                        code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
7574                        message: "the persisted commit cannot encode under the current schema"
7575                            .to_owned(),
7576                        retryable: false,
7577                        details: None,
7578                        operation: Some(CommitOperation::from(operation)),
7579                    };
7580                    rejections.push(rejection.clone());
7581                    CommitOperationOutcome::Error { rejection }
7582                })
7583                .collect::<Vec<_>>();
7584            self.persist_commit_outcome(
7585                &commit.client_commit_id,
7586                CommitOutcomeStatus::Rejected,
7587                &results,
7588                Some(&commit.ops),
7589            )?;
7590            self.delete_outbox_persisted(&commit.client_commit_id)?;
7591        }
7592        self.prune_commit_outcomes()?;
7593        let incompatible_ids = incompatible
7594            .iter()
7595            .map(|commit| commit.client_commit_id.as_str())
7596            .collect::<BTreeSet<_>>();
7597        self.outbox
7598            .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
7599        self.rejections.extend(rejections);
7600        Ok(true)
7601    }
7602
7603    /// §7.4.3: (re)create the base + visible table pair for every synced
7604    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
7605    fn create_synced_tables(&self) -> Result<(), String> {
7606        for table in &self.schema.tables {
7607            // The base half + the visible half form the synced-table pair. An
7608            // index name is global in SQLite, so the base half's indexes are
7609            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
7610            for (full, index_prefix) in [
7611                (base_table(&table.name), "_syncular_base_"),
7612                (visible_table(&table.name), ""),
7613            ] {
7614                let mut cols: Vec<String> =
7615                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
7616                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
7617                let sql = format!(
7618                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
7619                    cols.join(", "),
7620                    quote_ident(&table.primary_key)
7621                );
7622                self.conn
7623                    .execute(&sql, [])
7624                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7625                // Local secondary indexes (CREATE INDEX subset). Created on
7626                // both halves so mirror reads hit an index on either. Runs on
7627                // both the initial create and the §7.4.3 reset recreate path.
7628                for index in &table.indexes {
7629                    let unique = if index.unique { "UNIQUE " } else { "" };
7630                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
7631                    let cols_sql = index
7632                        .columns
7633                        .iter()
7634                        .map(|c| quote_ident(c))
7635                        .collect::<Vec<_>>()
7636                        .join(", ");
7637                    let index_sql = format!(
7638                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
7639                    );
7640                    self.conn
7641                        .execute(&index_sql, [])
7642                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7643                }
7644            }
7645        }
7646        // FTS exists only on the visible half. It is a local search
7647        // projection, never a synced/base table or a wire-schema member.
7648        for table in &self.schema.tables {
7649            for index in &table.fts_indexes {
7650                self.create_fts_projection(table, index)?;
7651            }
7652        }
7653        Ok(())
7654    }
7655
7656    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
7657        let count: i64 = self
7658            .conn
7659            .query_row(
7660                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
7661                rusqlite::params![index.name],
7662                |row| row.get(0),
7663            )
7664            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7665        Ok(count > 0)
7666    }
7667
7668    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
7669        for suffix in ["ai", "ad", "au"] {
7670            self.conn
7671                .execute(
7672                    &format!(
7673                        "DROP TRIGGER IF EXISTS {}",
7674                        quote_ident(&format!("{}_{suffix}", index.name))
7675                    ),
7676                    [],
7677                )
7678                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7679        }
7680        Ok(())
7681    }
7682
7683    fn create_fts_triggers(
7684        &self,
7685        table: &TableSchema,
7686        index: &FtsIndexSchema,
7687    ) -> Result<(), String> {
7688        let fts = quote_ident(&index.name);
7689        let source = visible_table(&table.name);
7690        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7691        let pk = quote_ident(&table.primary_key);
7692        let projection_columns = std::iter::once(source_id.clone())
7693            .chain(index.columns.iter().map(|column| quote_ident(column)))
7694            .collect::<Vec<_>>()
7695            .join(", ");
7696        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
7697            .chain(
7698                index
7699                    .columns
7700                    .iter()
7701                    .map(|column| format!("new.{}", quote_ident(column))),
7702            )
7703            .collect::<Vec<_>>()
7704            .join(", ");
7705        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7706        let delete_new = format!("DELETE FROM {fts} WHERE rowid = (SELECT id FROM {mapping} WHERE source_id = CAST(new.{pk} AS TEXT)); DELETE FROM {mapping} WHERE source_id = CAST(new.{pk} AS TEXT)");
7707        let delete_old = format!("DELETE FROM {fts} WHERE rowid = (SELECT id FROM {mapping} WHERE source_id = CAST(old.{pk} AS TEXT)); DELETE FROM {mapping} WHERE source_id = CAST(old.{pk} AS TEXT)");
7708        let insert_new = format!("INSERT INTO {mapping}(source_id) VALUES (CAST(new.{pk} AS TEXT)); INSERT INTO {fts} (rowid, {projection_columns}) VALUES ((SELECT id FROM {mapping} WHERE source_id = CAST(new.{pk} AS TEXT)), {new_values})");
7709        let bi = quote_ident(&format!("{}_bi", index.name));
7710        let ai = quote_ident(&format!("{}_ai", index.name));
7711        let ad = quote_ident(&format!("{}_ad", index.name));
7712        let au = quote_ident(&format!("{}_au", index.name));
7713        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
7714        let sql = format!(
7715            "DROP TRIGGER IF EXISTS {bi};
7716             DROP TRIGGER IF EXISTS {ai};
7717             DROP TRIGGER IF EXISTS {ad};
7718             DROP TRIGGER IF EXISTS {au};
7719             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
7720             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
7721             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
7722             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
7723        );
7724        self.conn
7725            .execute_batch(&sql)
7726            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7727    }
7728
7729    fn rebuild_fts_projection(
7730        &self,
7731        table: &TableSchema,
7732        index: &FtsIndexSchema,
7733    ) -> Result<(), String> {
7734        let fts = quote_ident(&index.name);
7735        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7736        let pk = quote_ident(&table.primary_key);
7737        let indexed_columns = index
7738            .columns
7739            .iter()
7740            .map(|column| quote_ident(column))
7741            .collect::<Vec<_>>();
7742        let projection_columns = std::iter::once(source_id.clone())
7743            .chain(indexed_columns.iter().cloned())
7744            .collect::<Vec<_>>()
7745            .join(", ");
7746        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7747        let sql = format!(
7748            "DELETE FROM {fts}; DELETE FROM {mapping}; INSERT INTO {fts} ({projection_columns}) SELECT CAST({pk} AS TEXT), {columns} FROM {source}; INSERT INTO {mapping}(id, source_id) SELECT rowid, {source_id} FROM {fts};",
7749            columns = indexed_columns.join(", "),
7750            source = visible_table(&table.name),
7751        );
7752        self.conn
7753            .execute_batch(&sql)
7754            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7755    }
7756
7757    fn create_fts_projection(
7758        &self,
7759        table: &TableSchema,
7760        index: &FtsIndexSchema,
7761    ) -> Result<(), String> {
7762        self.begin_observation("syncular_fts_schema")?;
7763        let result = (|| {
7764            let existed = self.fts_projection_exists(index)?;
7765            let tokenizer = index.tokenize.replace('\'', "''");
7766            let columns = index
7767                .columns
7768                .iter()
7769                .map(|column| quote_ident(column))
7770                .collect::<Vec<_>>()
7771                .join(", ");
7772            let sql = format!(
7773            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
7774            fts = quote_ident(&index.name),
7775            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
7776        );
7777            self.conn.execute(&sql, []).map_err(|error| {
7778                format!(
7779                    "cannot create local FTS5 projection {:?}: {error}",
7780                    index.name
7781                )
7782            })?;
7783            let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7784            let mapped: bool = self
7785                .conn
7786                .query_row(
7787                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
7788                    [format!("_syncular_fts_{}", index.name)],
7789                    |row| row.get(0),
7790                )
7791                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7792            self.conn.execute_batch(&format!("CREATE TABLE IF NOT EXISTS {mapping}(id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE)")).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7793            if !mapped && existed {
7794                self.conn
7795                    .execute_batch(&format!(
7796                        "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
7797                        quote_ident(FTS_SOURCE_ID_COLUMN),
7798                        quote_ident(&index.name)
7799                    ))
7800                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7801            }
7802            self.create_fts_triggers(table, index)?;
7803            if !existed {
7804                self.rebuild_fts_projection(table, index)?;
7805            }
7806            Ok(())
7807        })();
7808        if let Err(error) = result {
7809            self.rollback_observation("syncular_fts_schema");
7810            return Err(error);
7811        }
7812        if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
7813            self.rollback_observation("syncular_fts_schema");
7814            return Err(error.to_string());
7815        }
7816        Ok(())
7817    }
7818
7819    // -- persistence write-through --------------------------------------------
7820
7821    fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
7822        let state = serde_json::json!({
7823            "requested": scope_map_to_json(&sub.requested),
7824            "params": sub.params,
7825            "cursor": sub.cursor,
7826            "bootstrapState": sub.bootstrap_state,
7827            "status": sub.state.name(),
7828            "reasonCode": sub.reason_code,
7829            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
7830            "syncedOnce": sub.synced_once,
7831        });
7832        self.conn.execute(
7833            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
7834            rusqlite::params![sub.id, sub.table, state.to_string()],
7835        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7836    }
7837
7838    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
7839        let ops: Vec<Value> = commit
7840            .ops
7841            .iter()
7842            .map(|op| {
7843                serde_json::json!({
7844                    "op": if op.upsert { "upsert" } else { "delete" },
7845                    "table": op.table,
7846                    "rowId": op.row_id,
7847                    "baseVersion": op.base_version,
7848                    "values": op.values.clone().map(Value::Object),
7849                })
7850            })
7851            .collect();
7852        self.conn
7853            .execute(
7854                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
7855                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
7856            )
7857            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7858        for operation in &commit.ops {
7859            let Some(values) = &operation.values else {
7860                continue;
7861            };
7862            let Some(table) = self.schema.table(&operation.table) else {
7863                continue;
7864            };
7865            for column in table
7866                .columns
7867                .iter()
7868                .filter(|column| column.ty == ColumnType::BlobRef)
7869            {
7870                let Some(Value::String(raw)) = values.get(&column.name) else {
7871                    continue;
7872                };
7873                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
7874                    continue;
7875                };
7876                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
7877                    continue;
7878                };
7879                self.conn
7880                    .execute(
7881                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
7882                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
7883                        rusqlite::params![commit.client_commit_id, blob_id],
7884                    )
7885                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7886            }
7887        }
7888        Ok(())
7889    }
7890
7891    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
7892        if let Some(commit) = self
7893            .outbox
7894            .iter()
7895            .find(|commit| commit.client_commit_id == client_commit_id)
7896        {
7897            for operation in &commit.ops {
7898                self.overlay_dirty.table(&operation.table);
7899            }
7900        }
7901
7902        self.conn
7903            .execute(
7904                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
7905                rusqlite::params![client_commit_id],
7906            )
7907            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7908        self.conn
7909            .execute(
7910                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
7911                rusqlite::params![client_commit_id],
7912            )
7913            .map(|_| ())
7914            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7915    }
7916
7917    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
7918        match status {
7919            CommitOutcomeStatus::Applied => "applied",
7920            CommitOutcomeStatus::Cached => "cached",
7921            CommitOutcomeStatus::Conflict => "conflict",
7922            CommitOutcomeStatus::Rejected => "rejected",
7923        }
7924    }
7925
7926    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
7927        match resolution {
7928            CommitOutcomeResolution::Active => "active",
7929            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
7930            CommitOutcomeResolution::Superseded => "superseded",
7931            CommitOutcomeResolution::Dismissed => "dismissed",
7932        }
7933    }
7934
7935    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
7936        match value {
7937            "applied" => Ok(CommitOutcomeStatus::Applied),
7938            "cached" => Ok(CommitOutcomeStatus::Cached),
7939            "conflict" => Ok(CommitOutcomeStatus::Conflict),
7940            "rejected" => Ok(CommitOutcomeStatus::Rejected),
7941            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
7942        }
7943    }
7944
7945    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
7946        match value {
7947            "active" => Ok(CommitOutcomeResolution::Active),
7948            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
7949            "superseded" => Ok(CommitOutcomeResolution::Superseded),
7950            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
7951            _ => Err(format!(
7952                "invalid persisted commit outcome resolution {value:?}"
7953            )),
7954        }
7955    }
7956
7957    fn persist_commit_outcome(
7958        &self,
7959        client_commit_id: &str,
7960        status: CommitOutcomeStatus,
7961        results: &[CommitOperationOutcome],
7962        operations: Option<&[OutboxOp]>,
7963    ) -> Result<(), String> {
7964        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
7965        let operations_json = operations
7966            .map(|items| {
7967                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
7968            })
7969            .transpose()
7970            .map_err(|error| error.to_string())?;
7971        self.conn
7972            .execute(
7973                "INSERT INTO _syncular_commit_outcomes (
7974                   client_commit_id, status, recorded_at_ms, results_json,
7975                   operations_json, resolution
7976                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
7977                rusqlite::params![
7978                    client_commit_id,
7979                    Self::outcome_status_name(status),
7980                    self.clock_now_ms(),
7981                    results_json,
7982                    operations_json
7983                ],
7984            )
7985            .map(|_| ())
7986            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7987    }
7988
7989    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
7990        let StoredCommitOutcomeRow {
7991            sequence,
7992            client_commit_id,
7993            status,
7994            recorded_at_ms,
7995            results_json,
7996            operations_json,
7997            resolution,
7998            resolved_at_ms,
7999            replacement_client_commit_id,
8000        } = row;
8001        Ok(CommitOutcome {
8002            sequence,
8003            client_commit_id,
8004            status: Self::parse_outcome_status(&status)?,
8005            recorded_at_ms,
8006            results: serde_json::from_str(&results_json)
8007                .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
8008            operations: operations_json
8009                .map(|value| {
8010                    serde_json::from_str(&value).map_err(|error| {
8011                        format!("invalid persisted commit outcome operations: {error}")
8012                    })
8013                })
8014                .transpose()?,
8015            retained_rows: None,
8016            resolution: Self::parse_outcome_resolution(&resolution)?,
8017            resolved_at_ms,
8018            replacement_client_commit_id,
8019        })
8020    }
8021
8022    /// Resolve declared unique keys through SQLite, including affinity and NULL semantics.
8023    fn unique_conflicts(
8024        &self,
8025        table: &TableSchema,
8026        values: &Map<String, Value>,
8027        base: bool,
8028    ) -> Result<Vec<RetainedUniqueConflict>, String> {
8029        let source = if base {
8030            base_table(&table.name)
8031        } else {
8032            visible_table(&table.name)
8033        };
8034        let mut conflicts = Vec::new();
8035        for index in table.indexes.iter().filter(|index| index.unique) {
8036            let mut params = vec![json_param_to_sql(
8037                values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
8038            )?];
8039            for name in &index.columns {
8040                let column = table
8041                    .columns
8042                    .iter()
8043                    .find(|column| column.name == *name)
8044                    .ok_or("sync.local_corrupt")?;
8045                let cell = json_to_column_value(column, values.get(name))?;
8046                params.push(owned_sql_value(RowParam::Cell(&cell))?);
8047            }
8048            let sql = format!(
8049                "SELECT * FROM {source} WHERE {} != ? AND {}",
8050                quote_ident(&table.primary_key),
8051                index
8052                    .columns
8053                    .iter()
8054                    .map(|column| format!("{} = ?", quote_ident(column)))
8055                    .collect::<Vec<_>>()
8056                    .join(" AND ")
8057            );
8058            let mut statement = self
8059                .conn
8060                .prepare_cached(&sql)
8061                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8062            let records = statement
8063                .query_map(rusqlite::params_from_iter(params), |record| {
8064                    let values = table
8065                        .columns
8066                        .iter()
8067                        .enumerate()
8068                        .map(|(index, column)| {
8069                            let value = sql_ref_to_json(column, record.get_ref(index)?);
8070                            json_to_column_value(column, Some(&value)).map_err(|message| {
8071                                rusqlite::Error::ToSqlConversionFailure(message.into())
8072                            })
8073                        })
8074                        .collect::<Result<Row, _>>()?;
8075                    Ok((values, record.get::<_, i64>(table.columns.len())?))
8076                })
8077                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8078            for record in records {
8079                let (row, version) =
8080                    record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8081                let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
8082                conflicts.push(RetainedUniqueConflict {
8083                    index: index.name.clone(),
8084                    columns: index.columns.clone(),
8085                    row_id,
8086                    server_row: table
8087                        .columns
8088                        .iter()
8089                        .zip(&row)
8090                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
8091                        .collect(),
8092                    server_version: version,
8093                });
8094            }
8095        }
8096        Ok(conflicts)
8097    }
8098
8099    fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
8100        if let Some((commit, initial)) = self
8101            .failed_commits
8102            .iter()
8103            .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
8104        {
8105            let mut rows = Vec::new();
8106            for operation in &commit.ops {
8107                let table = self
8108                    .schema
8109                    .table(&operation.table)
8110                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
8111                let key =
8112                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
8113                        .map_err(|_| "sync.local_corrupt".to_owned())?;
8114                let base = self.stored_row(table, &operation.row_id, true);
8115                rows.push(RetainedCommitRow {
8116                    table: operation.table.clone(),
8117                    row_id: operation.row_id.clone(),
8118                    unique_conflicts: if operation.upsert {
8119                        self.unique_conflicts(
8120                            table,
8121                            initial
8122                                .get(&key)
8123                                .and_then(Value::as_object)
8124                                .or(operation.values.as_ref())
8125                                .ok_or("sync.local_corrupt")?,
8126                            true,
8127                        )?
8128                    } else {
8129                        Vec::new()
8130                    },
8131                    local_row: if operation.upsert {
8132                        initial
8133                            .get(&key)
8134                            .and_then(Value::as_object)
8135                            .cloned()
8136                            .or_else(|| operation.values.clone())
8137                    } else {
8138                        None
8139                    },
8140                    server_row: base.as_ref().map(|(values, _)| {
8141                        table
8142                            .columns
8143                            .iter()
8144                            .zip(values)
8145                            .map(|(column, value)| {
8146                                (column.name.clone(), column_value_to_json(value))
8147                            })
8148                            .collect()
8149                    }),
8150                    server_version: base.map(|(_, version)| version),
8151                });
8152            }
8153            outcome.retained_rows = Some(rows);
8154        }
8155        Ok(outcome)
8156    }
8157
8158    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
8159        let row = self
8160            .conn
8161            .query_row(
8162                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
8163                        resolution, resolved_at_ms, replacement_client_commit_id
8164                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
8165                rusqlite::params![client_commit_id],
8166                |row| {
8167                    Ok(StoredCommitOutcomeRow {
8168                        sequence: row.get(0)?,
8169                        client_commit_id: row.get(1)?,
8170                        status: row.get(2)?,
8171                        recorded_at_ms: row.get(3)?,
8172                        results_json: row.get(4)?,
8173                        operations_json: row.get(5)?,
8174                        resolution: row.get(6)?,
8175                        resolved_at_ms: row.get(7)?,
8176                        replacement_client_commit_id: row.get(8)?,
8177                    })
8178                },
8179            )
8180            .optional()
8181            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8182        row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
8183            .transpose()
8184    }
8185
8186    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
8187        if query.limit == Some(0) {
8188            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
8189        }
8190        let mut sql = String::from(
8191            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
8192                    resolution, resolved_at_ms, replacement_client_commit_id
8193               FROM _syncular_commit_outcomes",
8194        );
8195        if query.active_only {
8196            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
8197        }
8198        sql.push_str(" ORDER BY seq DESC");
8199        if let Some(limit) = query.limit {
8200            sql.push_str(&format!(" LIMIT {limit}"));
8201        }
8202        let mut stmt = self
8203            .conn
8204            .prepare(&sql)
8205            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8206        let rows = stmt
8207            .query_map([], |row| {
8208                Ok(StoredCommitOutcomeRow {
8209                    sequence: row.get(0)?,
8210                    client_commit_id: row.get(1)?,
8211                    status: row.get(2)?,
8212                    recorded_at_ms: row.get(3)?,
8213                    results_json: row.get(4)?,
8214                    operations_json: row.get(5)?,
8215                    resolution: row.get(6)?,
8216                    resolved_at_ms: row.get(7)?,
8217                    replacement_client_commit_id: row.get(8)?,
8218                })
8219            })
8220            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8221        let mut outcomes = Vec::new();
8222        for row in rows {
8223            outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
8224                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
8225            )?)?);
8226        }
8227        Ok(outcomes)
8228    }
8229
8230    fn prune_commit_outcomes(&self) -> Result<(), String> {
8231        #[cfg(test)]
8232        self.outcome_prune_count
8233            .set(self.outcome_prune_count.get() + 1);
8234        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
8235        let count = self
8236            .conn
8237            .query_row(
8238                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
8239                [],
8240                |row| row.get::<_, i64>(0),
8241            )
8242            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
8243            as usize;
8244        let excess = count.saturating_sub(max_entries);
8245        if excess == 0 {
8246            return Ok(());
8247        }
8248        let mut stmt = self
8249            .conn
8250            .prepare(
8251                "SELECT seq FROM _syncular_commit_outcomes
8252                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
8253                  ORDER BY seq ASC LIMIT ?1",
8254            )
8255            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8256        let rows = stmt
8257            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
8258            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8259        let sequences = rows
8260            .collect::<Result<Vec<_>, _>>()
8261            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8262        drop(stmt);
8263        for sequence in sequences {
8264            self.conn
8265                .execute(
8266                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
8267                    rusqlite::params![sequence],
8268                )
8269                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8270        }
8271        Ok(())
8272    }
8273
8274    // -- driver surface ---------------------------------------------------------
8275
8276    pub fn subscribe(
8277        &mut self,
8278        id: String,
8279        table: String,
8280        scopes: Vec<(String, Vec<String>)>,
8281        params: Option<String>,
8282    ) -> Result<(), String> {
8283        if self.schema.table(&table).is_none() {
8284            return Err(format!("unknown table {table:?}"));
8285        }
8286        if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
8287            let same_intent = existing.table == table
8288                && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
8289                && existing.params == params;
8290            if same_intent {
8291                return Ok(());
8292            }
8293            return Err(
8294                "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
8295                    .to_owned(),
8296            );
8297        }
8298        let sub = Subscription {
8299            id: id.clone(),
8300            table,
8301            requested: scopes,
8302            params,
8303            cursor: -1,
8304            bootstrap_state: None,
8305            state: SubState::Active,
8306            reason_code: None,
8307            effective: None,
8308            synced_once: false,
8309        };
8310        self.persist_sub(&sub)?;
8311        self.subs.push(sub);
8312        Ok(())
8313    }
8314
8315    pub fn unsubscribe(&mut self, id: &str) {
8316        if self.subs.iter().any(|s| s.id == id) {
8317            self.cancel_sync_round();
8318        }
8319        self.subs.retain(|s| s.id != id);
8320        let _ = self.conn.execute(
8321            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
8322            rusqlite::params![id],
8323        );
8324    }
8325
8326    // -- windowed subscriptions (§4.8) ------------------------------------------
8327
8328    /// §4.8: set the live window units for a base — a value-sharded family
8329    /// of subscriptions, one per unit. Added units get fresh subscriptions
8330    /// (image-lane bootstrap on the next sync); removed units are
8331    /// unsubscribed with their first eviction chunk in one transaction (E1–E4).
8332    /// Idempotent; re-entry cancels any deferred eviction.
8333    pub fn set_window(
8334        &mut self,
8335        base: &WindowBase,
8336        units: &[String],
8337    ) -> Result<CommandEffects, String> {
8338        let table = self
8339            .schema
8340            .table(&base.table)
8341            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
8342        if table.scope_column(&base.variable).is_none() {
8343            return Err(format!(
8344                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
8345                base.table, base.variable
8346            ));
8347        }
8348        let base_key = window_base_key(base);
8349        let wanted: std::collections::HashSet<&String> = units.iter().collect();
8350        let live = self.load_window_units(&base_key);
8351        self.begin_observation("syncular_window")?;
8352        let mut batch = ChangeAccumulator::default();
8353        let mut changed = false;
8354
8355        let prior_subs = self.subs.clone();
8356        let widened = (|| {
8357            // Widen: units wanted but not live → fresh subscription + registry row.
8358            for unit in units {
8359                if live.iter().any(|(u, _)| u == unit) {
8360                    continue;
8361                }
8362                let sub_id = derive_sub_id(base, unit);
8363                self.delete_pending_evict(&sub_id)?;
8364                self.insert_window_unit(&base_key, unit, &sub_id)?;
8365                self.subscribe(
8366                    sub_id,
8367                    base.table.clone(),
8368                    unit_scopes(base, unit),
8369                    base.params.clone(),
8370                )?;
8371                batch.window(&base_key, &base.table, unit);
8372                changed = true;
8373            }
8374
8375            self.finish_observation("syncular_window", batch)
8376        })();
8377        if let Err(error) = widened {
8378            self.rollback_observation("syncular_window");
8379            self.subs = prior_subs;
8380            return Err(error);
8381        }
8382        for (unit, sub_id) in live {
8383            if wanted.contains(&unit) {
8384                continue;
8385            }
8386            self.rebuild_overlay_if_dirty();
8387            let prior_subs = self.subs.clone();
8388            self.begin_observation("syncular_window")?;
8389            let effective = self
8390                .subs
8391                .iter()
8392                .find(|sub| sub.id == sub_id)
8393                .and_then(|sub| sub.effective.clone())
8394                .unwrap_or_else(|| unit_scopes(base, &unit));
8395            let mut batch = ChangeAccumulator::default();
8396            self.record_scope_map(&mut batch, &base.table, &effective);
8397            batch.window(&base_key, &base.table, &unit);
8398            let result = self.evict_unit(&base_key, base, &unit, &sub_id);
8399            let remaining = match result {
8400                Ok(remaining) => remaining,
8401                Err(error) => {
8402                    self.rollback_observation("syncular_window");
8403                    self.subs = prior_subs;
8404                    return Err(error);
8405                }
8406            };
8407            if let Err(error) = self.finish_observation("syncular_window", batch) {
8408                self.rollback_observation("syncular_window");
8409                self.subs = prior_subs;
8410                return Err(error);
8411            }
8412            if remaining {
8413                self.drain_pending_evictions()?;
8414            }
8415            changed = true;
8416        }
8417        Ok(if changed {
8418            CommandEffects::interactive()
8419        } else {
8420            CommandEffects::none()
8421        })
8422    }
8423
8424    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
8425    /// the subset still bootstrap-pending. Registration alone is not
8426    /// completeness — a unit is pending until its subscription completes a
8427    /// bootstrap round (cursor advances past -1 with no resume token held).
8428    pub fn window_state(&self, base: &WindowBase) -> WindowState {
8429        let mut units = Vec::new();
8430        let mut pending = Vec::new();
8431        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
8432            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
8433                Some(sub) => {
8434                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
8435                }
8436                None => true,
8437            };
8438            if is_pending {
8439                pending.push(unit.clone());
8440            }
8441            units.push(unit);
8442        }
8443        WindowState { units, pending }
8444    }
8445
8446    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
8447        let mut stmt = match self
8448            .conn
8449            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
8450        {
8451            Ok(stmt) => stmt,
8452            Err(_) => return Vec::new(),
8453        };
8454        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
8455            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8456        });
8457        match rows {
8458            Ok(rows) => rows.filter_map(Result::ok).collect(),
8459            Err(_) => Vec::new(),
8460        }
8461    }
8462
8463    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
8464        let mut stmt = match self.conn.prepare(
8465            "SELECT windows.base, windows.unit, subscriptions.tbl
8466               FROM _syncular_windows AS windows
8467               JOIN _syncular_subscriptions AS subscriptions
8468                 ON subscriptions.id = windows.sub_id
8469               ORDER BY windows.base, windows.unit",
8470        ) {
8471            Ok(stmt) => stmt,
8472            Err(_) => return Vec::new(),
8473        };
8474        let rows = stmt.query_map([], |row| {
8475            Ok((
8476                row.get::<_, String>(0)?,
8477                row.get::<_, String>(1)?,
8478                row.get::<_, String>(2)?,
8479            ))
8480        });
8481        match rows {
8482            Ok(rows) => rows.filter_map(Result::ok).collect(),
8483            Err(_) => Vec::new(),
8484        }
8485    }
8486
8487    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
8488        self.conn
8489            .query_row(
8490                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
8491                rusqlite::params![sub_id],
8492                |row| Ok((row.get(0)?, row.get(1)?)),
8493            )
8494            .ok()
8495    }
8496
8497    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
8498        self.conn
8499            .execute(
8500                "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
8501                rusqlite::params![base_key, unit, sub_id],
8502            )
8503            .map(|_| ())
8504            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8505    }
8506
8507    fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
8508        self.conn
8509            .execute(
8510                "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
8511                rusqlite::params![base_key, unit],
8512            )
8513            .map(|_| ())
8514            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8515    }
8516
8517    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
8518    /// Deletes the unit's rows except those pinned by a pending outbox
8519    /// commit (E1); records a deferred eviction if any pin remains; discards
8520    /// the subscription's cursor/resume/effective-echo (E3) and version
8521    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
8522    /// nothing.
8523    fn evict_unit(
8524        &mut self,
8525        base_key: &str,
8526        base: &WindowBase,
8527        unit: &str,
8528        sub_id: &str,
8529    ) -> Result<bool, String> {
8530        let effective = self
8531            .subs
8532            .iter()
8533            .find(|s| s.id == sub_id)
8534            .and_then(|s| s.effective.clone())
8535            .unwrap_or_else(|| unit_scopes(base, unit));
8536        let pinned = self.pinned_row_ids(&base.table);
8537        let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
8538        self.delete_window_unit(base_key, unit)?;
8539        self.conn
8540            .execute(
8541                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
8542                [sub_id],
8543            )
8544            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8545        self.subs.retain(|sub| sub.id != sub_id);
8546        if deferred {
8547            self.save_pending_evict(sub_id, &base.table, &effective)?;
8548        } else {
8549            self.delete_pending_evict(sub_id)?;
8550        }
8551        Ok(remaining)
8552    }
8553
8554    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
8555    /// primary keys. Returns (more unpinned work, any scoped rows remain).
8556    /// A missing scope-column mapping fails without deleting rows.
8557    fn evict_scope_rows(
8558        &self,
8559        table_name: &str,
8560        effective: &[(String, Vec<String>)],
8561        pinned: &std::collections::HashSet<String>,
8562    ) -> Result<(bool, bool), String> {
8563        if effective.is_empty() {
8564            return Ok((false, false));
8565        }
8566        let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
8567        let mut clauses = Vec::new();
8568        let mut params: Vec<SqlValue> = Vec::new();
8569        for (variable, values) in effective {
8570            let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
8571            if values.is_empty() {
8572                return Ok((false, false));
8573            }
8574            let holes = values
8575                .iter()
8576                .map(|v| {
8577                    params.push(SqlValue::Text(v.clone()));
8578                    "?"
8579                })
8580                .collect::<Vec<_>>()
8581                .join(", ");
8582            clauses.push(format!("{} IN ({holes})", quote_ident(column)));
8583        }
8584        let scope = clauses.join(" AND ");
8585        let scope_params = params.len();
8586        let pk = quote_ident(&table.primary_key);
8587        if !pinned.is_empty() {
8588            let holes = pinned
8589                .iter()
8590                .map(|id| {
8591                    params.push(SqlValue::Text(id.clone()));
8592                    "?"
8593                })
8594                .collect::<Vec<_>>()
8595                .join(", ");
8596            clauses.push(format!("{pk} NOT IN ({holes})"));
8597        }
8598        let predicate = clauses.join(" AND ");
8599        let base = base_table(table_name);
8600        let visible = visible_table(table_name);
8601        let mut ids: Vec<SqlValue> = Vec::new();
8602        for source in [&base, &visible] {
8603            let mut stmt = self
8604                .conn
8605                .prepare_cached(&format!(
8606                    "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
8607                ))
8608                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8609            ids = stmt
8610                .query_map(rusqlite::params_from_iter(&params), |row| row.get(0))
8611                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
8612                .collect::<Result<_, _>>()
8613                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8614            if !ids.is_empty() {
8615                break;
8616            }
8617        }
8618        if !ids.is_empty() {
8619            let holes = vec!["?"; ids.len()].join(", ");
8620            for source in [&base, &visible] {
8621                self.conn
8622                    .execute(
8623                        &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
8624                        rusqlite::params_from_iter(&ids),
8625                    )
8626                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8627            }
8628        }
8629        let mut remaining = false;
8630        let mut deferred = false;
8631        for source in [&base, &visible] {
8632            remaining |= self
8633                .conn
8634                .query_row(
8635                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
8636                    rusqlite::params_from_iter(&params),
8637                    |row| row.get::<_, bool>(0),
8638                )
8639                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8640            deferred |= self
8641                .conn
8642                .query_row(
8643                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
8644                    rusqlite::params_from_iter(&params[..scope_params]),
8645                    |row| row.get::<_, bool>(0),
8646                )
8647                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8648        }
8649        Ok((remaining, deferred))
8650    }
8651
8652    /// Resume committed eviction chunks, retaining outbox pins and their durable marker.
8653    fn drain_pending_evictions(&mut self) -> Result<(), String> {
8654        let pending = self.load_pending_evictions()?;
8655        if pending.is_empty() {
8656            return Ok(());
8657        }
8658        self.rebuild_overlay_if_dirty();
8659        for (sub_id, table_name, effective) in pending {
8660            if self.schema.table(&table_name).is_none() {
8661                self.delete_pending_evict(&sub_id)?;
8662                continue;
8663            }
8664            loop {
8665                std::thread::yield_now();
8666                self.begin_observation("syncular_evict")?;
8667                let pinned = self.pinned_row_ids(&table_name);
8668                let result = self.evict_scope_rows(&table_name, &effective, &pinned);
8669                let (remaining, deferred) = match result {
8670                    Ok(result) => result,
8671                    Err(error) => {
8672                        self.rollback_observation("syncular_evict");
8673                        return Err(error);
8674                    }
8675                };
8676                if !deferred {
8677                    if let Err(error) = self.delete_pending_evict(&sub_id) {
8678                        self.rollback_observation("syncular_evict");
8679                        return Err(error);
8680                    }
8681                }
8682                let mut batch = ChangeAccumulator::default();
8683                self.record_scope_map(&mut batch, &table_name, &effective);
8684                if let Err(error) = self.finish_observation("syncular_evict", batch) {
8685                    self.rollback_observation("syncular_evict");
8686                    return Err(error);
8687                }
8688                if !remaining {
8689                    break;
8690                }
8691            }
8692        }
8693        Ok(())
8694    }
8695
8696    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
8697    /// commit — rows that MUST NOT be evicted until the commit drains.
8698    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
8699        let mut pinned = std::collections::HashSet::new();
8700        for commit in self
8701            .outbox
8702            .iter()
8703            .chain(self.failed_commits.iter().map(|(commit, _)| commit))
8704        {
8705            for op in &commit.ops {
8706                if op.table == table {
8707                    pinned.insert(op.row_id.clone());
8708                }
8709            }
8710        }
8711        pinned
8712    }
8713
8714    fn save_pending_evict(
8715        &self,
8716        sub_id: &str,
8717        table: &str,
8718        effective: &[(String, Vec<String>)],
8719    ) -> Result<(), String> {
8720        self.conn.execute(
8721            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
8722            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
8723        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8724    }
8725
8726    fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
8727        self.conn
8728            .execute(
8729                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
8730                rusqlite::params![sub_id],
8731            )
8732            .map(|_| ())
8733            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8734    }
8735
8736    fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
8737        let mut stmt = self
8738            .conn
8739            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
8740            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8741        let rows = stmt
8742            .query_map([], |row| {
8743                Ok((
8744                    row.get::<_, String>(0)?,
8745                    row.get::<_, String>(1)?,
8746                    row.get::<_, String>(2)?,
8747                ))
8748            })
8749            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8750        rows.map(|row| {
8751            let (sub_id, table, json) =
8752                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8753            let value: Value = serde_json::from_str(&json)
8754                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
8755            let effective = json_to_scope_map(&value)
8756                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
8757            Ok((sub_id, table, effective))
8758        })
8759        .collect()
8760    }
8761
8762    /// Select failed-intent retention; reload durable retained aggregates.
8763    pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
8764        self.retain_failed_commits = enabled;
8765        for (commit, _) in &self.failed_commits {
8766            for operation in &commit.ops {
8767                self.overlay_dirty.table(&operation.table);
8768            }
8769        }
8770        self.failed_commits = self.load_failed_commits()?;
8771        for (commit, _) in &self.failed_commits {
8772            for operation in &commit.ops {
8773                self.overlay_dirty.table(&operation.table);
8774            }
8775        }
8776        Ok(())
8777    }
8778
8779    fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
8780        let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
8781            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8782        let rows = statement
8783            .query_map([], |row| {
8784                Ok((
8785                    row.get::<_, String>(0)?,
8786                    row.get::<_, String>(1)?,
8787                    row.get::<_, String>(2)?,
8788                ))
8789            })
8790            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8791        rows.map(|row| {
8792            let (id, operations, initial) =
8793                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8794            let operations: Vec<CommitOperation> =
8795                serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
8796            let initial =
8797                serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
8798            Ok((
8799                OutboxCommit {
8800                    client_commit_id: id,
8801                    ops: operations
8802                        .into_iter()
8803                        .map(|op| OutboxOp {
8804                            upsert: op.op == "upsert",
8805                            table: op.table,
8806                            row_id: op.row_id,
8807                            base_version: op.base_version,
8808                            values: op.values,
8809                        })
8810                        .collect(),
8811                },
8812                initial,
8813            ))
8814        })
8815        .collect()
8816    }
8817
8818    fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
8819        let mut initial = Map::new();
8820        for operation in operations {
8821            let table = self
8822                .schema
8823                .table(&operation.table)
8824                .ok_or_else(|| "sync.unknown_table".to_owned())?;
8825            if let Some((values, _)) = self
8826                .stored_row(table, &operation.row_id, true)
8827                .or_else(|| self.visible_row(table, &operation.row_id))
8828            {
8829                let mut intended: Map<String, Value> = table
8830                    .columns
8831                    .iter()
8832                    .zip(values.iter())
8833                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
8834                    .collect();
8835                if let Some(patch) = &operation.values {
8836                    intended.extend(patch.clone());
8837                }
8838                initial.insert(
8839                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
8840                        .map_err(|_| "sync.local_corrupt".to_owned())?,
8841                    Value::Object(intended),
8842                );
8843            }
8844        }
8845        self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
8846            serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
8847            serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
8848            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8849        self.failed_commits.push((
8850            OutboxCommit {
8851                client_commit_id: id.to_owned(),
8852                ops: operations.to_vec(),
8853            },
8854            initial,
8855        ));
8856        Ok(())
8857    }
8858
8859    fn drop_failed_commits(
8860        &mut self,
8861        matches: impl Fn(&str, &Map<String, Value>) -> bool,
8862    ) -> Result<Vec<OutboxCommit>, String> {
8863        let mut doomed = Vec::new();
8864        for (commit, initial) in &self.failed_commits {
8865            for operation in &commit.ops {
8866                let key =
8867                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
8868                        .map_err(|_| "sync.local_corrupt".to_owned())?;
8869                if initial
8870                    .get(&key)
8871                    .and_then(Value::as_object)
8872                    .is_some_and(|values| matches(&operation.table, values))
8873                {
8874                    doomed.push(commit.clone());
8875                    break;
8876                }
8877            }
8878        }
8879        for commit in &doomed {
8880            self.conn
8881                .execute(
8882                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
8883                    rusqlite::params![commit.client_commit_id],
8884                )
8885                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8886        }
8887        for commit in &doomed {
8888            self.conn.execute("UPDATE _syncular_commit_outcomes SET operations_json = NULL, results_json = '[]' WHERE client_commit_id = ?1", rusqlite::params![commit.client_commit_id]).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8889        }
8890        self.conflicts.retain(|record| {
8891            !doomed
8892                .iter()
8893                .any(|commit| commit.client_commit_id == record.client_commit_id)
8894        });
8895        self.rejections.retain(|record| {
8896            !doomed
8897                .iter()
8898                .any(|commit| commit.client_commit_id == record.client_commit_id)
8899        });
8900        self.failed_commits.retain(|(commit, _)| {
8901            !doomed
8902                .iter()
8903                .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
8904        });
8905        for commit in &doomed {
8906            for operation in &commit.ops {
8907                self.overlay_dirty.table(&operation.table);
8908            }
8909        }
8910        Ok(doomed)
8911    }
8912
8913    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
8914        if mutations.is_empty() {
8915            return Err("a commit must contain at least one operation (§6.1)".to_owned());
8916        }
8917        let mut ops = Vec::with_capacity(mutations.len());
8918        for mutation in mutations {
8919            match mutation {
8920                Mutation::Patch {
8921                    table,
8922                    values,
8923                    base_version,
8924                } => {
8925                    let schema_table = self
8926                        .schema
8927                        .table(&table)
8928                        .ok_or_else(|| "sync.unknown_table".to_owned())?;
8929                    let values = normalize_values_casing(schema_table, values)?;
8930                    let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
8931                    ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
8932                }
8933                Mutation::Upsert {
8934                    table,
8935                    values,
8936                    base_version,
8937                } => {
8938                    let schema_table = self
8939                        .schema
8940                        .table(&table)
8941                        .ok_or_else(|| format!("unknown table {table:?}"))?;
8942                    // §5: value keys are accepted in snake_case AND the
8943                    // generated row types' camelCase; normalize to SQL truth
8944                    // before the pk lookup / codec see them.
8945                    let values = normalize_values_casing(schema_table, values)?;
8946                    let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
8947                    // §6.7 `mutate` marks every column present: fill missing
8948                    // nullable columns with NULL before the payload is built.
8949                    let values = full_row_values(schema_table, values)?;
8950                    // §5.11: validate the payload encodes with the current
8951                    // codec. A key-selection or unknown-key failure is a
8952                    // durable rejection at the push seam (§10.3), never an
8953                    // author-time error. Full-row `mutate` presents every
8954                    // column, so no stored fallback is needed.
8955                    self.validate_author_encode(schema_table, &row_id, &values)?;
8956                    ops.push(OutboxOp {
8957                        upsert: true,
8958                        table,
8959                        row_id,
8960                        base_version,
8961                        values: Some(values),
8962                    });
8963                }
8964                Mutation::Delete {
8965                    table,
8966                    row_id,
8967                    base_version,
8968                } => {
8969                    if self.schema.table(&table).is_none() {
8970                        return Err(format!("unknown table {table:?}"));
8971                    }
8972                    ops.push(OutboxOp {
8973                        upsert: false,
8974                        table,
8975                        row_id,
8976                        base_version,
8977                        values: None,
8978                    });
8979                }
8980            }
8981        }
8982        self.record_outbox_commit(ops)
8983    }
8984
8985    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
8986        let commit = OutboxCommit {
8987            client_commit_id: uuid::Uuid::new_v4().to_string(),
8988            ops,
8989        };
8990        self.begin_observation("syncular_mutation")?;
8991        let mut batch = ChangeAccumulator::default();
8992        for op in &commit.ops {
8993            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
8994            if let Some(values) = &op.values {
8995                if let Some(table) = self.schema.table(&op.table) {
8996                    for scope in &table.scope_variables {
8997                        if let Some(Value::String(value)) = values.get(&scope.column) {
8998                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
8999                            precise = true;
9000                        }
9001                    }
9002                }
9003            }
9004            if !precise {
9005                batch.table(&op.table);
9006            }
9007        }
9008        if let Err(error) = self.persist_outbox_insert(&commit) {
9009            self.rollback_observation("syncular_mutation");
9010            return Err(error);
9011        }
9012        let was_dirty = self.overlay_dirty.snapshot();
9013        // A clean overlay contains the FIFO fold of every previous commit, so
9014        // the new commit applies over it. §7.1: an operation that does not
9015        // apply (a secondary unique collision) fails the whole commit; the
9016        // savepoint rollback removes the outbox entry and every visible write.
9017        if self.overlay_dirty.get() {
9018            self.rebuild_overlay();
9019        }
9020        if let Some(error) = commit
9021            .ops
9022            .iter()
9023            .find_map(|op| self.apply_outbox_op(op).err())
9024        {
9025            self.rollback_observation("syncular_mutation");
9026            self.overlay_dirty.restore(was_dirty.clone());
9027            return Err(error);
9028        }
9029        let id = commit.client_commit_id.clone();
9030        self.outbox.push(commit);
9031        batch.status = true;
9032        if let Err(error) = self.finish_observation("syncular_mutation", batch) {
9033            self.rollback_observation("syncular_mutation");
9034            self.outbox.pop();
9035            self.overlay_dirty.restore(was_dirty.clone());
9036            return Err(error);
9037        }
9038        Ok(id)
9039    }
9040
9041    /// Record one §6.1 sparse upsert: the primary key plus the supplied
9042    /// non-scope columns are present; every other column is absent and stays
9043    /// untouched on the server and in the local overlay. An absent local base
9044    /// refuses the whole batch with sync.row_missing before enqueueing (§7.1).
9045    pub fn patch(
9046        &mut self,
9047        table: &str,
9048        row_id: &str,
9049        partial: Map<String, Value>,
9050        base_version: Option<i64>,
9051    ) -> Result<String, String> {
9052        let operation = self.prepare_patch(table, row_id, partial, base_version)?;
9053        self.record_outbox_commit(vec![operation])
9054    }
9055
9056    fn prepare_patch(
9057        &self,
9058        table: &str,
9059        row_id: &str,
9060        partial: Map<String, Value>,
9061        base_version: Option<i64>,
9062    ) -> Result<OutboxOp, String> {
9063        let schema_table = self
9064            .schema
9065            .table(table)
9066            .ok_or_else(|| format!("unknown table {table:?}"))?;
9067        let partial = normalize_values_casing(schema_table, partial)?;
9068        if let Some(pk) = partial.get(&schema_table.primary_key) {
9069            if pk.as_str() != Some(row_id) {
9070                return Err(format!(
9071                    "sync.invalid_request: table {table:?}: patch cannot change the primary key"
9072                ));
9073            }
9074        }
9075        // Existence reads only the key. Locked encrypted values stay opaque;
9076        // decoding the complete row here would reject a valid plain patch.
9077        let exists: bool = self
9078            .conn
9079            .query_row(
9080                &format!(
9081                    "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
9082                    visible_table(table),
9083                    row_id_predicate(schema_table)
9084                ),
9085                rusqlite::params![row_id],
9086                |row| row.get(0),
9087            )
9088            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9089        if !exists {
9090            return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
9091        }
9092        // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
9093        // server accepts a present scope column whose value equals the
9094        // stored row and applies it as a no-op, so a decoded envelope
9095        // round-trips. The local row is the only value the client can prove
9096        // equality against; when the row is absent locally the client cannot
9097        // prove equality against the server's stored row and fails closed.
9098        // A primary key that is also a scope column needs no stored-row
9099        // read: the primary key in a sparse payload is by construction the
9100        // row id being patched (§6.1), so its value is proven equal already.
9101        for scope in &schema_table.scope_variables {
9102            if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
9103                continue;
9104            }
9105            let Some(column) = schema_table
9106                .columns
9107                .iter()
9108                .find(|column| column.name == scope.column)
9109            else {
9110                continue;
9111            };
9112            let stored = self
9113                .conn
9114                .query_row(
9115                    &format!(
9116                        "SELECT {} FROM {} WHERE {}",
9117                        quote_ident(&scope.column),
9118                        visible_table(&schema_table.name),
9119                        row_id_predicate(schema_table)
9120                    ),
9121                    rusqlite::params![row_id],
9122                    |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
9123                )
9124                .ok();
9125            // Decode the supplied value through the same column-type path the
9126            // rest of the patch encoding uses, so a value that only equals
9127            // the stored one in another number representation still compares
9128            // equal.
9129            let incoming = json_to_column_value(column, partial.get(&scope.column))
9130                .ok()
9131                .map(|value| column_value_to_json(&value));
9132            // A stored blob never equals a patch value, matching the server's
9133            // scope-column comparison (§3.4): bytes are unstorable as scope
9134            // values.
9135            let equal = stored.as_ref().is_some_and(|stored| {
9136                stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
9137            });
9138            if !equal {
9139                return Err(format!(
9140                    "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
9141                    scope.column
9142                ));
9143            }
9144        }
9145        let mut values = partial;
9146        // Presence-set semantics: a proven-equal scope column is dropped, so
9147        // the patch leaves the stored value untouched (§6.2).
9148        for scope in &schema_table.scope_variables {
9149            values.remove(&scope.column);
9150        }
9151        values.insert(
9152            schema_table.primary_key.clone(),
9153            Value::from(row_id.to_owned()),
9154        );
9155        // §5.11: structural validation only; an absent key-id selector is
9156        // resolved at the push seam from the stored local row, and a
9157        // key-selection or unknown-key failure becomes a durable
9158        // `client.encrypt_failed` rejection there (§10.3), never an
9159        // author-time error.
9160        self.validate_author_encode(schema_table, row_id, &values)?;
9161        Ok(OutboxOp {
9162            upsert: true,
9163            table: table.to_owned(),
9164            row_id: row_id.to_owned(),
9165            base_version,
9166            values: Some(values),
9167        })
9168    }
9169
9170    /// §5.11 key-id fallback: the stored local row's selector value as a
9171    /// positional row (only the selector slot filled). Returns None when no
9172    /// selector is configured, the row is locally absent, or the stored
9173    /// value is not a usable key id.
9174    fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
9175        let selector = self.encryption.key_id_columns.get(&table.name)?;
9176        let index = table
9177            .columns
9178            .iter()
9179            .position(|column| &column.name == selector)?;
9180        let sql = format!(
9181            "SELECT {} FROM {} WHERE {}",
9182            quote_ident(selector),
9183            visible_table(&table.name),
9184            row_id_predicate(table)
9185        );
9186        let key: Option<String> = self
9187            .conn
9188            .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
9189            .ok()?;
9190        let key = key.filter(|key| !key.is_empty())?;
9191        let mut fallback: Row = vec![None; table.columns.len()];
9192        fallback[index] = Some(ColumnValue::String(key));
9193        Some(fallback)
9194    }
9195
9196    /// §5.11/§10.3 author-time encode check: a structural failure (a
9197    /// non-nullable NULL, a value the codec rejects) is an author-time
9198    /// error; a key-selection or unknown-key failure is deferred to the push
9199    /// seam, where it becomes a durable `client.encrypt_failed` rejection.
9200    fn validate_author_encode(
9201        &self,
9202        table: &TableSchema,
9203        row_id: &str,
9204        values: &Map<String, Value>,
9205    ) -> Result<(), String> {
9206        match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
9207            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
9208            result => result.map(|_| ()),
9209        }
9210    }
9211
9212    /// §5.11/§10.3: drop every pending commit whose push encode fails with
9213    /// `client.encrypt_failed` (an unresolvable key id or an unknown key) and
9214    /// raise one durable rejection per commit. `sync()` never aborts for an
9215    /// encode failure. Mirrors §7.4.4's `drop_incompatible_outbox`, including
9216    /// undoing the dropped commit's purely-optimistic rows.
9217    fn drop_unencodable_outbox(&mut self) -> Result<bool, String> {
9218        if !self
9219            .schema
9220            .tables
9221            .iter()
9222            .any(|table| table.has_encrypted_columns())
9223        {
9224            return Ok(false);
9225        }
9226        let failures = self
9227            .outbox
9228            .iter()
9229            .filter_map(|commit| {
9230                commit
9231                    .ops
9232                    .iter()
9233                    .find_map(|op| {
9234                        let values = op.values.as_ref().filter(|_| op.upsert)?;
9235                        let table = self.schema.table(&op.table)?;
9236                        let fallback = self.stored_key_fallback(table, &op.row_id);
9237                        match encode_sparse_row_json(
9238                            table,
9239                            &op.row_id,
9240                            values,
9241                            &self.encryption,
9242                            fallback.as_ref(),
9243                        ) {
9244                            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Some(error),
9245                            _ => None,
9246                        }
9247                    })
9248                    .map(|error| (commit.clone(), error))
9249            })
9250            .collect::<Vec<_>>();
9251        if failures.is_empty() {
9252            return Ok(false);
9253        }
9254        for (commit, message) in failures {
9255            if self.retain_failed_commits {
9256                self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
9257            }
9258            let rejection = RejectionRecord {
9259                client_commit_id: commit.client_commit_id.clone(),
9260                op_index: 0,
9261                code: ENCRYPT_FAILED_CODE.to_owned(),
9262                message,
9263                retryable: false,
9264                details: None,
9265                operation: commit.ops.first().map(CommitOperation::from),
9266            };
9267            self.persist_commit_outcome(
9268                &commit.client_commit_id,
9269                CommitOutcomeStatus::Rejected,
9270                &[CommitOperationOutcome::Error {
9271                    rejection: rejection.clone(),
9272                }],
9273                Some(&commit.ops),
9274            )?;
9275            self.delete_outbox_persisted(&commit.client_commit_id)?;
9276            self.outbox
9277                .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
9278            self.rejections.push(rejection);
9279        }
9280        self.prune_commit_outcomes()?;
9281        self.rebuild_overlay_if_dirty();
9282        Ok(true)
9283    }
9284
9285    pub fn pending_commit_ids(&self) -> Vec<String> {
9286        self.outbox
9287            .iter()
9288            .map(|c| c.client_commit_id.clone())
9289            .collect()
9290    }
9291
9292    /// §6.1: the sparse push payload bytes of every pending upsert operation,
9293    /// FIFO — exactly what the next push round would put on the wire. Used by
9294    /// conformance B.21(h) to pin cross-core byte identity.
9295    pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
9296        let mut payloads = Vec::new();
9297        for commit in &self.outbox {
9298            for op in &commit.ops {
9299                let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
9300                    continue;
9301                };
9302                let Some(table) = self.schema.table(&op.table) else {
9303                    continue;
9304                };
9305                let fallback = self.stored_key_fallback(table, &op.row_id);
9306                if let Ok(payload) = encode_sparse_row_json(
9307                    table,
9308                    &op.row_id,
9309                    values,
9310                    &self.encryption,
9311                    fallback.as_ref(),
9312                ) {
9313                    payloads.push(payload);
9314                }
9315            }
9316        }
9317        payloads
9318    }
9319
9320    pub fn conflicts(&self) -> &[ConflictRecord] {
9321        &self.conflicts
9322    }
9323
9324    pub fn rejections(&self) -> &[RejectionRecord] {
9325        &self.rejections
9326    }
9327
9328    pub fn resolve_commit_outcome(
9329        &mut self,
9330        input: ResolveCommitOutcomeInput,
9331    ) -> Result<CommitOutcome, String> {
9332        let current = self
9333            .commit_outcome(&input.client_commit_id)?
9334            .ok_or_else(|| {
9335                format!(
9336                    "sync.outcome_not_found: no durable outcome exists for {:?}",
9337                    input.client_commit_id
9338                )
9339            })?;
9340        if current.resolution != CommitOutcomeResolution::Active {
9341            return Ok(current);
9342        }
9343        if input.resolution == CommitOutcomeResolution::Active {
9344            return Err("sync.invalid_request: resolution must leave active state".to_owned());
9345        }
9346        match input.resolution {
9347            CommitOutcomeResolution::Superseded => {
9348                let replacement = input
9349                    .replacement_client_commit_id
9350                    .as_deref()
9351                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
9352                    .ok_or_else(|| {
9353                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
9354                            .to_owned()
9355                    })?;
9356                let _ = replacement;
9357            }
9358            _ if input.replacement_client_commit_id.is_some() => {
9359                return Err(
9360                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
9361                        .to_owned(),
9362                );
9363            }
9364            _ => {}
9365        }
9366        let allowed = match current.status {
9367            CommitOutcomeStatus::Conflict => matches!(
9368                input.resolution,
9369                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
9370            ),
9371            CommitOutcomeStatus::Rejected => {
9372                matches!(
9373                    input.resolution,
9374                    CommitOutcomeResolution::Superseded
9375                        | CommitOutcomeResolution::ResolvedKeepServer
9376                )
9377            }
9378            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
9379                input.resolution == CommitOutcomeResolution::Dismissed
9380            }
9381        };
9382        if !allowed {
9383            return Err(format!(
9384                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
9385                input.resolution, current.status
9386            ));
9387        }
9388
9389        self.begin_observation("syncular_outcome_resolution")?;
9390        let previous_failed = self.failed_commits.clone();
9391        let previous_conflicts = self.conflicts.clone();
9392        let previous_rejections = self.rejections.clone();
9393        let result = (|| {
9394            self.conn
9395                .execute(
9396                    "UPDATE _syncular_commit_outcomes
9397                        SET resolution = ?1, resolved_at_ms = ?2,
9398                            replacement_client_commit_id = ?3
9399                      WHERE client_commit_id = ?4 AND resolution = 'active'",
9400                    rusqlite::params![
9401                        Self::outcome_resolution_name(input.resolution),
9402                        self.clock_now_ms(),
9403                        input.replacement_client_commit_id,
9404                        input.client_commit_id
9405                    ],
9406                )
9407                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9408            let resolved = self
9409                .commit_outcome(&current.client_commit_id)?
9410                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
9411            self.conn
9412                .execute(
9413                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
9414                    rusqlite::params![input.client_commit_id],
9415                )
9416                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9417            for (commit, _) in &self.failed_commits {
9418                if commit.client_commit_id == input.client_commit_id {
9419                    for operation in &commit.ops {
9420                        self.overlay_dirty.table(&operation.table);
9421                    }
9422                }
9423            }
9424            self.failed_commits
9425                .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
9426            self.prune_commit_outcomes()?;
9427            Ok(resolved)
9428        })();
9429        let resolved = match result {
9430            Ok(outcome) => outcome,
9431            Err(error) => {
9432                self.rollback_observation("syncular_outcome_resolution");
9433                self.failed_commits = previous_failed;
9434                self.conflicts = previous_conflicts;
9435                self.rejections = previous_rejections;
9436                return Err(error);
9437            }
9438        };
9439        self.conflicts
9440            .retain(|record| record.client_commit_id != current.client_commit_id);
9441        self.rejections
9442            .retain(|record| record.client_commit_id != current.client_commit_id);
9443        let mut batch = ChangeAccumulator {
9444            conflicts: current.status == CommitOutcomeStatus::Conflict,
9445            rejections: current.status == CommitOutcomeStatus::Rejected,
9446            outcomes: true,
9447            ..ChangeAccumulator::default()
9448        };
9449        if let Some(operations) = &current.operations {
9450            for operation in operations {
9451                batch.table(&operation.table);
9452            }
9453        }
9454        self.rebuild_overlay_if_dirty();
9455        if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
9456            self.rollback_observation("syncular_outcome_resolution");
9457            self.failed_commits = previous_failed;
9458            self.conflicts = previous_conflicts;
9459            self.rejections = previous_rejections;
9460            return Err(error);
9461        }
9462        Ok(resolved)
9463    }
9464
9465    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
9466        self.schema_floor.as_ref()
9467    }
9468
9469    /// §7.3.5: the client's opaque auth-lease state, if any.
9470    pub fn lease_state(&self) -> Option<&LeaseState> {
9471        self.lease_state.as_ref()
9472    }
9473
9474    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
9475    /// the two lease codes set it; other errors leave leaseState untouched.
9476    fn record_lease_error(&mut self, code: &str) {
9477        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
9478            return;
9479        }
9480        let mut next = self.lease_state.clone().unwrap_or_default();
9481        next.error_code = Some(code.to_owned());
9482        self.set_lease_state(Some(next));
9483    }
9484
9485    fn set_lease_state(&mut self, next: Option<LeaseState>) {
9486        if self.lease_state == next {
9487            return;
9488        }
9489        if self.begin_observation("syncular_lease").is_err() {
9490            return;
9491        }
9492        self.lease_state = next;
9493        if let Some(lease) = &self.lease_state {
9494            if let Ok(json) = serde_json::to_string(lease) {
9495                self.set_meta(LEASE_STATE_KEY, &json);
9496            }
9497        } else {
9498            self.delete_meta(LEASE_STATE_KEY);
9499        }
9500        let batch = ChangeAccumulator {
9501            status: true,
9502            ..ChangeAccumulator::default()
9503        };
9504        if self.finish_observation("syncular_lease", batch).is_err() {
9505            self.rollback_observation("syncular_lease");
9506        }
9507    }
9508
9509    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
9510        if self.schema_floor == next {
9511            return;
9512        }
9513        if self.begin_observation("syncular_schema_floor").is_err() {
9514            return;
9515        }
9516        self.schema_floor = next;
9517        self.stopped = self.schema_floor.is_some();
9518        if let Some(floor) = &self.schema_floor {
9519            if let Ok(json) = serde_json::to_string(floor) {
9520                self.set_meta(SCHEMA_FLOOR_KEY, &json);
9521            }
9522        } else {
9523            self.delete_meta(SCHEMA_FLOOR_KEY);
9524        }
9525        let batch = ChangeAccumulator {
9526            status: true,
9527            ..ChangeAccumulator::default()
9528        };
9529        if self
9530            .finish_observation("syncular_schema_floor", batch)
9531            .is_err()
9532        {
9533            self.rollback_observation("syncular_schema_floor");
9534        }
9535    }
9536
9537    fn set_upgrading(&mut self, value: bool) {
9538        if self.upgrading == value {
9539            return;
9540        }
9541        if self.begin_observation("syncular_upgrading").is_err() {
9542            return;
9543        }
9544        self.upgrading = value;
9545        let batch = ChangeAccumulator {
9546            status: true,
9547            ..ChangeAccumulator::default()
9548        };
9549        if self
9550            .finish_observation("syncular_upgrading", batch)
9551            .is_err()
9552        {
9553            self.rollback_observation("syncular_upgrading");
9554        }
9555    }
9556
9557    pub fn sync_needed(&self) -> bool {
9558        self.sync_needed
9559    }
9560
9561    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
9562        let sub = self.subs.iter().find(|s| s.id == id)?;
9563        Some(SubscriptionStateView {
9564            id: sub.id.clone(),
9565            table: sub.table.clone(),
9566            status: sub.state.name().to_owned(),
9567            cursor: sub.cursor,
9568            has_resume_token: sub.bootstrap_state.is_some(),
9569            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
9570            reason_code: sub.reason_code.clone(),
9571        })
9572    }
9573
9574    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
9575        let schema_table = self
9576            .schema
9577            .table(table)
9578            .ok_or_else(|| format!("unknown table {table:?}"))?;
9579        let sql = format!(
9580            "SELECT * FROM {} ORDER BY {} ASC",
9581            visible_table(table),
9582            quote_ident(&schema_table.primary_key)
9583        );
9584        let mut stmt = self
9585            .conn
9586            .prepare(&sql)
9587            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9588        let mut rows = stmt
9589            .query([])
9590            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9591        let mut out = Vec::new();
9592        while let Some(row) = rows
9593            .next()
9594            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
9595        {
9596            let mut values = Map::new();
9597            for (i, column) in schema_table.columns.iter().enumerate() {
9598                let value = row
9599                    .get_ref(i)
9600                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9601                values.insert(column.name.clone(), sql_ref_to_json(column, value));
9602            }
9603            let version: i64 = row
9604                .get(schema_table.columns.len())
9605                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9606            let row_id = match values.get(&schema_table.primary_key) {
9607                Some(Value::String(s)) => s.clone(),
9608                Some(Value::Number(n)) => n.to_string(),
9609                Some(Value::Bool(b)) => b.to_string(),
9610                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
9611            };
9612            out.push(RowState {
9613                row_id,
9614                version,
9615                values,
9616            });
9617        }
9618        Ok(out)
9619    }
9620
9621    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
9622    //
9623    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
9624    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
9625    // the op with `yrs`, re-encodes the whole doc state, and records a
9626    // baseVersion-less sparse upsert carrying only the primary key and the
9627    // crdt column (§5.10.3, §6.6). No local merge — merging is server-side;
9628    // the overlay re-materializes the edit immediately (optimistic apply,
9629    // §7.1) and the server-merged bytes arrive on the next pull, idempotently.
9630    // Byte-compatible with `@syncular/crdt-yjs`.
9631
9632    /// The current stored value of a `crdt` column for one row — the visible
9633    /// (optimistic) bytes, or `None` when the row is absent or the column is
9634    /// NULL (the empty document, §5.10.1). Errors if the column is not a
9635    /// `crdt` column (guards the app against a typo'd column name).
9636    #[cfg(feature = "crdt-yjs")]
9637    fn crdt_column_bytes(
9638        &self,
9639        table: &str,
9640        row_id: &str,
9641        column: &str,
9642    ) -> Result<Option<Vec<u8>>, String> {
9643        let schema_table = self
9644            .schema
9645            .table(table)
9646            .ok_or_else(|| format!("unknown table {table:?}"))?;
9647        let col = schema_table
9648            .columns
9649            .iter()
9650            .find(|c| c.name == column)
9651            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
9652        if col.ty != ColumnType::Crdt {
9653            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
9654        }
9655        let sql = format!(
9656            "SELECT {} FROM {} WHERE {}",
9657            quote_ident(column),
9658            visible_table(table),
9659            row_id_predicate(schema_table)
9660        );
9661        let bytes: Option<Vec<u8>> = self
9662            .conn
9663            .prepare_cached(&sql)
9664            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
9665            .query_row(rusqlite::params![row_id], |row| {
9666                row.get::<_, Option<Vec<u8>>>(0)
9667            })
9668            .map_err(|e| match e {
9669                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
9670                other => other.to_string(),
9671            })?;
9672        Ok(bytes)
9673    }
9674
9675    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
9676    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
9677    /// An absent row / NULL column is the empty document (empty string).
9678    #[cfg(feature = "crdt-yjs")]
9679    pub fn crdt_text(
9680        &self,
9681        table: &str,
9682        row_id: &str,
9683        column: &str,
9684        name: &str,
9685    ) -> Result<String, String> {
9686        let bytes = self
9687            .crdt_column_bytes(table, row_id, column)?
9688            .unwrap_or_default();
9689        crate::crdt::text(&bytes, name)
9690    }
9691
9692    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and
9693    /// record the resulting crdt-only sparse operation. Returns the enqueued
9694    /// `clientCommitId`.
9695    #[cfg(feature = "crdt-yjs")]
9696    pub fn crdt_insert_text(
9697        &mut self,
9698        table: &str,
9699        row_id: &str,
9700        column: &str,
9701        name: &str,
9702        index: u32,
9703        value: &str,
9704    ) -> Result<String, String> {
9705        let current = self
9706            .crdt_column_bytes(table, row_id, column)?
9707            .unwrap_or_default();
9708        let update = crate::crdt::insert_text(&current, name, index, value)?;
9709        self.crdt_push_update(table, row_id, column, &update)
9710    }
9711
9712    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and
9713    /// record the resulting crdt-only sparse operation. Returns the enqueued
9714    /// `clientCommitId`.
9715    #[cfg(feature = "crdt-yjs")]
9716    pub fn crdt_delete_text(
9717        &mut self,
9718        table: &str,
9719        row_id: &str,
9720        column: &str,
9721        name: &str,
9722        index: u32,
9723        len: u32,
9724    ) -> Result<String, String> {
9725        let current = self
9726            .crdt_column_bytes(table, row_id, column)?
9727            .unwrap_or_default();
9728        let update = crate::crdt::delete_text(&current, name, index, len)?;
9729        self.crdt_push_update(table, row_id, column, &update)
9730    }
9731
9732    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
9733    /// `crdt` column's current state and record the resulting crdt-only sparse
9734    /// operation. The app authored the update with its own `yrs` model.
9735    /// Returns the enqueued `clientCommitId`.
9736    #[cfg(feature = "crdt-yjs")]
9737    pub fn crdt_apply_update(
9738        &mut self,
9739        table: &str,
9740        row_id: &str,
9741        column: &str,
9742        update: &[u8],
9743    ) -> Result<String, String> {
9744        let current = self
9745            .crdt_column_bytes(table, row_id, column)?
9746            .unwrap_or_default();
9747        let next = crate::crdt::apply_update(&current, update)?;
9748        self.crdt_push_update(table, row_id, column, &next)
9749    }
9750
9751    /// Shared tail of the crdt edit methods: record a §6.1 sparse crdt-only
9752    /// upsert — the primary key plus the crdt column, no `baseVersion` — and
9753    /// enqueue it. The server merges the crdt column and leaves every other
9754    /// column untouched (§5.10.3), so a baseVersion-less crdt edit cannot
9755    /// clobber a concurrent edit to another column. A locally absent row
9756    /// records the same partial operation; the server answers per §6.2.
9757    #[cfg(feature = "crdt-yjs")]
9758    fn crdt_push_update(
9759        &mut self,
9760        table: &str,
9761        row_id: &str,
9762        column: &str,
9763        crdt_bytes: &[u8],
9764    ) -> Result<String, String> {
9765        let mut bytes_obj = Map::new();
9766        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
9767        let mut partial = Map::new();
9768        partial.insert(column.to_owned(), Value::Object(bytes_obj));
9769        self.patch(table, row_id, partial, None)
9770    }
9771
9772    /// Run an arbitrary read-only SQL query against the local database and
9773    /// return each row as a `column-name → JSON value` map. This is the seam
9774    /// the React `useSyncQuery` live-query API needs (it takes app-authored
9775    /// SQL over the visible tables/views, not a fixed table read like
9776    /// [`read_rows`]).
9777    ///
9778    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
9779    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
9780    /// same envelope the command surface uses everywhere else. Output BLOB
9781    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
9782    ///
9783    /// The result column typing is dynamic (SQLite's stored affinity), because
9784    /// arbitrary SQL can alias, join, and compute — there is no schema column
9785    /// to consult per output cell, unlike [`read_rows`].
9786    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
9787        query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
9788    }
9789
9790    /// Repository benchmark and conformance access, present only with `bench-internals`.
9791    #[cfg(feature = "bench-internals")]
9792    #[doc(hidden)]
9793    pub fn benchmark_connection(&mut self) -> &mut Connection {
9794        &mut self.conn
9795    }
9796
9797    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
9798    #[cfg(feature = "bench-internals")]
9799    #[doc(hidden)]
9800    pub fn benchmark_phases(
9801        &self,
9802        enabled: Option<bool>,
9803        reset: bool,
9804    ) -> Result<Option<Value>, String> {
9805        self.benchmark_phases.configure(enabled, reset)?;
9806        Ok(self.benchmark_phases.snapshot())
9807    }
9808
9809    /// Rows, coverage, and local revision from one SQLite read snapshot.
9810    /// §7.5 atomic snapshot read. An `owner` records a failure in
9811    /// `diagnostics_snapshot().query_failures` until its next successful read.
9812    pub fn query_snapshot(
9813        &mut self,
9814        sql: &str,
9815        params: &[QueryValue],
9816        coverage: &[WindowCoverage],
9817        owner: Option<QueryOwner<'_>>,
9818    ) -> Result<QuerySnapshot, String> {
9819        let result = snapshot_connection(&self.conn, sql, params, coverage);
9820        if let Some(owner) = owner {
9821            self.record_query_read(owner, result.as_ref().err());
9822        }
9823        result.map_err(|failure| failure.to_string())
9824    }
9825
9826    /// §7.6: record the outcome of one owned snapshot read, including reads a
9827    /// host ran on a separate connection. A success removes the owner's entry;
9828    /// an identical failure leaves it unchanged; a changed failure moves last.
9829    pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
9830        let position = self
9831            .query_failures
9832            .iter()
9833            .position(|entry| entry.id == owner.id);
9834        let Some(failure) = failure else {
9835            if let Some(index) = position {
9836                self.query_failures.remove(index);
9837            }
9838            return;
9839        };
9840        let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
9841            .into_iter()
9842            .map(str::to_owned)
9843            .collect();
9844        let code = failure.code.unwrap_or("client.query_failed").to_owned();
9845        if position.is_some_and(|index| {
9846            let entry = &self.query_failures[index];
9847            entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
9848        }) {
9849            return;
9850        }
9851        if let Some(index) = position {
9852            self.query_failures.remove(index);
9853        }
9854        self.query_failures.push(DiagnosticQueryFailure {
9855            id: owner.id.to_owned(),
9856            tables,
9857            code,
9858            sqlite_code: failure.sqlite_code,
9859            at_ms: self.clock_now_ms(),
9860        });
9861        if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
9862            self.query_failures.remove(0);
9863        }
9864    }
9865
9866    // -- request building ---------------------------------------------------------
9867
9868    fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
9869        #[cfg(feature = "bench-internals")]
9870        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
9871        let log_epoch = self.get_meta(LOG_EPOCH_KEY);
9872        let mut frames = vec![Frame::ReqHeader {
9873            client_id: self.client_id.clone(),
9874            schema_version: self.schema.version,
9875            log_epoch: log_epoch.clone(),
9876        }];
9877        let mut pushed_ids = Vec::new();
9878        let mut ops_in_request = 0usize;
9879        let mut deferred_commits = 0usize;
9880        #[cfg(feature = "bench-internals")]
9881        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
9882        for (index, commit) in self
9883            .outbox
9884            .iter()
9885            .take(if log_epoch.is_some() {
9886                self.outbox.len()
9887            } else {
9888                0
9889            })
9890            .enumerate()
9891        {
9892            // §6.1 splitBatch: stop at the operation cap — commits apply in
9893            // order, so everything from the first non-fitting commit on is
9894            // deferred to the next round. A single over-cap commit still goes
9895            // alone (commits are atomic and cannot be split).
9896            if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
9897                deferred_commits = self.outbox.len() - index;
9898                break;
9899            }
9900            ops_in_request += commit.ops.len();
9901            let operations = commit
9902                .ops
9903                .iter()
9904                .map(|op| {
9905                    let payload = op.values.as_ref().and_then(|values| {
9906                        let table = self.schema.table(&op.table)?;
9907                        // §0: outbox entries encode at send time with the
9908                        // current codec (validated at mutate()). §6.1: the
9909                        // values map's keys are the sparse presence set.
9910                        // §5.11: encrypted columns are encrypted here, with
9911                        // an absent key-id selector read from the stored row.
9912                        // `sync_inner` runs `drop_unencodable_outbox` first,
9913                        // so an encrypt failure cannot reach this encode; the
9914                        // author seam validated every other failure.
9915                        let fallback = self.stored_key_fallback(table, &op.row_id);
9916                        encode_sparse_row_json(
9917                            table,
9918                            &op.row_id,
9919                            values,
9920                            &self.encryption,
9921                            fallback.as_ref(),
9922                        )
9923                        .ok()
9924                    });
9925                    ssp2::model::Operation {
9926                        table: op.table.clone(),
9927                        row_id: op.row_id.clone(),
9928                        op: if op.upsert { Op::Upsert } else { Op::Delete },
9929                        base_version: op.base_version,
9930                        payload,
9931                    }
9932                })
9933                .collect();
9934            frames.push(Frame::PushCommit {
9935                client_commit_id: commit.client_commit_id.clone(),
9936                operations,
9937            });
9938            pushed_ids.push(commit.client_commit_id.clone());
9939        }
9940        #[cfg(feature = "bench-internals")]
9941        drop(outbox_phase);
9942        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
9943        // bare URL — capability negotiation, decided per transport.
9944        let accept = self.limits.accept.unwrap_or(if url_capable {
9945            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
9946        } else {
9947            DEFAULT_ACCEPT
9948        });
9949        frames.push(Frame::PullHeader {
9950            limit_commits: self.limits.limit_commits.unwrap_or(0),
9951            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
9952            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
9953            accept,
9954        });
9955        let mut fresh = Vec::new();
9956        for sub in &self.subs {
9957            if sub.state != SubState::Active {
9958                continue;
9959            }
9960            let mut scopes = sub.requested.clone();
9961            sort_scope_map(&mut scopes);
9962            frames.push(Frame::Subscription {
9963                id: sub.id.clone(),
9964                table: sub.table.clone(),
9965                scopes,
9966                params: sub.params.clone().map(RawJson),
9967                cursor: sub.cursor,
9968                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
9969            });
9970            fresh.push((
9971                sub.id.clone(),
9972                sub.cursor < 0 && sub.bootstrap_state.is_none(),
9973            ));
9974        }
9975        let message = Message {
9976            wire_version: WIRE_VERSION,
9977            msg_kind: MsgKind::Request,
9978            frames,
9979        };
9980        (
9981            message,
9982            RequestMeta {
9983                pushed_ids,
9984                fresh,
9985                accept,
9986                deferred_commits,
9987            },
9988        )
9989    }
9990
9991    // -- sync -------------------------------------------------------------------
9992
9993    /// Cloneable observation handle; no client lock is needed to read it.
9994    pub fn progress(&self) -> ProgressObserver {
9995        self.progress.clone()
9996    }
9997
9998    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
9999        let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
10000            Ok(prepared) => prepared,
10001            Err(outcome) => return *outcome,
10002        };
10003        let mut next = prepared;
10004        loop {
10005            match self.apply_sync_round(next.exchange(transport)) {
10006                crate::AppliedSyncRound::Continue(prepared) => next = prepared,
10007                crate::AppliedSyncRound::Complete {
10008                    outcome, controls, ..
10009                } => {
10010                    for text in controls {
10011                        let _ = transport.realtime_send(&text);
10012                    }
10013                    return outcome;
10014                }
10015            }
10016        }
10017    }
10018
10019    fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
10020        if let SyncOutcome::Failed {
10021            error_code,
10022            message,
10023            details,
10024        } = &mut outcome
10025        {
10026            if let Some(failure) = self.storage_failure.borrow().as_ref() {
10027                let code = failure
10028                    .code
10029                    .expect("retained storage failures are classified");
10030                *error_code = code.to_owned();
10031                *message = match code {
10032                    "client.storage_full" => "local SQLite storage is full",
10033                    "client.storage_io" => "local SQLite storage I/O failed",
10034                    "client.storage_corrupt" => "local SQLite storage is corrupt",
10035                    _ => unreachable!("unrecognized classified storage failure"),
10036                }
10037                .to_owned();
10038                *details = failure.details();
10039            }
10040        }
10041        let retry_delay_ms = self.round_retry_delay_ms;
10042        self.progress.update(|p| match &outcome {
10043            SyncOutcome::Ok(report) if report.failed.is_empty() => {
10044                p.state = ProgressState::Complete
10045            }
10046            SyncOutcome::Ok(_) => {
10047                p.state = ProgressState::Failed;
10048                p.error_code = Some("sync.scope_revoked".into());
10049            }
10050            SyncOutcome::Failed { error_code, .. } => {
10051                p.state = ProgressState::Failed;
10052                p.error_code = Some(Self::diagnostic_code(error_code));
10053                p.retry_delay_ms = retry_delay_ms;
10054            }
10055            SyncOutcome::RealtimeUnavailable { .. } => {
10056                p.state = ProgressState::Failed;
10057                p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
10058                p.retry_delay_ms = retry_delay_ms;
10059            }
10060        });
10061        let completed_at_ms = self.clock_now_ms();
10062        self.last_round = Some(match &outcome {
10063            SyncOutcome::Ok(report) => DiagnosticLastRound {
10064                status: "succeeded".to_owned(),
10065                started_at_ms,
10066                completed_at_ms,
10067                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
10068                counters: Some(DiagnosticRoundCounters {
10069                    pushed: report.pushed,
10070                    applied: report.applied.len(),
10071                    rejected: report.rejected.len(),
10072                    retryable: report.retryable.len(),
10073                    conflicts: report.conflicts,
10074                    commits_applied: report.commits_applied,
10075                    segment_rows_applied: report.segment_rows_applied,
10076                    bootstrapping: report.bootstrapping.len(),
10077                    resets: report.resets.len(),
10078                    revoked: report.revoked.len(),
10079                    failed: report.failed.len(),
10080                    deferred_commits: report.deferred_commits,
10081                }),
10082                error_code: None,
10083            },
10084            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
10085                status: "failed".to_owned(),
10086                started_at_ms,
10087                completed_at_ms,
10088                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
10089                counters: None,
10090                error_code: Some(Self::diagnostic_code(error_code)),
10091            },
10092            SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
10093                status: "failed".to_owned(),
10094                started_at_ms,
10095                completed_at_ms,
10096                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
10097                counters: None,
10098                error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
10099            },
10100        });
10101        outcome
10102    }
10103
10104    /// Capture one request on the client owner. Mutations made after this
10105    /// call remain in the outbox for a later round; no network I/O occurs here.
10106    pub fn prepare_sync_round(
10107        &mut self,
10108        url_capable: bool,
10109    ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
10110        if self.active_round.is_some() {
10111            return Err(Box::new(SyncOutcome::Failed {
10112                error_code: "client.round_in_progress".into(),
10113                message: "a sync round is already in flight".into(),
10114                details: None,
10115            }));
10116        }
10117        self.storage_failure.borrow_mut().take();
10118        self.progress.start();
10119        self.round_retry_delay_ms = None;
10120        let started_at_ms = self.clock_now_ms();
10121        let prepared = (|| {
10122            if self.stopped {
10123                return Err(Box::new(SyncOutcome::Ok(SyncReport {
10124                    schema_floor: self.schema_floor.clone(),
10125                    ..SyncReport::default()
10126                })));
10127            }
10128            if self.security_preflight {
10129                return Err(Box::new(SyncOutcome::Failed {
10130                    error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
10131                    message: "security preflight must complete before syncing".into(),
10132                    details: None,
10133                }));
10134            }
10135            self.set_sync_needed(false, false);
10136            let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
10137                self.pending_blob_uploads().map_err(|e| {
10138                    Box::new(SyncOutcome::Failed {
10139                        error_code: e.code,
10140                        message: e.message,
10141                        details: e.details,
10142                    })
10143                })?
10144            } else {
10145                Vec::new()
10146            };
10147            self.drain_pending_evictions()
10148                .and_then(|_| self.drop_unencodable_outbox())
10149                .map_err(|message| {
10150                    Box::new(SyncOutcome::Failed {
10151                        error_code: "storage.failed".into(),
10152                        message,
10153                        details: None,
10154                    })
10155                })?;
10156            if self.realtime_state != RealtimeState::Connected
10157                && self.realtime_policy == RealtimePolicy::Required
10158            {
10159                let retry_delay_ms = self.schedule_background_retry();
10160                self.realtime_retry_delay_ms = Some(retry_delay_ms);
10161                return Err(Box::new(SyncOutcome::RealtimeUnavailable {
10162                    state: self.realtime_state,
10163                    reason_code: self.realtime_reason_code.clone(),
10164                    retry_delay_ms,
10165                }));
10166            }
10167            let (message, meta) = self.build_request(url_capable);
10168            let id = uuid::Uuid::new_v4();
10169            self.active_round = Some(id);
10170            Ok(crate::PreparedSyncRound {
10171                id,
10172                started_at_ms,
10173                message,
10174                meta,
10175                uploads: uploads.into(),
10176                realtime: self.realtime_state == RealtimeState::Connected,
10177                scopes: self
10178                    .subs
10179                    .iter()
10180                    .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
10181                    .collect(),
10182                #[cfg(feature = "bench-internals")]
10183                benchmark_phases: self.benchmark_phases.clone(),
10184                progress: self.progress.clone(),
10185                fixed_now: self.now_ms,
10186            })
10187        })();
10188        prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
10189    }
10190
10191    /// Discard the reply of an old authorization/subscription context. Its
10192    /// submitted commits stay durable and can be retried with the same ids.
10193    pub fn cancel_sync_round(&mut self) {
10194        self.active_round = None;
10195    }
10196
10197    /// Apply captured network results on the owner, preserving all normal
10198    /// version, schema, content-address and cursor checks. No network I/O.
10199    pub fn apply_sync_round(
10200        &mut self,
10201        completed: crate::CompletedSyncRound,
10202    ) -> crate::AppliedSyncRound {
10203        let prepared = completed.prepared;
10204        if self.active_round != Some(prepared.id) || self.security_preflight {
10205            return crate::AppliedSyncRound::Complete {
10206                outcome: SyncOutcome::Failed {
10207                    error_code: "client.round_cancelled".into(),
10208                    message: "sync round belongs to a released client context".into(),
10209                    details: None,
10210                },
10211                controls: Vec::new(),
10212                more: false,
10213                bootstrap_advanced: false,
10214            };
10215        }
10216        self.storage_failure.borrow_mut().take();
10217        let (response, mut downloads, transport_failed) = match completed.exchange {
10218            crate::round::ExchangeResult::Upload { id, result } => {
10219                let result = result.and_then(|()| {
10220                    self.conn
10221                        .execute(
10222                            "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
10223                            rusqlite::params![id],
10224                        )
10225                        .map(|_| ())
10226                        .map_err(|error| {
10227                            TransportError::new(
10228                                "client.failed",
10229                                Self::sqlite_failure(&self.storage_failure, error),
10230                            )
10231                        })
10232                });
10233                match result {
10234                    Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
10235                    Err(TransportError {
10236                        code,
10237                        message,
10238                        details,
10239                    }) => {
10240                        self.active_round = None;
10241                        if Self::retryable_failure_code(&code) {
10242                            self.schedule_background_retry();
10243                        }
10244                        let outcome = self.finish_sync_round(
10245                            prepared.started_at_ms,
10246                            SyncOutcome::Failed {
10247                                error_code: code,
10248                                message,
10249                                details,
10250                            },
10251                        );
10252                        return crate::AppliedSyncRound::Complete {
10253                            outcome,
10254                            controls: Vec::new(),
10255                            more: false,
10256                            bootstrap_advanced: false,
10257                        };
10258                    }
10259                }
10260            }
10261            crate::round::ExchangeResult::Reply {
10262                response,
10263                downloads,
10264                transport_failed,
10265            } => (response, downloads, transport_failed),
10266        };
10267        self.active_round = None;
10268        let before = self
10269            .subs
10270            .iter()
10271            .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
10272            .collect::<Vec<_>>();
10273        let outcome = (|| {
10274            let response = match response {
10275                Ok(response) => response,
10276                Err(TransportError {
10277                    code,
10278                    message,
10279                    details,
10280                }) => {
10281                    if transport_failed {
10282                        if prepared.realtime {
10283                            self.set_realtime_state(RealtimeState::Lost, Some(&code));
10284                        }
10285                        self.record_lease_error(&code);
10286                    }
10287                    if Self::retryable_failure_code(&code) {
10288                        let delay = self.schedule_background_retry();
10289                        if prepared.realtime && transport_failed {
10290                            self.realtime_retry_delay_ms = Some(delay);
10291                        }
10292                    }
10293                    return SyncOutcome::Failed {
10294                        error_code: code,
10295                        message,
10296                        details,
10297                    };
10298                }
10299            };
10300            let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
10301            if let SyncOutcome::Ok(report) = &mut outcome {
10302                report.deferred_commits = prepared.meta.deferred_commits;
10303            }
10304            match &outcome {
10305                SyncOutcome::Ok(_) => self.reset_background_retry(),
10306                SyncOutcome::Failed { error_code, .. }
10307                    if Self::retryable_failure_code(error_code) =>
10308                {
10309                    self.schedule_background_retry();
10310                }
10311                _ => {}
10312            }
10313            if prepared.meta.deferred_commits > 0 {
10314                self.set_sync_needed(true, true);
10315            }
10316            self.previous_version_lifetime_check();
10317            outcome
10318        })();
10319        let more = matches!(&outcome, SyncOutcome::Ok(report) if !report.bootstrapping.is_empty() || report.commits_applied > 0 || report.segment_rows_applied > 0 || !report.resets.is_empty() || self.sync_needed);
10320        let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
10321            && before
10322                != self
10323                    .subs
10324                    .iter()
10325                    .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
10326                    .collect::<Vec<_>>();
10327        crate::AppliedSyncRound::Complete {
10328            outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
10329            controls: downloads.controls,
10330            more,
10331            bootstrap_advanced,
10332        }
10333    }
10334
10335    pub fn sync_until_idle(
10336        &mut self,
10337        transport: &mut dyn Transport,
10338        max_rounds: Option<u32>,
10339    ) -> SyncOutcome {
10340        let rounds = max_rounds.unwrap_or(20).max(1);
10341        let mut aggregate = SyncReport::default();
10342        let mut spent = 0;
10343        while spent < rounds {
10344            spent += 1;
10345            let before = self
10346                .subs
10347                .iter()
10348                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
10349                .collect::<Vec<_>>();
10350            let outcome = self.sync(transport);
10351            match outcome {
10352                SyncOutcome::Failed {
10353                    error_code,
10354                    message,
10355                    details,
10356                } => {
10357                    return SyncOutcome::Failed {
10358                        details,
10359                        error_code,
10360                        message,
10361                    };
10362                }
10363                SyncOutcome::RealtimeUnavailable { .. } => return outcome,
10364                SyncOutcome::Ok(report) => {
10365                    if max_rounds.is_none()
10366                        && report.segment_rows_applied > 0
10367                        && !report.bootstrapping.is_empty()
10368                        && before
10369                            != self
10370                                .subs
10371                                .iter()
10372                                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
10373                                .collect::<Vec<_>>()
10374                    {
10375                        spent = 0;
10376                    }
10377                    aggregate.merge(&report);
10378                    // §4.5: pull again whenever the response contained
10379                    // commits or segments; resets re-bootstrap; a pending
10380                    // resume token continues paging (§4.7); a raised
10381                    // sync-needed signal covers §6.1 splitBatch remainders
10382                    // (deferred outbox commits push on the next round).
10383                    let more = !report.bootstrapping.is_empty()
10384                        || report.commits_applied > 0
10385                        || report.segment_rows_applied > 0
10386                        || !report.resets.is_empty()
10387                        || self.sync_needed;
10388                    if !more {
10389                        return SyncOutcome::Ok(aggregate);
10390                    }
10391                }
10392            }
10393        }
10394        SyncOutcome::Failed {
10395            details: None,
10396            error_code: "sync.invalid_request".into(),
10397            message: "sync did not reach idle within the round budget".into(),
10398        }
10399    }
10400
10401    fn process_response(
10402        &mut self,
10403        transport: &mut dyn Transport,
10404        response: Message,
10405        meta: &RequestMeta,
10406    ) -> SyncOutcome {
10407        #[cfg(feature = "bench-internals")]
10408        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
10409        let mut report = SyncReport {
10410            pushed: meta.pushed_ids.len() as u32,
10411            ..SyncReport::default()
10412        };
10413        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
10414            HashMap::new();
10415        let pushed_ids = meta
10416            .pushed_ids
10417            .iter()
10418            .map(String::as_str)
10419            .collect::<HashSet<_>>();
10420        let mut last_final_push_result_id: Option<String> = None;
10421        for frame in &response.frames {
10422            match frame {
10423                Frame::PushResultDetails {
10424                    client_commit_id,
10425                    entries,
10426                } => {
10427                    let details = rejection_details_by_commit
10428                        .entry(client_commit_id.clone())
10429                        .or_default();
10430                    for entry in entries {
10431                        let parsed = match RejectionDetails::parse(&entry.details.0) {
10432                            Ok(value) => value,
10433                            Err(message) => {
10434                                return SyncOutcome::Failed {
10435                                    details: None,
10436                                    error_code: "sync.invalid_request".to_owned(),
10437                                    message,
10438                                };
10439                            }
10440                        };
10441                        details.insert(entry.op_index, parsed);
10442                    }
10443                }
10444                Frame::PushResult {
10445                    client_commit_id,
10446                    status,
10447                    results,
10448                    ..
10449                } if pushed_ids.contains(client_commit_id.as_str())
10450                    && Self::push_result_is_final(*status, results) =>
10451                {
10452                    last_final_push_result_id = Some(client_commit_id.clone());
10453                }
10454                _ => {}
10455            }
10456        }
10457        let mut frames = response.frames.into_iter().peekable();
10458        if response.wire_version < 2 {
10459            return SyncOutcome::Failed {
10460                details: None,
10461                error_code: "client.invalid_host_response".to_owned(),
10462                message: "server response does not carry wire version 2 log-epoch state".to_owned(),
10463            };
10464        }
10465        match frames.next() {
10466            Some(Frame::RespHeader {
10467                required_schema_version,
10468                latest_schema_version,
10469                log_epoch,
10470                reset_required,
10471            }) => {
10472                let Some(log_epoch) = log_epoch else {
10473                    return SyncOutcome::Failed {
10474                        details: None,
10475                        error_code: "client.invalid_host_response".to_owned(),
10476                        message: "response header omits logEpoch".to_owned(),
10477                    };
10478                };
10479                let Some(reset_required) = reset_required else {
10480                    return SyncOutcome::Failed {
10481                        details: None,
10482                        error_code: "client.invalid_host_response".to_owned(),
10483                        message: "response header omits resetRequired".to_owned(),
10484                    };
10485                };
10486                if let Some(required) = required_schema_version {
10487                    // §1.6 schema-floor response: nothing else is processed;
10488                    // stop syncing and surface the upgrade requirement.
10489                    let floor = SchemaFloor {
10490                        required_schema_version: Some(required),
10491                        latest_schema_version,
10492                    };
10493                    self.set_schema_floor(Some(floor.clone()));
10494                    report.schema_floor = Some(floor);
10495                    return SyncOutcome::Ok(report);
10496                }
10497                if reset_required {
10498                    if frames.next().is_some() {
10499                        return SyncOutcome::Failed {
10500                            details: None,
10501                            error_code: "client.invalid_host_response".to_owned(),
10502                            message: "log-epoch reset response contains body frames".to_owned(),
10503                        };
10504                    }
10505                    match self.run_log_epoch_reset(&log_epoch) {
10506                        Ok(resets) => {
10507                            report.resets = resets;
10508                            return SyncOutcome::Ok(report);
10509                        }
10510                        Err(message) => {
10511                            return SyncOutcome::Failed {
10512                                details: None,
10513                                error_code: "sync.local_corrupt".to_owned(),
10514                                message,
10515                            };
10516                        }
10517                    }
10518                }
10519                if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
10520                    return SyncOutcome::Failed {
10521                        details: None,
10522                        error_code: "client.invalid_host_response".to_owned(),
10523                        message: "server changed logEpoch without requiring a reset".to_owned(),
10524                    };
10525                }
10526            }
10527            _ => {
10528                return SyncOutcome::Failed {
10529                    details: None,
10530                    error_code: "sync.invalid_request".to_owned(),
10531                    message: "response does not start with RESP_HEADER".to_owned(),
10532                };
10533            }
10534        }
10535
10536        let mut failure: Option<(String, String)> = None;
10537        while let Some(frame) = frames.next() {
10538            match frame {
10539                frame @ Frame::PushResult { .. } => {
10540                    let successful = matches!(
10541                        &frame,
10542                        Frame::PushResult {
10543                            status: PushStatus::Applied | PushStatus::Cached,
10544                            ..
10545                        }
10546                    );
10547                    let mut results = vec![frame];
10548                    if successful {
10549                        while matches!(
10550                            frames.peek(),
10551                            Some(Frame::PushResult {
10552                                status: PushStatus::Applied | PushStatus::Cached,
10553                                ..
10554                            })
10555                        ) {
10556                            if let Some(next) = frames.next() {
10557                                results.push(next);
10558                            }
10559                        }
10560                    }
10561                    if self
10562                        .handle_push_results(
10563                            &results,
10564                            &pushed_ids,
10565                            &rejection_details_by_commit,
10566                            &mut report,
10567                            last_final_push_result_id.as_deref(),
10568                        )
10569                        .is_err()
10570                    {
10571                        failure = Some((
10572                            "client.outcome_persistence_failed".to_owned(),
10573                            "local commit outcome could not be persisted".to_owned(),
10574                        ));
10575                        break;
10576                    }
10577                }
10578                Frame::PushResultDetails { .. } => {}
10579                Frame::SubStart {
10580                    id,
10581                    status,
10582                    reason_code,
10583                    effective_scopes,
10584                    bootstrap: _,
10585                } => {
10586                    let mut body = Vec::new();
10587                    let mut sub_end: Option<(i64, Option<String>)> = None;
10588                    for inner in frames.by_ref() {
10589                        match inner {
10590                            Frame::SubEnd {
10591                                next_cursor,
10592                                bootstrap_state,
10593                            } => {
10594                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
10595                                break;
10596                            }
10597                            Frame::Unknown { .. } => {}
10598                            other => body.push(other),
10599                        }
10600                    }
10601                    if let Err(SectionError::Abort(code, message)) = self.process_section(
10602                        transport,
10603                        &id,
10604                        status,
10605                        &reason_code,
10606                        effective_scopes,
10607                        body,
10608                        sub_end,
10609                        meta,
10610                        &mut report,
10611                    ) {
10612                        failure = Some((code, message));
10613                        break;
10614                    }
10615                }
10616                Frame::Lease {
10617                    lease_id,
10618                    expires_at_ms,
10619                } => {
10620                    // §7.3.5: persist the opaque lease; a fresh lease clears
10621                    // any prior lease error (the outage/revocation is over).
10622                    self.set_lease_state(Some(LeaseState {
10623                        lease_id: Some(lease_id),
10624                        expires_at_ms: Some(expires_at_ms),
10625                        error_code: None,
10626                    }));
10627                }
10628                Frame::Error { code, message, .. } => {
10629                    // §1.6: the whole request failed; already-completed
10630                    // subscriptions keep their applied data and cursors.
10631                    failure = Some((code, message));
10632                    break;
10633                }
10634                Frame::Unknown { .. } => {}
10635                _ => {
10636                    failure = Some((
10637                        "sync.invalid_request".to_owned(),
10638                        "unexpected frame in response".to_owned(),
10639                    ));
10640                    break;
10641                }
10642            }
10643        }
10644
10645        // §7.1: reconcile the visible overlay once at the response boundary
10646        // after all outbox acknowledgements and server data applied so a
10647        // batch of PUSH_RESULT frames cannot trigger repeated full-table
10648        // rebuilds. This also covers a round that aborted mid-way.
10649        if self.overlay_dirty.get() {
10650            self.rebuild_overlay();
10651        }
10652        if let Some((error_code, message)) = failure {
10653            return SyncOutcome::Failed {
10654                details: None,
10655                error_code,
10656                message,
10657            };
10658        }
10659        // §4.8 E1: the push half may have drained commits that pinned rows of
10660        // a shrunk window unit — retry any deferred evictions now.
10661        if let Err(message) = self.drain_pending_evictions() {
10662            return SyncOutcome::Failed {
10663                details: None,
10664                error_code: "storage.failed".into(),
10665                message,
10666            };
10667        }
10668        self.ack_after_pull(transport);
10669        // §7.4.5: the reset is over once the first post-reset pull round
10670        // leaves no subscription mid-bootstrap — the tables are rebuilt.
10671        if self.upgrading && report.bootstrapping.is_empty() {
10672            self.set_upgrading(false);
10673        }
10674        SyncOutcome::Ok(report)
10675    }
10676
10677    // -- push results (§6.3, §7.2) ------------------------------------------------
10678
10679    fn handle_push_results(
10680        &mut self,
10681        frames: &[Frame],
10682        pushed_ids: &HashSet<&str>,
10683        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
10684        report: &mut SyncReport,
10685        last_final_push_result_id: Option<&str>,
10686    ) -> Result<(), String> {
10687        self.begin_observation("syncular_push_results")?;
10688        let previous_failed = self.failed_commits.clone();
10689        let conflict_count = self.conflicts.len();
10690        let rejection_count = self.rejections.len();
10691        let applied_count = report.applied.len();
10692        let rejected_count = report.rejected.len();
10693        let retryable_count = report.retryable.len();
10694        let was_dirty = self.overlay_dirty.snapshot();
10695        let mut removed = Vec::new();
10696        let mut batch = ChangeAccumulator::default();
10697        let persisted = (|| {
10698            for frame in frames {
10699                let Frame::PushResult {
10700                    client_commit_id,
10701                    status,
10702                    results,
10703                    ..
10704                } = frame
10705                else {
10706                    return Err("unexpected acknowledgement frame".to_owned());
10707                };
10708                if !pushed_ids.contains(client_commit_id.as_str()) {
10709                    continue;
10710                }
10711                let Some(index) = self
10712                    .outbox
10713                    .iter()
10714                    .position(|commit| commit.client_commit_id == *client_commit_id)
10715                else {
10716                    continue;
10717                };
10718                let status = *status;
10719                let rejection_details = rejection_details_by_commit.get(client_commit_id);
10720                let operations = self.outbox[index].ops.clone();
10721                match status {
10722                    PushStatus::Applied | PushStatus::Cached => {
10723                        // §7.2: a lost ack replays as `cached` — proceed as if the
10724                        // ack had arrived.
10725                        let journal_results = results
10726                            .iter()
10727                            .map(|result| {
10728                                let op_index = match result {
10729                                    OpResult::Applied { op_index }
10730                                    | OpResult::Conflict { op_index, .. }
10731                                    | OpResult::Error { op_index, .. } => *op_index,
10732                                };
10733                                CommitOperationOutcome::Applied { op_index }
10734                            })
10735                            .collect::<Vec<_>>();
10736                        let outcome_status = if status == PushStatus::Applied {
10737                            CommitOutcomeStatus::Applied
10738                        } else {
10739                            CommitOutcomeStatus::Cached
10740                        };
10741                        let persisted = self
10742                            .persist_commit_outcome(
10743                                client_commit_id,
10744                                outcome_status,
10745                                &journal_results,
10746                                None,
10747                            )
10748                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
10749                        persisted?;
10750                        for operation in &operations {
10751                            self.overlay_dirty.table(&operation.table);
10752                        }
10753                        batch.status = true;
10754                        batch.outcomes = true;
10755                    }
10756                    PushStatus::Rejected => {
10757                        if results.iter().any(|result| {
10758                            matches!(
10759                                result,
10760                                OpResult::Error {
10761                                    code,
10762                                    retryable: true,
10763                                    ..
10764                                } if code == "sync.idempotency_cache_miss"
10765                            )
10766                        }) {
10767                            // §6.3/§7.2: a serving failure, not an outcome — keep the
10768                            // exact commit queued for an identical retry.
10769                            report.retryable.push(client_commit_id.to_owned());
10770                            continue;
10771                        }
10772
10773                        let mut journal_results = Vec::with_capacity(results.len());
10774                        let mut conflicts = Vec::new();
10775                        let mut rejections = Vec::new();
10776                        for result in results {
10777                            match result {
10778                                OpResult::Applied { op_index } => {
10779                                    journal_results.push(CommitOperationOutcome::Applied {
10780                                        op_index: *op_index,
10781                                    });
10782                                }
10783                                OpResult::Conflict {
10784                                    op_index,
10785                                    code,
10786                                    message,
10787                                    server_version,
10788                                    server_row,
10789                                    conflict_columns: conflict_columns_bitmap,
10790                                } => {
10791                                    let operation = operations
10792                                        .get(*op_index as usize)
10793                                        .map(CommitOperation::from);
10794                                    let (table, row_id) = operation
10795                                        .as_ref()
10796                                        .map(|op| (op.table.clone(), op.row_id.clone()))
10797                                        .unwrap_or_default();
10798                                    let server_row_json = self
10799                                        .schema
10800                                        .table(&table)
10801                                        .and_then(|t| {
10802                                            decode_row_bytes(t, server_row, &self.encryption)
10803                                                .ok()
10804                                                .map(|row| (t, row))
10805                                        })
10806                                        .map(|(t, row)| {
10807                                            let mut map = Map::new();
10808                                            for (i, column) in t.columns.iter().enumerate() {
10809                                                map.insert(
10810                                                    column.name.clone(),
10811                                                    column_value_to_json(
10812                                                        row.get(i).unwrap_or(&None),
10813                                                    ),
10814                                                );
10815                                            }
10816                                            map
10817                                        })
10818                                        .unwrap_or_default();
10819                                    // §6.3: decode the presence-layout bitmap
10820                                    // into the marked column names.
10821                                    let conflict_columns = self
10822                                        .schema
10823                                        .table(&table)
10824                                        .map(|t| {
10825                                            t.columns
10826                                                .iter()
10827                                                .enumerate()
10828                                                .filter(|(index, _)| {
10829                                                    conflict_columns_bitmap
10830                                                        .get(index / 8)
10831                                                        .is_some_and(|byte| {
10832                                                            byte & (1 << (index % 8)) != 0
10833                                                        })
10834                                                })
10835                                                .map(|(_, column)| column.name.clone())
10836                                                .collect::<Vec<_>>()
10837                                        })
10838                                        .unwrap_or_default();
10839                                    let conflict = ConflictRecord {
10840                                        client_commit_id: client_commit_id.to_owned(),
10841                                        op_index: *op_index,
10842                                        table,
10843                                        row_id,
10844                                        code: code.clone(),
10845                                        message: message.clone(),
10846                                        server_version: *server_version,
10847                                        server_row: server_row_json,
10848                                        conflict_columns,
10849                                        operation,
10850                                    };
10851                                    journal_results.push(CommitOperationOutcome::Conflict {
10852                                        conflict: conflict.clone(),
10853                                    });
10854                                    conflicts.push(conflict);
10855                                }
10856                                OpResult::Error {
10857                                    op_index,
10858                                    code,
10859                                    message,
10860                                    retryable,
10861                                } => {
10862                                    let rejection = RejectionRecord {
10863                                        client_commit_id: client_commit_id.to_owned(),
10864                                        op_index: *op_index,
10865                                        code: code.clone(),
10866                                        message: message.clone(),
10867                                        retryable: *retryable,
10868                                        details: rejection_details
10869                                            .and_then(|details| details.get(op_index))
10870                                            .cloned(),
10871                                        operation: operations
10872                                            .get(*op_index as usize)
10873                                            .map(CommitOperation::from),
10874                                    };
10875                                    journal_results.push(CommitOperationOutcome::Error {
10876                                        rejection: rejection.clone(),
10877                                    });
10878                                    rejections.push(rejection);
10879                                }
10880                            }
10881                        }
10882                        if self.retain_failed_commits {
10883                            self.retain_failed_commit(client_commit_id, &operations)?;
10884                        }
10885                        let outcome_status = if conflicts.is_empty() {
10886                            CommitOutcomeStatus::Rejected
10887                        } else {
10888                            CommitOutcomeStatus::Conflict
10889                        };
10890                        let persisted = self
10891                            .persist_commit_outcome(
10892                                client_commit_id,
10893                                outcome_status,
10894                                &journal_results,
10895                                Some(&operations),
10896                            )
10897                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
10898                        persisted?;
10899                        batch.conflicts = !conflicts.is_empty();
10900                        batch.rejections = !rejections.is_empty();
10901                        batch.status = true;
10902                        batch.outcomes = true;
10903                        self.conflicts.extend(conflicts);
10904                        self.rejections.extend(rejections);
10905                        for operation in &operations {
10906                            self.overlay_dirty.table(&operation.table);
10907                        }
10908                    }
10909                }
10910                removed.push((index, self.outbox.remove(index)));
10911                if batch.status {
10912                    for operation in &operations {
10913                        if !self.record_row_scopes(
10914                            &mut batch,
10915                            &operation.table,
10916                            &operation.row_id,
10917                            false,
10918                        ) {
10919                            batch.table(&operation.table);
10920                        }
10921                    }
10922                }
10923                match status {
10924                    PushStatus::Applied | PushStatus::Cached => {
10925                        report.applied.push(client_commit_id.to_owned())
10926                    }
10927                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
10928                }
10929            }
10930            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)) {
10931                self.prune_commit_outcomes()?;
10932            }
10933            self.finish_observation("syncular_push_results", batch)
10934        })();
10935        if let Err(error) = persisted {
10936            self.rollback_observation("syncular_push_results");
10937            self.failed_commits = previous_failed;
10938            for (index, commit) in removed.into_iter().rev() {
10939                self.outbox.insert(index, commit);
10940            }
10941            self.conflicts.truncate(conflict_count);
10942            self.rejections.truncate(rejection_count);
10943            self.overlay_dirty.restore(was_dirty.clone());
10944            report.applied.truncate(applied_count);
10945            report.rejected.truncate(rejected_count);
10946            report.retryable.truncate(retryable_count);
10947            return Err(error);
10948        }
10949        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
10950        Ok(())
10951    }
10952
10953    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
10954        status != PushStatus::Rejected
10955            || !results.iter().any(|result| {
10956                matches!(
10957                    result,
10958                    OpResult::Error {
10959                        code,
10960                        retryable: true,
10961                        ..
10962                    } if code == "sync.idempotency_cache_miss"
10963                )
10964            })
10965    }
10966
10967    // -- subscription sections ------------------------------------------------------
10968
10969    #[allow(clippy::too_many_arguments)]
10970    fn process_section(
10971        &mut self,
10972        transport: &mut dyn Transport,
10973        id: &str,
10974        status: SubStatus,
10975        reason_code: &str,
10976        effective_scopes: Vec<(String, Vec<String>)>,
10977        body: Vec<Frame>,
10978        sub_end: Option<(i64, Option<String>)>,
10979        meta: &RequestMeta,
10980        report: &mut SyncReport,
10981    ) -> Result<(), SectionError> {
10982        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
10983            return Ok(()); // unknown echo: ignore
10984        };
10985        if status != SubStatus::Active && sub_end.is_none() {
10986            return Err(SectionError::Abort(
10987                "sync.invalid_request".into(),
10988                "subscription section without SUB_END".into(),
10989            ));
10990        }
10991        match status {
10992            SubStatus::Revoked => {
10993                self.begin_observation("syncular_revocation")
10994                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
10995                let previous = self.subs[sub_index].clone();
10996                let was_dirty = self.overlay_dirty.snapshot();
10997                let previous_outbox = self.outbox.clone();
10998                let previous_failed = self.failed_commits.clone();
10999                let previous_rejections = self.rejections.clone();
11000                let previous_conflicts = self.conflicts.clone();
11001                let revoked_count = report.revoked.len();
11002                let failed_count = report.failed.len();
11003                let result =
11004                    (|| {
11005                        let mut batch = ChangeAccumulator::default();
11006                        let registered = self.window_unit_by_sub_id(id);
11007                        // §3.3: stop pulling, purge exactly the last effective grant.
11008                        let (table, effective) = {
11009                            let sub = &self.subs[sub_index];
11010                            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
11011                        };
11012                        let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
11013                            SectionError::Abort(
11014                                "sync.unknown_table".to_owned(),
11015                                "revoked scope references an unknown table".to_owned(),
11016                            )
11017                        })?;
11018                        let dropped = self
11019                            .drop_failed_commits(|name, values| {
11020                                name == table
11021                                    && !effective.is_empty()
11022                                    && effective.iter().all(|(variable, allowed)| {
11023                                        scoped_table
11024                                            .scope_column(variable)
11025                                            .and_then(|column| values.get(column))
11026                                            .and_then(Value::as_str)
11027                                            .is_some_and(|value| {
11028                                                allowed.iter().any(|allowed| allowed == value)
11029                                            })
11030                                    })
11031                            })
11032                            .map_err(|message| {
11033                                SectionError::Abort("storage.failed".to_owned(), message)
11034                            })?;
11035                        for commit in dropped {
11036                            batch.conflicts = true;
11037                            batch.rejections = true;
11038                            batch.outcomes = true;
11039                            for operation in commit.ops {
11040                                batch.table(&operation.table);
11041                            }
11042                        }
11043                        let purged = self.purge_scope_rows(&table, &effective);
11044                        match purged {
11045                            Ok(()) => {
11046                                self.record_scope_map(&mut batch, &table, &effective);
11047                                let sub = &mut self.subs[sub_index];
11048                                sub.state = SubState::Revoked;
11049                                sub.reason_code = Some(if reason_code.is_empty() {
11050                                    "sync.scope_revoked".to_owned()
11051                                } else {
11052                                    reason_code.to_owned()
11053                                });
11054                                report.revoked.push(id.to_owned());
11055                                let doomed_effective = effective;
11056                                let sub_table = table;
11057                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
11058                                    |message| SectionError::Abort("storage.failed".into(), message),
11059                                )?;
11060                                let dropped = self
11061                                    .drop_doomed_outbox(&sub_table, &doomed_effective)
11062                                    .map_err(|message| {
11063                                        SectionError::Abort("storage.failed".to_owned(), message)
11064                                    })?;
11065                                if dropped {
11066                                    batch.status = true;
11067                                    batch.rejections = true;
11068                                    batch.outcomes = true;
11069                                }
11070                                // §5.9.7 B2: revocation deletes now-unauthorized blob
11071                                // bodies (evicted ≠ revoked).
11072                                self.delete_unreferenced_cached_blobs();
11073                            }
11074                            Err(()) => {
11075                                // §3.3 fail closed: no local mapping — never clear by
11076                                // approximation; fatal configuration error.
11077                                let sub = &mut self.subs[sub_index];
11078                                sub.state = SubState::Failed;
11079                                sub.reason_code = Some("sync.scope_revoked".to_owned());
11080                                report.failed.push(id.to_owned());
11081                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
11082                                    |message| SectionError::Abort("storage.failed".into(), message),
11083                                )?;
11084                            }
11085                        }
11086                        if let Some((base_key, unit)) = registered {
11087                            batch.window(&base_key, &self.subs[sub_index].table, &unit);
11088                        }
11089                        self.rebuild_overlay_if_dirty();
11090                        self.finish_observation("syncular_revocation", batch)
11091                            .map_err(|message| {
11092                                SectionError::Abort("storage.failed".to_owned(), message)
11093                            })?;
11094                        Ok(())
11095                    })();
11096                if result.is_err() {
11097                    self.rollback_observation("syncular_revocation");
11098                    self.subs[sub_index] = previous;
11099                    self.overlay_dirty.restore(was_dirty.clone());
11100                    self.outbox = previous_outbox;
11101                    self.failed_commits = previous_failed;
11102                    self.rejections = previous_rejections;
11103                    self.conflicts = previous_conflicts;
11104                    report.revoked.truncate(revoked_count);
11105                    report.failed.truncate(failed_count);
11106                }
11107                result
11108            }
11109            SubStatus::Reset => {
11110                self.begin_observation("syncular_reset")
11111                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
11112                let previous = self.subs[sub_index].clone();
11113                let was_dirty = self.overlay_dirty.snapshot();
11114                let reset_count = report.resets.len();
11115                let result = (|| {
11116                    let mut batch = ChangeAccumulator::default();
11117                    let registered = self.window_unit_by_sub_id(id);
11118                    // §4.6: discard cursor + bootstrap state, keep local rows —
11119                    // reset is a staleness signal, not a purge signal.
11120                    let sub = &mut self.subs[sub_index];
11121                    sub.cursor = -1;
11122                    sub.bootstrap_state = None;
11123                    report.resets.push(id.to_owned());
11124                    self.persist_sub(&self.subs[sub_index].clone())
11125                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11126                    if let Some((base_key, unit)) = registered {
11127                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
11128                    }
11129                    self.finish_observation("syncular_reset", batch)
11130                        .map_err(|message| {
11131                            SectionError::Abort("storage.failed".to_owned(), message)
11132                        })?;
11133                    Ok(())
11134                })();
11135                if result.is_err() {
11136                    self.rollback_observation("syncular_reset");
11137                    self.subs[sub_index] = previous;
11138                    self.overlay_dirty.restore(was_dirty.clone());
11139                    report.resets.truncate(reset_count);
11140                }
11141                result
11142            }
11143            SubStatus::Active => {
11144                let fresh = meta
11145                    .fresh
11146                    .iter()
11147                    .find(|(fid, _)| fid == id)
11148                    .map(|(_, fresh)| *fresh)
11149                    .unwrap_or(false);
11150                let previous = self.subs[sub_index].clone();
11151                self.subs[sub_index].effective = Some(effective_scopes);
11152                let outcome = self
11153                    .apply_section_body(transport, sub_index, body, fresh, meta, report)
11154                    .and_then(|()| {
11155                        let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
11156                            SectionError::Abort(
11157                                "sync.invalid_request".into(),
11158                                "subscription section without SUB_END".into(),
11159                            )
11160                        })?;
11161                        self.apply_sub_end(sub_index, cursor, bootstrap_state)
11162                            .map_err(|message| {
11163                                SectionError::Abort("storage.failed".into(), message)
11164                            })
11165                    });
11166                match outcome {
11167                    Ok(()) => {
11168                        if self.subs[sub_index].bootstrap_state.is_some() {
11169                            report.bootstrapping.push(id.to_owned());
11170                        }
11171                        Ok(())
11172                    }
11173                    Err(SectionError::FailClosed) => {
11174                        self.subs[sub_index] = previous.clone();
11175                        self.begin_observation("syncular_section_failure")
11176                            .map_err(|message| {
11177                                SectionError::Abort("storage.failed".into(), message)
11178                            })?;
11179                        let persisted = (|| {
11180                            let mut batch = ChangeAccumulator::default();
11181                            if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
11182                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
11183                            }
11184                            self.subs[sub_index].state = SubState::Failed;
11185                            self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
11186                            self.persist_sub(&self.subs[sub_index])?;
11187                            self.finish_observation("syncular_section_failure", batch)
11188                        })();
11189                        if let Err(message) = persisted {
11190                            self.rollback_observation("syncular_section_failure");
11191                            self.subs[sub_index] = previous;
11192                            return Err(SectionError::Abort("storage.failed".into(), message));
11193                        }
11194                        report.failed.push(id.to_owned());
11195                        Ok(())
11196                    }
11197                    Err(error) => {
11198                        // Frame/block transactions already completed. Preserve their
11199                        // rows and revisions, but leave this subscription cursor unchanged.
11200                        self.subs[sub_index] = previous;
11201                        Err(error)
11202                    }
11203                }
11204            }
11205        }
11206    }
11207
11208    fn apply_sub_end(
11209        &mut self,
11210        sub_index: usize,
11211        cursor: i64,
11212        bootstrap_state: Option<String>,
11213    ) -> Result<(), String> {
11214        #[cfg(feature = "bench-internals")]
11215        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
11216        let previous = self.subs[sub_index].clone();
11217        self.begin_observation("syncular_sub_end")?;
11218        let persisted = (|| {
11219            let mut batch = ChangeAccumulator::default();
11220            let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
11221                && cursor >= 0
11222                && bootstrap_state.is_none();
11223            if completed {
11224                if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
11225                    batch.window(&base_key, &previous.table, &unit);
11226                }
11227            }
11228            let sub = &mut self.subs[sub_index];
11229            sub.cursor = cursor;
11230            sub.bootstrap_state = bootstrap_state;
11231            sub.synced_once = true;
11232            self.persist_sub(&self.subs[sub_index])?;
11233            self.finish_observation("syncular_sub_end", batch)
11234        })();
11235        if persisted.is_err() {
11236            self.rollback_observation("syncular_sub_end");
11237            self.subs[sub_index] = previous;
11238        }
11239        persisted
11240    }
11241
11242    fn apply_section_body(
11243        &mut self,
11244        transport: &mut dyn Transport,
11245        sub_index: usize,
11246        body: Vec<Frame>,
11247        fresh: bool,
11248        meta: &RequestMeta,
11249        report: &mut SyncReport,
11250    ) -> Result<(), SectionError> {
11251        let mut saw_segment = false;
11252        for frame in body {
11253            match frame {
11254                Frame::Commit {
11255                    tables, changes, ..
11256                } => {
11257                    self.apply_commit_frame(&tables, &changes)
11258                        .map_err(|(c, m)| SectionError::Abort(c, m))?;
11259                    report.commits_applied += 1;
11260                }
11261                Frame::SegmentInline { payload } => {
11262                    let segment = decode_rows_segment(&payload)
11263                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
11264                    self.progress.update(|p| {
11265                        p.phase = ProgressPhase::Import;
11266                        p.subscription_id = Some(self.subs[sub_index].id.clone());
11267                        p.table = Some(self.subs[sub_index].table.clone());
11268                        p.segment_id = None;
11269                        p.bytes_received = payload.len() as u64;
11270                        p.bytes_total = Some(payload.len() as u64);
11271                        p.rows_processed = 0;
11272                        p.rows_total =
11273                            Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
11274                    });
11275                    let first = !saw_segment;
11276                    saw_segment = true;
11277                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
11278                    report.segment_rows_applied += applied;
11279                }
11280                Frame::SegmentRef {
11281                    segment_id,
11282                    byte_length,
11283                    media_type,
11284                    table,
11285                    row_count,
11286                    as_of_commit_seq,
11287                    scope_digest,
11288                    row_cursor,
11289                    next_row_cursor,
11290                    url,
11291                    url_expires_at_ms,
11292                    ..
11293                } => {
11294                    // §4.2: reject a descriptor whose mediaType was not
11295                    // advertised — never skip or guess.
11296                    let advertised = match media_type {
11297                        MediaType::Rows => {
11298                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
11299                                || meta.accept & ACCEPT_INLINE_ROWS != 0
11300                        }
11301                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
11302                    };
11303                    if !advertised {
11304                        return Err(SectionError::Abort(
11305                            "sync.invalid_request".to_owned(),
11306                            format!(
11307                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
11308                                media_type.name()
11309                            ),
11310                        ));
11311                    }
11312                    self.progress.update(|p| {
11313                        p.phase = ProgressPhase::Download;
11314                        p.subscription_id = Some(self.subs[sub_index].id.clone());
11315                        p.table = Some(table.clone());
11316                        p.segment_id = Some(segment_id.clone());
11317                        p.bytes_received = 0;
11318                        p.bytes_total = u64::try_from(byte_length).ok();
11319                        p.rows_processed = 0;
11320                        p.rows_total = u64::try_from(row_count).ok();
11321                    });
11322                    let observer = self.progress.clone();
11323                    let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
11324                    let bytes = if let Some(url) = url {
11325                        // §5.4: a url-carrying descriptor MUST be fetched
11326                        // from that URL; failure invalidates the whole
11327                        // descriptor (no fall-through to §5.5 — re-pull
11328                        // recovers, §1.4 rule 5).
11329                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
11330                            return Err(SectionError::Abort(
11331                                "sync.invalid_request".to_owned(),
11332                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
11333                                    .to_owned(),
11334                            ));
11335                        }
11336                        // §5.4: MUST NOT start a fetch at/past expiry.
11337                        if url_expires_at_ms.is_some_and(|exp| {
11338                            exp <= transport
11339                                .segment_fetch_started_at(&url)
11340                                .unwrap_or_else(|| self.clock_now_ms())
11341                        }) {
11342                            return Err(SectionError::Abort(
11343                                "sync.segment_expired".to_owned(),
11344                                format!(
11345                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
11346                                ),
11347                            ));
11348                        }
11349                        transport
11350                            .fetch_url(&url, &mut on_progress)
11351                            .map_err(|e| SectionError::Abort(e.code, e.message))?
11352                    } else {
11353                        let requested_scopes_json =
11354                            canonical_scope_json(&self.subs[sub_index].requested);
11355                        transport
11356                            .download_segment(
11357                                &SegmentRequest {
11358                                    segment_id: segment_id.clone(),
11359                                    table,
11360                                    requested_scopes_json,
11361                                },
11362                                &mut on_progress,
11363                            )
11364                            .map_err(|e| SectionError::Abort(e.code, e.message))?
11365                    };
11366                    // §5.1: verify the content address before applying.
11367                    on_progress(bytes.len() as u64);
11368                    let digest = Sha256::digest(&bytes);
11369                    let expected = segment_id
11370                        .strip_prefix("sha256:")
11371                        .unwrap_or(segment_id.as_str());
11372                    if bytes_to_hex(&digest) != expected {
11373                        // §5.1: the client discards the segment and re-pulls.
11374                        self.schedule_background_retry();
11375                        return Err(SectionError::Abort(
11376                            "sync.invalid_request".to_owned(),
11377                            "segment bytes do not match the content address (§5.1)".to_owned(),
11378                        ));
11379                    }
11380                    self.progress.update(|p| p.phase = ProgressPhase::Import);
11381                    if media_type == MediaType::Sqlite {
11382                        // §5.3: images are whole-table — a paged sqlite
11383                        // descriptor is invalid.
11384                        if row_cursor.is_some() || next_row_cursor.is_some() {
11385                            return Err(SectionError::Abort(
11386                                "sync.invalid_request".to_owned(),
11387                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
11388                                    .to_owned(),
11389                            ));
11390                        }
11391                        let first = !saw_segment;
11392                        saw_segment = true;
11393                        let applied = self.apply_sqlite_segment(
11394                            sub_index,
11395                            &bytes,
11396                            fresh && first,
11397                            row_count,
11398                            as_of_commit_seq,
11399                            &scope_digest,
11400                        )?;
11401                        report.segment_rows_applied += applied;
11402                    } else {
11403                        let segment = decode_rows_segment(&bytes).map_err(|e| {
11404                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
11405                        })?;
11406                        let first = row_cursor.is_none();
11407                        saw_segment = true;
11408                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
11409                        report.segment_rows_applied += applied;
11410                    }
11411                }
11412                Frame::Unknown { .. } => {}
11413                Frame::Error { code, message, .. } => {
11414                    return Err(SectionError::Abort(code, message))
11415                }
11416                _ => {
11417                    return Err(SectionError::Abort(
11418                        "sync.invalid_request".to_owned(),
11419                        "unexpected frame inside a subscription section".to_owned(),
11420                    ));
11421                }
11422            }
11423        }
11424        Ok(())
11425    }
11426
11427    // Both pull and realtime frames use the same durable observation boundary.
11428    fn apply_commit_frame(
11429        &mut self,
11430        tables: &[String],
11431        changes: &[ssp2::model::Change],
11432    ) -> Result<(), (String, String)> {
11433        #[cfg(feature = "bench-internals")]
11434        let _phase = self.benchmark_phases.start(Phase::CommitApply);
11435        let was_dirty = self.overlay_dirty.snapshot();
11436        self.begin_observation("syncular_commit")
11437            .map_err(|message| ("storage.failed".into(), message))?;
11438        let applied = (|| {
11439            let mut batch = ChangeAccumulator::default();
11440            self.record_commit_changes(&mut batch, tables, changes);
11441            self.apply_commit_changes(tables, changes)?;
11442            self.rebuild_overlay_if_dirty();
11443            self.finish_observation("syncular_commit", batch)
11444                .map_err(|message| ("storage.failed".into(), message))
11445        })();
11446        if applied.is_err() {
11447            self.rollback_observation("syncular_commit");
11448            self.overlay_dirty.restore(was_dirty.clone());
11449        }
11450        applied
11451    }
11452
11453    fn apply_commit_changes(
11454        &mut self,
11455        tables: &[String],
11456        changes: &[ssp2::model::Change],
11457    ) -> Result<(), (String, String)> {
11458        // A clean overlay can reconcile changed primary keys independently
11459        // when their tables have no secondary unique constraints. Such a
11460        // constraint can change whether another row's pending write succeeds,
11461        // so those frames still require the complete FIFO replay below.
11462        let mirror_visible = !self.overlay_dirty.get()
11463            && self.failed_commits.is_empty()
11464            && (self.outbox.is_empty()
11465                || changes.iter().all(|change| {
11466                    tables
11467                        .get(change.table_index as usize)
11468                        .and_then(|name| self.schema.table(name))
11469                        .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
11470                }));
11471        for change in changes {
11472            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
11473                (
11474                    "sync.invalid_request".to_owned(),
11475                    "change tableIndex out of range".to_owned(),
11476                )
11477            })?;
11478            let table = self.schema.table(table_name).ok_or_else(|| {
11479                (
11480                    "sync.schema_mismatch".to_owned(),
11481                    format!("change targets unknown table {table_name:?}"),
11482                )
11483            })?;
11484            match change.op {
11485                Op::Upsert => {
11486                    let payload = change.row.as_ref().ok_or_else(|| {
11487                        (
11488                            "sync.invalid_request".to_owned(),
11489                            "upsert change without row payload".to_owned(),
11490                        )
11491                    })?;
11492                    // §5.11: decrypt encrypted columns on apply.
11493                    let row = {
11494                        #[cfg(feature = "bench-internals")]
11495                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
11496                        decode_row_bytes(table, payload, &self.encryption)
11497                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?
11498                    };
11499                    let version = change.row_version.unwrap_or(0);
11500                    let table_name = table.name.clone();
11501                    self.write_base_row(&table_name, &row, version)
11502                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
11503                    if mirror_visible {
11504                        self.write_row(&visible_table(&table_name), &table_name, &row, version)
11505                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
11506                    }
11507                }
11508                Op::Delete => {
11509                    self.delete_base_row(table_name, &change.row_id)
11510                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
11511                    if mirror_visible {
11512                        let sql = format!(
11513                            "DELETE FROM {} WHERE {}",
11514                            visible_table(table_name),
11515                            row_id_predicate(table)
11516                        );
11517                        self.conn
11518                            .prepare_cached(&sql)
11519                            .and_then(|mut statement| {
11520                                statement.execute(rusqlite::params![change.row_id])
11521                            })
11522                            .map_err(|error| {
11523                                ("sync.invalid_request".to_owned(), error.to_string())
11524                            })?;
11525                    }
11526                }
11527            }
11528        }
11529        if mirror_visible {
11530            if !self.outbox.is_empty() {
11531                let changed_rows: HashSet<(&str, &str)> = changes
11532                    .iter()
11533                    .map(|change| {
11534                        (
11535                            tables[change.table_index as usize].as_str(),
11536                            change.row_id.as_str(),
11537                        )
11538                    })
11539                    .collect();
11540                self.apply_outbox_ops(
11541                    self.outbox
11542                        .iter()
11543                        .flat_map(|commit| &commit.ops)
11544                        .filter(|op| {
11545                            changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
11546                        }),
11547                );
11548            }
11549            self.overlay_dirty.set(false);
11550        }
11551        Ok(())
11552    }
11553
11554    /// §5.6 segment application: validate against the generated schema,
11555    /// clear the grant on a fresh bootstrap's first page (fail closed
11556    /// without a mapping), then replace-or-upsert each row with its
11557    /// segment-carried server version (§5.2).
11558    fn apply_segment(
11559        &mut self,
11560        sub_index: usize,
11561        segment: &RowsSegment,
11562        first_fresh_page: bool,
11563    ) -> Result<u32, SectionError> {
11564        let (sub_table, effective) = {
11565            let sub = &self.subs[sub_index];
11566            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
11567        };
11568        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
11569            SectionError::Abort(
11570                "sync.schema_mismatch".to_owned(),
11571                format!("subscription table {sub_table:?} is not in the client schema"),
11572            )
11573        })?;
11574        // §5.2: the column table validates against the generated schema —
11575        // order, names, types, nullability; mismatch is fatal. §5.11: the
11576        // server sends the WIRE types (bytes for an encrypted column), so
11577        // validate against wire_columns.
11578        let matches = segment.table == table.name
11579            && segment.schema_version == self.schema.version
11580            && segment.columns.len() == table.wire_columns.len()
11581            && segment
11582                .columns
11583                .iter()
11584                .zip(table.wire_columns.iter())
11585                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
11586        if !matches {
11587            return Err(SectionError::Abort(
11588                "sync.schema_mismatch".to_owned(),
11589                "segment column table does not match the generated schema (§5.2)".to_owned(),
11590            ));
11591        }
11592        let mut applied = 0u32;
11593        let empty = Vec::new();
11594        let blocks = if segment.blocks.is_empty() {
11595            std::slice::from_ref(&empty)
11596        } else {
11597            &segment.blocks
11598        };
11599        for (index, block) in blocks.iter().enumerate() {
11600            let clear = first_fresh_page && index == 0;
11601            if !clear && block.is_empty() {
11602                continue;
11603            }
11604            let was_dirty = self.overlay_dirty.snapshot();
11605            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
11606                && self
11607                    .outbox
11608                    .iter()
11609                    .flat_map(|commit| &commit.ops)
11610                    .any(|op| op.table == table.name);
11611            let mirror_visible = !reconcile_pending && self.failed_commits.is_empty();
11612            self.begin_observation("syncular_segment_block")
11613                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11614            let outcome = (|| {
11615                self.rebuild_overlay_if_dirty();
11616                let mut changed_keys = Vec::new();
11617                let mut batch = ChangeAccumulator::default();
11618                if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
11619                    batch.table(&table.name);
11620                }
11621                if clear {
11622                    self.purge_scope_rows(&table.name, &effective)
11623                        .map_err(|()| SectionError::FailClosed)?;
11624                }
11625                for (row_index, row) in block.iter().enumerate() {
11626                    let decrypted;
11627                    let values = if table.has_encrypted_columns() {
11628                        let mut values = row.values.clone();
11629                        crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
11630                            .map_err(|message| {
11631                            SectionError::Abort("client.decrypt_failed".into(), message)
11632                        })?;
11633                        decrypted = values;
11634                        &decrypted
11635                    } else {
11636                        &row.values
11637                    };
11638                    self.write_base_row(&table.name, values, row.server_version)
11639                        .map_err(|message| {
11640                            SectionError::Abort("sync.invalid_request".into(), message)
11641                        })?;
11642                    if reconcile_pending {
11643                        changed_keys.push(
11644                            owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
11645                                |message| SectionError::Abort("storage.failed".into(), message),
11646                            )?,
11647                        );
11648                    }
11649                    if mirror_visible {
11650                        self.write_row(
11651                            &visible_table(&table.name),
11652                            &table.name,
11653                            values,
11654                            row.server_version,
11655                        )
11656                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11657                    }
11658                    let processed = u64::from(applied) + row_index as u64 + 1;
11659                    if processed.is_multiple_of(1024) {
11660                        self.progress.update(|p| p.rows_processed = processed);
11661                    }
11662                }
11663                if reconcile_pending {
11664                    self.reconcile_imported_rows(&table, &mut changed_keys)
11665                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11666                }
11667                if self.failed_commits.is_empty() {
11668                    self.apply_outbox_ops(
11669                        self.outbox
11670                            .iter()
11671                            .flat_map(|commit| &commit.ops)
11672                            .filter(|op| op.table == table.name),
11673                    );
11674                    self.overlay_dirty.set(false);
11675                } else {
11676                    self.rebuild_overlay();
11677                }
11678                self.finish_observation("syncular_segment_block", batch)
11679                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
11680            })();
11681            if let Err(error) = outcome {
11682                self.rollback_observation("syncular_segment_block");
11683                self.overlay_dirty.restore(was_dirty.clone());
11684                return Err(error);
11685            }
11686            applied += block.len() as u32;
11687            self.progress
11688                .update(|p| p.rows_processed = u64::from(applied));
11689            std::thread::yield_now();
11690        }
11691        self.progress
11692            .update(|p| p.rows_processed = u64::from(applied));
11693        Ok(applied)
11694    }
11695
11696    /// §5.3 sqlite-image application: validate the in-file metadata
11697    /// against the descriptor, validate column names/order against the
11698    /// generated schema, run the §5.6 first-page clear when fresh, then
11699    /// replace-or-upsert every image row with its `_syncular_version`.
11700    /// Mechanics: the image lands in a temp file read through a second
11701    /// rusqlite connection, equivalent to ATTACH + INSERT…SELECT.
11702    /// Each image chunk commits its writes and revision before readers continue.
11703    fn apply_sqlite_segment(
11704        &mut self,
11705        sub_index: usize,
11706        bytes: &[u8],
11707        first_fresh_page: bool,
11708        row_count: i64,
11709        as_of_commit_seq: i64,
11710        scope_digest: &str,
11711    ) -> Result<u32, SectionError> {
11712        let invalid = |detail: &str| {
11713            SectionError::Abort(
11714                "sync.invalid_request".to_owned(),
11715                format!("sqlite segment rejected: {detail} (§5.3)"),
11716            )
11717        };
11718        let (sub_table, effective) = {
11719            let sub = &self.subs[sub_index];
11720            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
11721        };
11722        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
11723            SectionError::Abort(
11724                "sync.schema_mismatch".to_owned(),
11725                format!("subscription table {sub_table:?} is not in the client schema"),
11726            )
11727        })?;
11728
11729        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
11730        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
11731        let img = match rusqlite::Connection::open_with_flags(
11732            &path,
11733            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
11734        ) {
11735            Ok(conn) => conn,
11736            Err(_) => {
11737                let _ = std::fs::remove_file(&path);
11738                return Err(invalid("bytes do not open as a SQLite database"));
11739            }
11740        };
11741        let outcome = self.apply_sqlite_image(
11742            &img,
11743            &table,
11744            first_fresh_page,
11745            &effective,
11746            row_count,
11747            as_of_commit_seq,
11748            scope_digest,
11749        );
11750        drop(img);
11751        let _ = std::fs::remove_file(&path);
11752        outcome
11753    }
11754
11755    #[allow(clippy::too_many_arguments)]
11756    fn apply_sqlite_image(
11757        &mut self,
11758        img: &rusqlite::Connection,
11759        table: &crate::schema::TableSchema,
11760        first_fresh_page: bool,
11761        effective: &[(String, Vec<String>)],
11762        row_count: i64,
11763        as_of_commit_seq: i64,
11764        scope_digest: &str,
11765    ) -> Result<u32, SectionError> {
11766        let invalid = |detail: String| {
11767            SectionError::Abort(
11768                "sync.invalid_request".to_owned(),
11769                format!("sqlite segment rejected: {detail} (§5.3)"),
11770            )
11771        };
11772
11773        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
11774        //    one row).
11775        type MetaRow = (i64, String, i64, i64, String, i64, i64);
11776        let meta: MetaRow = img
11777            .query_row(
11778                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
11779                        \"scopeDigest\", \"rowCount\",
11780                        (SELECT count(*) FROM _syncular_segment)
11781                 FROM _syncular_segment",
11782                [],
11783                |row| {
11784                    Ok((
11785                        row.get(0)?,
11786                        row.get(1)?,
11787                        row.get(2)?,
11788                        row.get(3)?,
11789                        row.get(4)?,
11790                        row.get(5)?,
11791                        row.get(6)?,
11792                    ))
11793                },
11794            )
11795            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
11796        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
11797        if meta_count != 1 {
11798            return Err(invalid(format!(
11799                "_syncular_segment must contain exactly one row, found {meta_count}"
11800            )));
11801        }
11802        if format != 1 {
11803            return Err(invalid(format!("format {format}")));
11804        }
11805        if meta_table != table.name {
11806            return Err(invalid(format!("image table {meta_table:?}")));
11807        }
11808        if schema_version != i64::from(self.schema.version) {
11809            return Err(invalid(format!("schemaVersion {schema_version}")));
11810        }
11811        if pin != as_of_commit_seq {
11812            return Err(invalid(format!("asOfCommitSeq {pin}")));
11813        }
11814        if digest != scope_digest {
11815            return Err(invalid("scopeDigest mismatch".to_owned()));
11816        }
11817        if meta_rows != row_count {
11818            return Err(invalid(format!("rowCount {meta_rows}")));
11819        }
11820
11821        // 2. Column names and order vs the generated schema (§5.3 rule 3).
11822        let mut names: Vec<String> = Vec::new();
11823        let mut primary_keys: Vec<String> = Vec::new();
11824        {
11825            let mut stmt = img
11826                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
11827                .map_err(|_| invalid("image data table missing".to_owned()))?;
11828            let mut rows = stmt
11829                .query([])
11830                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
11831            while let Some(row) = rows
11832                .next()
11833                .map_err(|_| invalid("image data table unreadable".to_owned()))?
11834            {
11835                if row
11836                    .get::<_, i64>(5)
11837                    .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
11838                    > 0
11839                {
11840                    primary_keys.push(row.get(1).map_err(|error| {
11841                        invalid(Self::sqlite_failure(&self.storage_failure, error))
11842                    })?);
11843                }
11844                names.push(
11845                    row.get::<_, String>(1)
11846                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
11847                );
11848            }
11849        }
11850        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
11851        expected.push("_syncular_version");
11852        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
11853            return Err(SectionError::Abort(
11854                "sync.schema_mismatch".to_owned(),
11855                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
11856            ));
11857        }
11858
11859        if primary_keys != vec![table.primary_key.clone()] {
11860            return Err(invalid(
11861                "image primary key does not match generated schema".into(),
11862            ));
11863        }
11864        let actual_count: i64 = img
11865            .query_row(
11866                &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
11867                [],
11868                |row| row.get(0),
11869            )
11870            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
11871        if actual_count != row_count {
11872            return Err(invalid("image row count does not match descriptor".into()));
11873        }
11874        let insert = self.insert_row_sql(&base_table(&table.name), table);
11875        let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
11876        let column_list = names
11877            .iter()
11878            .map(|n| quote_ident(n))
11879            .collect::<Vec<_>>()
11880            .join(", ");
11881        let mut stmt = img
11882            .prepare(&format!(
11883                "SELECT {column_list} FROM {} ORDER BY {}",
11884                quote_ident(&table.name),
11885                quote_ident(&table.primary_key)
11886            ))
11887            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
11888        let mut rows = stmt
11889            .query([])
11890            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
11891        let mut applied = 0u32;
11892        loop {
11893            let clear = applied == 0 && first_fresh_page;
11894            let was_dirty = self.overlay_dirty.snapshot();
11895            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
11896                && self
11897                    .outbox
11898                    .iter()
11899                    .flat_map(|commit| &commit.ops)
11900                    .any(|op| op.table == table.name);
11901            let mirror_visible = !reconcile_pending && self.failed_commits.is_empty();
11902            self.begin_observation("syncular_image_chunk")
11903                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11904            let result = (|| {
11905                self.rebuild_overlay_if_dirty();
11906                let mut changed_keys = Vec::new();
11907                let mut batch = ChangeAccumulator::default();
11908                if clear {
11909                    if self.scoped_rows_exist(&table.name, effective) {
11910                        batch.table(&table.name);
11911                    }
11912                    self.purge_scope_rows(&table.name, effective)
11913                        .map_err(|()| SectionError::FailClosed)?;
11914                }
11915                let mut processed = 0u32;
11916                {
11917                    let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
11918                        invalid(Self::sqlite_failure(&self.storage_failure, error))
11919                    })?;
11920                    let mut mirror = if mirror_visible {
11921                        Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
11922                            invalid(Self::sqlite_failure(&self.storage_failure, error))
11923                        })?)
11924                    } else {
11925                        None
11926                    };
11927                    for _ in 0..1024 {
11928                        let Some(row) = rows.next().map_err(|error| {
11929                            invalid(Self::sqlite_failure(&self.storage_failure, error))
11930                        })?
11931                        else {
11932                            break;
11933                        };
11934                        for (i, column) in table.columns.iter().enumerate() {
11935                            let cell = row.get_ref(i).map_err(|error| {
11936                                invalid(Self::sqlite_failure(&self.storage_failure, error))
11937                            })?;
11938                            let param = image_cell_param(column, cell).map_err(invalid)?;
11939                            ins.raw_bind_parameter(i + 1, &param).map_err(|error| {
11940                                invalid(Self::sqlite_failure(&self.storage_failure, error))
11941                            })?;
11942                            if let Some(mirror) = &mut mirror {
11943                                mirror.raw_bind_parameter(i + 1, &param).map_err(|error| {
11944                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
11945                                })?;
11946                            }
11947                        }
11948                        let version_index = table.columns.len();
11949                        let version: i64 = row.get(version_index).map_err(|error| {
11950                            invalid(Self::sqlite_failure(&self.storage_failure, error))
11951                        })?;
11952                        if version < 1 {
11953                            return Err(invalid("image row version must be positive".into()));
11954                        }
11955                        ins.raw_bind_parameter(version_index + 1, version)
11956                            .map_err(|error| {
11957                                invalid(Self::sqlite_failure(&self.storage_failure, error))
11958                            })?;
11959                        ins.raw_execute().map_err(|error| {
11960                            invalid(Self::sqlite_failure(&self.storage_failure, error))
11961                        })?;
11962                        if let Some(mirror) = &mut mirror {
11963                            mirror
11964                                .raw_bind_parameter(version_index + 1, version)
11965                                .map_err(|error| {
11966                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
11967                                })?;
11968                            mirror.raw_execute().map_err(|error| {
11969                                invalid(Self::sqlite_failure(&self.storage_failure, error))
11970                            })?;
11971                        }
11972                        if reconcile_pending {
11973                            changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
11974                                |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
11975                            )?);
11976                        }
11977                        processed += 1;
11978                    }
11979                }
11980                if processed > 0 {
11981                    batch.table(&table.name);
11982                    self.overlay_dirty.table(&table.name);
11983                }
11984                if reconcile_pending {
11985                    self.reconcile_imported_rows(table, &mut changed_keys)
11986                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11987                }
11988                if self.failed_commits.is_empty() {
11989                    self.apply_outbox_ops(
11990                        self.outbox
11991                            .iter()
11992                            .flat_map(|commit| &commit.ops)
11993                            .filter(|op| op.table == table.name),
11994                    );
11995                    self.overlay_dirty.set(false);
11996                } else {
11997                    self.rebuild_overlay();
11998                }
11999                self.finish_observation("syncular_image_chunk", batch)
12000                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12001                Ok(processed)
12002            })();
12003            let processed = match result {
12004                Ok(processed) => processed,
12005                Err(error) => {
12006                    self.rollback_observation("syncular_image_chunk");
12007                    self.overlay_dirty.restore(was_dirty.clone());
12008                    return Err(error);
12009                }
12010            };
12011            applied += processed;
12012            self.progress
12013                .update(|p| p.rows_processed = u64::from(applied));
12014            std::thread::yield_now();
12015            if i64::from(applied) == actual_count {
12016                break;
12017            }
12018        }
12019        Ok(applied)
12020    }
12021
12022    /// Restore only imported and pending row identities before FIFO replay. All pending
12023    /// identities participate: a server row can free a unique value for a different edit.
12024    /// The caller owns the chunk transaction and publishes its revision after replay.
12025    fn reconcile_imported_rows(
12026        &self,
12027        table: &TableSchema,
12028        keys: &mut Vec<SqlValue>,
12029    ) -> Result<(), String> {
12030        let visible = visible_table(&table.name);
12031        let base = base_table(&table.name);
12032        let pk = quote_ident(&table.primary_key);
12033        let predicate = row_id_predicate(table);
12034        let mut lookup = self.conn.prepare_cached(&format!(
12035            "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
12036        )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12037        let mut deleted = HashSet::new();
12038        for op in self
12039            .outbox
12040            .iter()
12041            .flat_map(|commit| &commit.ops)
12042            .filter(|op| op.table == table.name)
12043        {
12044            if op.upsert {
12045                if let Some(values) = &op.values {
12046                    let key = json_to_column_value(
12047                        &table.columns[table.pk_index],
12048                        values.get(&table.primary_key),
12049                    )?;
12050                    keys.push(owned_sql_value(RowParam::Cell(&key))?);
12051                }
12052            } else if deleted.insert(&op.row_id) {
12053                keys.extend(
12054                    lookup
12055                        .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
12056                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
12057                        .collect::<Result<Vec<_>, _>>()
12058                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
12059                );
12060            }
12061        }
12062        // Delete the complete affected set before restoring any base value, so
12063        // old optimistic unique values cannot collide with the new server rows.
12064        for chunk in keys.chunks(400) {
12065            let holes = vec!["?"; chunk.len()].join(",");
12066            self.conn
12067                .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
12068                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
12069                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12070        }
12071        let updates = table
12072            .columns
12073            .iter()
12074            .map(|column| column.name.as_str())
12075            .chain(std::iter::once("_syncular_version"))
12076            .filter(|name| *name != table.primary_key)
12077            .map(|name| {
12078                let name = quote_ident(name);
12079                format!("{name}=excluded.{name}")
12080            })
12081            .collect::<Vec<_>>()
12082            .join(",");
12083        for chunk in keys.chunks(400) {
12084            let holes = vec!["?"; chunk.len()].join(",");
12085            self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
12086                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
12087                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12088        }
12089        Ok(())
12090    }
12091
12092    // -- application-authorized local purge ---------------------------------------
12093
12094    fn compile_local_data_purge(
12095        &self,
12096        input: &LocalDataPurgeInput,
12097    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
12098        let invalid = |message: String| format!("sync.invalid_request: {message}");
12099        if input.purge_id.is_empty()
12100            || input.purge_id.len() > 128
12101            || !is_local_operation_code_like(&input.purge_id)
12102        {
12103            return Err(invalid(
12104                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
12105            ));
12106        }
12107        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
12108            return Err(invalid(format!(
12109                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
12110            )));
12111        }
12112
12113        let mut deduplicated: BTreeMap<
12114            String,
12115            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
12116        > = BTreeMap::new();
12117        for target in &input.targets {
12118            let table = self.schema.table(&target.table).ok_or_else(|| {
12119                invalid(format!(
12120                    "local purge names unknown table {:?}",
12121                    target.table
12122                ))
12123            })?;
12124            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
12125                return Err(invalid(format!(
12126                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
12127                    target.table
12128                )));
12129            }
12130            let mut selectors = Vec::with_capacity(target.selectors.len());
12131            let mut canonical_selectors = BTreeMap::new();
12132            for (column_name, raw_values) in &target.selectors {
12133                let Some((column_index, column)) = table
12134                    .columns
12135                    .iter()
12136                    .enumerate()
12137                    .find(|(_, column)| column.name == *column_name)
12138                else {
12139                    return Err(invalid(format!(
12140                        "local purge target {:?} names unknown column {:?}",
12141                        target.table, column_name
12142                    )));
12143                };
12144                let encrypted = table
12145                    .encrypted_columns
12146                    .iter()
12147                    .any(|candidate| candidate.index == column_index);
12148                if column.ty != ColumnType::String || encrypted {
12149                    return Err(invalid(format!(
12150                        "local purge selector {:?}.{:?} must be a plaintext string column",
12151                        target.table, column_name
12152                    )));
12153                }
12154                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
12155                    return Err(invalid(format!(
12156                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
12157                        target.table, column_name
12158                    )));
12159                }
12160                let mut values = raw_values.clone();
12161                values.sort();
12162                values.dedup();
12163                if values.iter().any(|value| {
12164                    value.is_empty()
12165                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
12166                        || !is_local_operation_code_like(value)
12167                }) {
12168                    return Err(invalid(format!(
12169                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
12170                    )));
12171                }
12172                selectors.push((column_name.clone(), values.clone()));
12173                canonical_selectors.insert(column_name.clone(), values);
12174            }
12175            let canonical = LocalDataPurgeTarget {
12176                table: target.table.clone(),
12177                selectors: canonical_selectors,
12178            };
12179            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
12180            deduplicated.insert(
12181                key,
12182                (
12183                    CompiledLocalDataPurgeTarget {
12184                        table: target.table.clone(),
12185                        selectors,
12186                    },
12187                    canonical,
12188                ),
12189            );
12190        }
12191        let targets = deduplicated
12192            .values()
12193            .map(|(compiled, _)| compiled.clone())
12194            .collect::<Vec<_>>();
12195        let canonical = deduplicated
12196            .values()
12197            .map(|(_, target)| target.clone())
12198            .collect::<Vec<_>>();
12199        let canonical_plan =
12200            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
12201        Ok((targets, canonical_plan))
12202    }
12203
12204    fn local_purge_base_row_ids(
12205        &self,
12206        targets: &[CompiledLocalDataPurgeTarget],
12207    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
12208        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12209        for target in targets {
12210            let table = self
12211                .schema
12212                .table(&target.table)
12213                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
12214            let mut clauses = Vec::with_capacity(target.selectors.len());
12215            let mut params = Vec::new();
12216            for (column, values) in &target.selectors {
12217                clauses.push(format!(
12218                    "{} IN ({})",
12219                    quote_ident(column),
12220                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
12221                ));
12222                params.extend(values.iter().cloned().map(SqlValue::Text));
12223            }
12224            let sql = format!(
12225                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
12226                quote_ident(&table.primary_key),
12227                base_table(&target.table),
12228                clauses.join(" AND ")
12229            );
12230            let mut statement = self
12231                .conn
12232                .prepare(&sql)
12233                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12234            let rows = statement
12235                .query_map(rusqlite::params_from_iter(params), |row| {
12236                    let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
12237                    render_row_id_json(Some(&value))
12238                        .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
12239                })
12240                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12241            let ids = by_table.entry(target.table.clone()).or_default();
12242            for row in rows {
12243                ids.insert(
12244                    row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
12245                );
12246            }
12247        }
12248        Ok(by_table)
12249    }
12250
12251    fn local_purge_values_match(
12252        target: &CompiledLocalDataPurgeTarget,
12253        values: &Map<String, Value>,
12254    ) -> bool {
12255        target.selectors.iter().all(|(column, allowed)| {
12256            values
12257                .get(column)
12258                .and_then(Value::as_str)
12259                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
12260        })
12261    }
12262
12263    /// Apply one host-authorized local security purge. The host MUST gate the
12264    /// corresponding subscriptions first; this primitive owns local SQLite
12265    /// cleanup and never grants/revokes server authority by itself.
12266    pub fn purge_local_data(
12267        &mut self,
12268        input: &LocalDataPurgeInput,
12269    ) -> Result<LocalDataPurgeResult, String> {
12270        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
12271        self.cancel_sync_round();
12272        let meta_key = format!("localPurge:{}", input.purge_id);
12273        let applied_plan = self
12274            .conn
12275            .query_row(
12276                "SELECT value FROM _syncular_meta WHERE key = ?1",
12277                rusqlite::params![meta_key],
12278                |row| row.get::<_, String>(0),
12279            )
12280            .optional()
12281            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12282        if let Some(applied_plan) = applied_plan {
12283            if applied_plan != canonical_plan {
12284                return Err(format!(
12285                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
12286                    input.purge_id
12287                ));
12288            }
12289            return Ok(LocalDataPurgeResult {
12290                already_applied: true,
12291                purged_rows: 0,
12292                dropped_commits: 0,
12293            });
12294        }
12295
12296        // RFC 0005 D7: the container file cannot be a purge target (its name is
12297        // not in the running schema), so purge drops it as a fixed step, before
12298        // any mirror row is touched and unconditionally.
12299        let _ = self.drop_previous_version();
12300
12301        let prior_outbox = self.outbox.clone();
12302        let prior_failed = self.failed_commits.clone();
12303        let prior_rejections = self.rejections.clone();
12304        let prior_conflicts = self.conflicts.clone();
12305        let prior_overlay_dirty = self.overlay_dirty.snapshot();
12306        self.begin_observation("syncular_local_purge")?;
12307        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
12308            let row_ids = self.local_purge_base_row_ids(&targets)?;
12309            let retained_dropped = self.drop_failed_commits(|table, values| {
12310                targets.iter().any(|target| {
12311                    target.table == table && Self::local_purge_values_match(target, values)
12312                })
12313            })?;
12314            let doomed = self
12315                .outbox
12316                .iter()
12317                .filter(|commit| {
12318                    commit.ops.iter().any(|operation| {
12319                        let matching_targets = targets
12320                            .iter()
12321                            .filter(|target| target.table == operation.table)
12322                            .collect::<Vec<_>>();
12323                        if matching_targets.is_empty() {
12324                            return false;
12325                        }
12326                        if row_ids
12327                            .get(&operation.table)
12328                            .is_some_and(|ids| ids.contains(&operation.row_id))
12329                        {
12330                            return true;
12331                        }
12332                        operation.values.as_ref().is_some_and(|values| {
12333                            matching_targets
12334                                .iter()
12335                                .any(|target| Self::local_purge_values_match(target, values))
12336                        })
12337                    })
12338                })
12339                .cloned()
12340                .collect::<Vec<_>>();
12341            let doomed_ids = doomed
12342                .iter()
12343                .map(|commit| commit.client_commit_id.clone())
12344                .collect::<BTreeSet<_>>();
12345            let mut batch = ChangeAccumulator::default();
12346            for commit in &retained_dropped {
12347                batch.conflicts = true;
12348                batch.rejections = true;
12349                batch.outcomes = true;
12350                for operation in &commit.ops {
12351                    batch.table(&operation.table);
12352                }
12353            }
12354            let mut rejections = Vec::new();
12355            for commit in &doomed {
12356                for operation in &commit.ops {
12357                    batch.table(&operation.table);
12358                }
12359                let results = commit
12360                    .ops
12361                    .iter()
12362                    .enumerate()
12363                    .map(|(op_index, operation)| {
12364                        let rejection = RejectionRecord {
12365                            client_commit_id: commit.client_commit_id.clone(),
12366                            op_index: op_index as i32,
12367                            code: "client.local_data_purged".to_owned(),
12368                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
12369                            retryable: false,
12370                            details: None,
12371                            operation: Some(CommitOperation::from(operation)),
12372                        };
12373                        rejections.push(rejection.clone());
12374                        CommitOperationOutcome::Error { rejection }
12375                    })
12376                    .collect::<Vec<_>>();
12377                self.persist_commit_outcome(
12378                    &commit.client_commit_id,
12379                    CommitOutcomeStatus::Rejected,
12380                    &results,
12381                    Some(&commit.ops),
12382                )?;
12383                self.delete_outbox_persisted(&commit.client_commit_id)?;
12384            }
12385            if !doomed.is_empty() {
12386                self.outbox
12387                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
12388                self.rejections.extend(rejections);
12389                self.prune_commit_outcomes()?;
12390                batch.status = true;
12391                batch.rejections = true;
12392                batch.outcomes = true;
12393            }
12394
12395            let mut purged_rows = 0usize;
12396            for (table, ids) in &row_ids {
12397                if ids.is_empty() {
12398                    continue;
12399                }
12400                batch.table(table);
12401                for row_id in ids {
12402                    self.delete_base_row(table, row_id)?;
12403                    purged_rows += 1;
12404                }
12405            }
12406            self.rebuild_overlay_if_dirty();
12407            self.delete_unreferenced_cached_blobs();
12408            self.conn
12409                .execute(
12410                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
12411                    rusqlite::params![meta_key, canonical_plan],
12412                )
12413                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12414            Ok((
12415                batch,
12416                LocalDataPurgeResult {
12417                    already_applied: false,
12418                    purged_rows,
12419                    dropped_commits: doomed.len(),
12420                },
12421            ))
12422        })();
12423        let (batch, result) = match applied {
12424            Ok(value) => value,
12425            Err(error) => {
12426                self.rollback_observation("syncular_local_purge");
12427                self.outbox = prior_outbox;
12428                self.failed_commits = prior_failed;
12429                self.rejections = prior_rejections;
12430                self.conflicts = prior_conflicts;
12431                self.overlay_dirty.restore(prior_overlay_dirty.clone());
12432                return Err(error);
12433            }
12434        };
12435        if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
12436            self.rollback_observation("syncular_local_purge");
12437            self.outbox = prior_outbox;
12438            self.failed_commits = prior_failed;
12439            self.rejections = prior_rejections;
12440            self.conflicts = prior_conflicts;
12441            self.overlay_dirty.restore(prior_overlay_dirty.clone());
12442            return Err(error);
12443        }
12444        Ok(result)
12445    }
12446
12447    /// Application-authorized recovery of the replicated projection. Unlike a
12448    /// local security purge, this retains the entire outbox, device identity,
12449    /// lease, outcomes, subscription registrations, and protected bookkeeping.
12450    /// The projection reset, subscription rewind, optimistic replay, and
12451    /// idempotency marker share one savepoint for interruption safety.
12452    pub fn rebootstrap_local_data(
12453        &mut self,
12454        input: &LocalDataRebootstrapInput,
12455    ) -> Result<LocalDataRebootstrapResult, String> {
12456        if input.rebootstrap_id.is_empty()
12457            || input.rebootstrap_id.len() > 128
12458            || !is_local_operation_code_like(&input.rebootstrap_id)
12459        {
12460            return Err(
12461                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
12462                    .to_owned(),
12463            );
12464        }
12465        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
12466        if let Some(persisted) = self.get_meta_strict(&meta_key)? {
12467            let (retained_commits, reset_subscriptions) =
12468                decode_local_rebootstrap_receipt(&persisted)?;
12469            return Ok(LocalDataRebootstrapResult {
12470                already_applied: true,
12471                retained_commits,
12472                reset_subscriptions,
12473            });
12474        }
12475        if self.stopped || self.schema_floor.is_some() {
12476            return Err(
12477                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
12478                    .to_owned(),
12479            );
12480        }
12481
12482        let retained_commits = self.outbox.len();
12483        let reset_subscriptions = self.subs.len();
12484        let prior_subs = self.subs.clone();
12485        let prior_upgrading = self.upgrading;
12486        let prior_stopped = self.stopped;
12487        let prior_schema_floor = self.schema_floor.clone();
12488        let prior_overlay_dirty = self.overlay_dirty.snapshot();
12489        let prior_sync_needed = self.sync_needed;
12490        let prior_sync_intents = self.sync_intent_queue.clone();
12491        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
12492
12493        self.cancel_sync_round();
12494        self.begin_observation("syncular_local_rebootstrap")?;
12495        let mut batch = ChangeAccumulator::default();
12496        let applied = (|| -> Result<(), String> {
12497            self.run_schema_reset_observed(&mut batch, false, false)?;
12498            self.conn
12499                .execute(
12500                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
12501                    rusqlite::params![meta_key, receipt],
12502                )
12503                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12504            self.sync_needed = true;
12505            self.sync_intent_queue.push_back(SyncIntent::Interactive);
12506            batch.status = true;
12507            Ok(())
12508        })();
12509        if let Err(error) = applied {
12510            self.rollback_observation("syncular_local_rebootstrap");
12511            self.subs = prior_subs;
12512            self.upgrading = prior_upgrading;
12513            self.stopped = prior_stopped;
12514            self.schema_floor = prior_schema_floor;
12515            self.overlay_dirty.restore(prior_overlay_dirty.clone());
12516            self.sync_needed = prior_sync_needed;
12517            self.sync_intent_queue = prior_sync_intents;
12518            return Err(error);
12519        }
12520        if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
12521            self.rollback_observation("syncular_local_rebootstrap");
12522            self.subs = prior_subs;
12523            self.upgrading = prior_upgrading;
12524            self.stopped = prior_stopped;
12525            self.schema_floor = prior_schema_floor;
12526            self.overlay_dirty.restore(prior_overlay_dirty.clone());
12527            self.sync_needed = prior_sync_needed;
12528            self.sync_intent_queue = prior_sync_intents;
12529            return Err(error);
12530        }
12531
12532        Ok(LocalDataRebootstrapResult {
12533            already_applied: false,
12534            retained_commits,
12535            reset_subscriptions,
12536        })
12537    }
12538
12539    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
12540
12541    /// Delete base rows matching the effective scopes; `Err(())` = no local
12542    /// scope-column mapping for a key (the fail-closed case).
12543    fn purge_scope_rows(
12544        &mut self,
12545        table_name: &str,
12546        effective: &[(String, Vec<String>)],
12547    ) -> Result<(), ()> {
12548        if effective.is_empty() {
12549            return Ok(());
12550        }
12551        let table = self.schema.table(table_name).ok_or(())?.clone();
12552        let mut clauses = Vec::new();
12553        let mut params: Vec<SqlValue> = Vec::new();
12554        for (variable, values) in effective {
12555            let column = table.scope_column(variable).ok_or(())?;
12556            let placeholders: Vec<String> = values
12557                .iter()
12558                .map(|v| {
12559                    params.push(SqlValue::Text(v.clone()));
12560                    "?".to_owned()
12561                })
12562                .collect();
12563            clauses.push(format!(
12564                "{} IN ({})",
12565                quote_ident(column),
12566                placeholders.join(", ")
12567            ));
12568        }
12569        let sql = format!(
12570            "DELETE FROM {} WHERE {}",
12571            base_table(table_name),
12572            clauses.join(" AND ")
12573        );
12574        self.overlay_dirty.table(table_name);
12575        self.conn
12576            .execute(&sql, rusqlite::params_from_iter(&params))
12577            .map_err(|_| ())?;
12578        self.conn
12579            .execute(
12580                &format!(
12581                    "DELETE FROM {} WHERE {}",
12582                    visible_table(table_name),
12583                    clauses.join(" AND ")
12584                ),
12585                rusqlite::params_from_iter(&params),
12586            )
12587            .map_err(|_| ())?;
12588        Ok(())
12589    }
12590
12591    /// §3.3: drop pending commits whose upserts provably land in the
12592    /// revoked effective scopes — whole-commit, never per-operation.
12593    fn drop_doomed_outbox(
12594        &mut self,
12595        table_name: &str,
12596        effective: &[(String, Vec<String>)],
12597    ) -> Result<bool, String> {
12598        if effective.is_empty() {
12599            return Ok(false);
12600        }
12601        let Some(table) = self.schema.table(table_name).cloned() else {
12602            return Ok(false);
12603        };
12604        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
12605        for (variable, values) in effective {
12606            match table.scope_column(variable) {
12607                Some(column) => mappings.push((column, values)),
12608                None => return Ok(false), // not provable without a mapping
12609            }
12610        }
12611        let doomed: Vec<OutboxCommit> = self
12612            .outbox
12613            .iter()
12614            .filter(|commit| {
12615                commit.ops.iter().any(|op| {
12616                    op.upsert
12617                        && op.table == table_name
12618                        && op.values.as_ref().is_some_and(|values| {
12619                            mappings
12620                                .iter()
12621                                .all(|(column, allowed)| match values.get(*column) {
12622                                    Some(Value::String(s)) => allowed.contains(s),
12623                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
12624                                    _ => false,
12625                                })
12626                        })
12627                })
12628            })
12629            .cloned()
12630            .collect();
12631        if doomed.is_empty() {
12632            return Ok(false);
12633        }
12634        let mut rejections = Vec::new();
12635        for commit in &doomed {
12636            let results = commit
12637                .ops
12638                .iter()
12639                .enumerate()
12640                .map(|(op_index, operation)| {
12641                    let rejection = RejectionRecord {
12642                        client_commit_id: commit.client_commit_id.clone(),
12643                        op_index: op_index as i32,
12644                        code: "sync.scope_revoked".to_owned(),
12645                        message: "the commit was dropped because its effective scope was revoked"
12646                            .to_owned(),
12647                        retryable: false,
12648                        details: None,
12649                        operation: Some(CommitOperation::from(operation)),
12650                    };
12651                    rejections.push(rejection.clone());
12652                    CommitOperationOutcome::Error { rejection }
12653                })
12654                .collect::<Vec<_>>();
12655            self.persist_commit_outcome(
12656                &commit.client_commit_id,
12657                CommitOutcomeStatus::Rejected,
12658                &results,
12659                Some(&commit.ops),
12660            )?;
12661            self.delete_outbox_persisted(&commit.client_commit_id)?;
12662        }
12663        self.prune_commit_outcomes()?;
12664        let doomed_ids = doomed
12665            .iter()
12666            .map(|commit| commit.client_commit_id.as_str())
12667            .collect::<BTreeSet<_>>();
12668        self.outbox
12669            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
12670        self.rejections.extend(rejections);
12671        Ok(true)
12672    }
12673
12674    // -- blobs (§5.9) ----------------------------------------------------------------
12675
12676    /// §5.9.7: hash bytes into the content address, cache them, queue the
12677    /// upload (flushed before the next push, B4). Returns the canonical
12678    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
12679    /// column value.
12680    pub fn upload_blob(
12681        &mut self,
12682        bytes: &[u8],
12683        media_type: Option<String>,
12684        name: Option<String>,
12685    ) -> Result<Value, String> {
12686        let blob_id = blob_id_for(bytes);
12687        let now = self.clock_now_ms();
12688        let tx = self
12689            .conn
12690            .transaction()
12691            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12692        tx.execute(
12693            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
12694             ON CONFLICT(blob_id) DO NOTHING",
12695            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
12696        )
12697        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12698        tx.execute(
12699            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
12700            rusqlite::params![blob_id, media_type, now],
12701        )
12702        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12703        tx.commit()
12704            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12705        self.enforce_blob_cache_cap();
12706        let mut obj = Map::new();
12707        obj.insert("blobId".to_owned(), Value::from(blob_id));
12708        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
12709        if let Some(mt) = media_type {
12710            obj.insert("mediaType".to_owned(), Value::from(mt));
12711        }
12712        if let Some(n) = name {
12713            obj.insert("name".to_owned(), Value::from(n));
12714        }
12715        Ok(Value::Object(obj))
12716    }
12717
12718    /// §5.9.7: resolve an owned blob body without driver encoding. A
12719    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
12720    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
12721    pub fn fetch_blob_bytes(
12722        &mut self,
12723        transport: &mut dyn Transport,
12724        blob_id_or_ref: &str,
12725    ) -> Result<FetchedBlob, (String, String)> {
12726        let simple = |m: String| ("client.failed".to_owned(), m);
12727        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
12728            blob_id_or_ref.to_owned()
12729        } else {
12730            let value: Value = serde_json::from_str(blob_id_or_ref)
12731                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
12732            value
12733                .get("blobId")
12734                .and_then(Value::as_str)
12735                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
12736                .to_owned()
12737        };
12738        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
12739            return Ok(cached);
12740        }
12741        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
12742        // blob.not_found) verbatim so the harness can assert on it. The
12743        // authorized endpoint serves bytes inline OR (always-issue, presign
12744        // configured) a signed url the client fetches directly — no host auth,
12745        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
12746        #[cfg(feature = "bench-internals")]
12747        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
12748        let bytes = match transport
12749            .blob_download(&blob_id)
12750            .map_err(|e| (e.code, e.message))?
12751        {
12752            BlobDownload::Bytes(bytes) => bytes,
12753            BlobDownload::Url {
12754                url,
12755                url_expires_at_ms,
12756            } => {
12757                // §5.9.5: MUST NOT start a fetch at/past expiry.
12758                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
12759                    return Err((
12760                        "sync.segment_expired".to_owned(),
12761                        format!(
12762                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
12763                        ),
12764                    ));
12765                }
12766                transport
12767                    .fetch_blob_url(&url)
12768                    .map_err(|e| (e.code, e.message))?
12769            }
12770        };
12771        #[cfg(feature = "bench-internals")]
12772        drop(download_phase);
12773        #[cfg(feature = "bench-internals")]
12774        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
12775        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
12776        if blob_id_for(&bytes) != blob_id {
12777            return Err(simple(format!(
12778                "blob content address mismatch for {blob_id}"
12779            )));
12780        }
12781        #[cfg(feature = "bench-internals")]
12782        drop(validate_phase);
12783        #[cfg(feature = "bench-internals")]
12784        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
12785        let now = self.clock_now_ms();
12786        self.conn
12787            .execute(
12788                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
12789                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
12790            )
12791            .map_err(|e| simple(e.to_string()))?;
12792        self.conn
12793            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
12794            .map_err(|e| simple(e.to_string()))?;
12795        self.enforce_blob_cache_cap();
12796        #[cfg(feature = "bench-internals")]
12797        drop(insert_phase);
12798        self.get_cached_blob_bytes(&blob_id)
12799            .map_err(simple)?
12800            .ok_or_else(|| simple("blob cache write failed".to_owned()))
12801    }
12802
12803    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
12804        #[cfg(feature = "bench-internals")]
12805        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
12806        let mut stmt = self
12807            .conn
12808            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
12809            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12810        let mut rows = stmt
12811            .query(rusqlite::params![blob_id])
12812            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12813        if let Some(row) = rows
12814            .next()
12815            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
12816        {
12817            let bytes: Vec<u8> = row
12818                .get(0)
12819                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12820            let byte_length: i64 = row
12821                .get(1)
12822                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12823            let media_type: Option<String> = row
12824                .get(2)
12825                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12826            return Ok(Some(FetchedBlob {
12827                blob_id: blob_id.to_owned(),
12828                byte_length,
12829                bytes,
12830                media_type,
12831            }));
12832        }
12833        Ok(None)
12834    }
12835
12836    /// §5.9.7 B4: upload every queued blob before push.
12837    fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
12838        let pending: Vec<(String, Option<String>)> = {
12839            let mut stmt = self
12840                .conn
12841                .prepare(
12842                    "SELECT blob_id, media_type FROM (
12843                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
12844                      UNION ALL
12845                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
12846                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
12847                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
12848                    ) ORDER BY created_at_ms, blob_id",
12849                )
12850                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
12851            let rows = stmt
12852                .query_map([], |row| {
12853                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
12854                })
12855                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
12856            rows.collect::<Result<_, _>>()
12857                .map_err(|error| match error {
12858                    rusqlite::Error::InvalidColumnType(..)
12859                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
12860                        "sync.local_corrupt",
12861                        "Pending blob upload metadata is invalid",
12862                    ),
12863                    _ => TransportError::new("client.failed", error.to_string()),
12864                })?
12865        };
12866        let mut uploads = Vec::new();
12867        for (blob_id, media_type) in pending {
12868            let bytes = (|| {
12869                let stored: Option<(SqlValue, SqlValue)> = self
12870                    .conn
12871                    .query_row(
12872                        "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
12873                        rusqlite::params![blob_id],
12874                        |row| Ok((row.get(0)?, row.get(1)?)),
12875                    )
12876                    .optional()
12877                    .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
12878                let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
12879                    return Err(TransportError::new(
12880                        "sync.local_corrupt",
12881                        "Pending blob upload body is missing or corrupt",
12882                    ));
12883                };
12884                if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
12885                    return Err(TransportError::new(
12886                        "sync.local_corrupt",
12887                        "Pending blob upload body is missing or corrupt",
12888                    ));
12889                }
12890                Ok(bytes)
12891            })();
12892            let failed = bytes.is_err();
12893            uploads.push(crate::round::PendingUpload {
12894                id: blob_id,
12895                bytes,
12896                media_type,
12897            });
12898            if failed {
12899                break;
12900            }
12901        }
12902        Ok(uploads)
12903    }
12904
12905    #[cfg(test)]
12906    fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
12907        for upload in self.pending_blob_uploads()? {
12908            self.upload_one(
12909                transport,
12910                &upload.id,
12911                &upload.bytes?,
12912                upload.media_type.as_deref(),
12913            )?;
12914            self.conn
12915                .execute(
12916                    "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
12917                    rusqlite::params![upload.id],
12918                )
12919                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
12920        }
12921        Ok(())
12922    }
12923
12924    /// §5.9.3: upload one blob, preferring the presigned direct-to-storage
12925    /// grant when the transport supports it, else streaming through the direct
12926    /// host-authenticated endpoint (capability, not fallback). A `Url` grant
12927    /// PUTs direct with no host auth; on a grant PUT failure the client streams
12928    /// through the direct endpoint — a *different, host-authenticated
12929    /// capability*, not a fall-through of the grant's authority.
12930    #[cfg(test)]
12931    fn upload_one(
12932        &self,
12933        transport: &mut dyn Transport,
12934        blob_id: &str,
12935        bytes: &[u8],
12936        media_type: Option<&str>,
12937    ) -> Result<(), TransportError> {
12938        crate::round::upload_one(transport, blob_id, bytes, media_type, self.now_ms)
12939    }
12940
12941    fn visible_blob_ids_sql(&self) -> String {
12942        let mut selects = Vec::new();
12943        for table in &self.schema.tables {
12944            for column in table
12945                .columns
12946                .iter()
12947                .filter(|column| column.ty == ColumnType::BlobRef)
12948            {
12949                let identifier = quote_ident(&column.name);
12950                selects.push(format!(
12951                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
12952                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
12953                     AND json_type({identifier}, '$.blobId') = 'text'",
12954                    quote_ident(&table.name),
12955                ));
12956            }
12957        }
12958        if selects.is_empty() {
12959            "SELECT NULL AS blob_id WHERE 0".to_owned()
12960        } else {
12961            selects.join(" UNION ")
12962        }
12963    }
12964
12965    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
12966    /// staged uploads pin bodies directly; no derived refcount is stored.
12967    fn enforce_blob_cache_cap(&self) {
12968        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
12969            return;
12970        };
12971        let mut total: i64 = self
12972            .conn
12973            .query_row(
12974                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
12975                [],
12976                |row| row.get(0),
12977            )
12978            .unwrap_or(0);
12979        if total <= max_bytes {
12980            return;
12981        }
12982        let query = format!(
12983            "SELECT blob_id, byte_length FROM _syncular_blobs
12984             WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
12985               AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
12986               AND blob_id NOT IN ({})
12987             ORDER BY created_at_ms ASC, blob_id ASC",
12988            self.visible_blob_ids_sql(),
12989        );
12990        let candidates: Vec<(String, i64)> = {
12991            let Ok(mut stmt) = self.conn.prepare(&query) else {
12992                return;
12993            };
12994            let Ok(rows) = stmt.query_map([], |row| {
12995                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
12996            }) else {
12997                return;
12998            };
12999            rows.filter_map(Result::ok).collect()
13000        };
13001        for (blob_id, byte_length) in candidates {
13002            if total <= max_bytes {
13003                break;
13004            }
13005            let _ = self.conn.execute(
13006                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
13007                rusqlite::params![blob_id],
13008            );
13009            total -= byte_length;
13010        }
13011    }
13012
13013    fn delete_unreferenced_cached_blobs(&mut self) {
13014        #[cfg(feature = "bench-internals")]
13015        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
13016        if !self.schema_has_blobs() {
13017            return;
13018        }
13019        self.rebuild_overlay_if_dirty();
13020        let _ = self.conn.execute(
13021            &format!(
13022                "DELETE FROM _syncular_blobs
13023                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
13024                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
13025                   AND blob_id NOT IN ({})",
13026                self.visible_blob_ids_sql(),
13027            ),
13028            [],
13029        );
13030    }
13031
13032    // -- local row storage ----------------------------------------------------------
13033
13034    /// The cached per-table primary-key upsert SQL for `full_table` (see
13035    /// the `insert_sql` field: built once, reused per row).
13036    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
13037        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
13038            return sql.clone();
13039        }
13040        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
13041        columns.push(quote_ident("_syncular_version"));
13042        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
13043        let primary_key = quote_ident(&table.primary_key);
13044        let updates = columns
13045            .iter()
13046            .filter(|column| **column != primary_key)
13047            .map(|column| format!("{column}=excluded.{column}"))
13048            .collect::<Vec<_>>()
13049            .join(", ");
13050        let sql = format!(
13051            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
13052            columns.join(", "),
13053            placeholders.join(", ")
13054        );
13055        self.insert_sql
13056            .borrow_mut()
13057            .insert(full_table.to_owned(), sql.clone());
13058        sql
13059    }
13060
13061    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
13062        self.overlay_dirty.table(table_name);
13063        self.write_row(&base_table(table_name), table_name, row, version)
13064    }
13065
13066    fn write_row(
13067        &self,
13068        full_table: &str,
13069        table_name: &str,
13070        row: &Row,
13071        version: i64,
13072    ) -> Result<(), String> {
13073        #[cfg(feature = "bench-internals")]
13074        let _phase = self.benchmark_phases.start(Phase::RowWrite);
13075        let table = self
13076            .schema
13077            .table(table_name)
13078            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
13079        let sql = self.insert_row_sql(full_table, table);
13080        let mut stmt = self
13081            .conn
13082            .prepare_cached(&sql)
13083            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13084        let params = row
13085            .iter()
13086            .map(RowParam::Cell)
13087            .chain(std::iter::once(RowParam::Version(version)));
13088        stmt.execute(rusqlite::params_from_iter(params))
13089            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13090        Ok(())
13091    }
13092
13093    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
13094        let table = self
13095            .schema
13096            .table(table_name)
13097            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
13098        self.overlay_dirty.table(table_name);
13099        let sql = format!(
13100            "DELETE FROM {} WHERE {}",
13101            base_table(table_name),
13102            row_id_predicate(table)
13103        );
13104        let mut stmt = self
13105            .conn
13106            .prepare_cached(&sql)
13107            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13108        stmt.execute(rusqlite::params![row_id])
13109            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13110        Ok(())
13111    }
13112
13113    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
13114    /// the outbox changed since the last rebuild (the no-op sync round).
13115    fn rebuild_overlay_if_dirty(&mut self) {
13116        if self.overlay_dirty.get() {
13117            self.rebuild_overlay();
13118        }
13119    }
13120
13121    /// §7.1: local reads see outbox state applied optimistically — rebuild
13122    /// every visible table as (base server state) + (pending outbox replay
13123    /// on top). Optimistic rows carry version `-1`.
13124    fn rebuild_overlay(&mut self) {
13125        #[cfg(feature = "bench-internals")]
13126        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
13127        #[cfg(test)]
13128        self.overlay_rebuild_count
13129            .set(self.overlay_rebuild_count.get() + 1);
13130        let dirty = self.overlay_dirty.snapshot();
13131        let tables: Vec<_> = self
13132            .schema
13133            .tables
13134            .iter()
13135            .filter(|table| {
13136                dirty
13137                    .as_ref()
13138                    .is_none_or(|names| names.is_empty() || names.contains(&table.name))
13139            })
13140            .cloned()
13141            .collect();
13142        let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
13143        self.exec("SAVEPOINT syncular_overlay");
13144        for table in &tables {
13145            for index in &table.fts_indexes {
13146                let _ = self.drop_fts_triggers(index);
13147            }
13148            let visible = visible_table(&table.name);
13149            let base = base_table(&table.name);
13150            self.exec(&format!("DELETE FROM {visible}"));
13151            self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
13152        }
13153        for (commit, initial) in &self.failed_commits {
13154            for operation in &commit.ops {
13155                if !names.contains(operation.table.as_str()) || !operation.upsert {
13156                    continue;
13157                }
13158                if let Some(table) = self.schema.table(&operation.table) {
13159                    if self.visible_row(table, &operation.row_id).is_none()
13160                        && operation.values.as_ref().is_some_and(|values| {
13161                            table
13162                                .columns
13163                                .iter()
13164                                .all(|column| values.contains_key(&column.name))
13165                        })
13166                    {
13167                        let key = serde_json::to_string(&(
13168                            operation.table.clone(),
13169                            operation.row_id.clone(),
13170                        ))
13171                        .expect("row key serialization");
13172                        if let Some(Value::Object(values)) = initial.get(&key) {
13173                            self.apply_outbox_ops(&[OutboxOp {
13174                                values: Some(values.clone()),
13175                                ..operation.clone()
13176                            }]);
13177                        }
13178                    }
13179                }
13180            }
13181            self.apply_outbox_ops(
13182                commit
13183                    .ops
13184                    .iter()
13185                    .filter(|op| names.contains(op.table.as_str())),
13186            );
13187        }
13188        for commit in &self.outbox {
13189            self.apply_outbox_ops(
13190                commit
13191                    .ops
13192                    .iter()
13193                    .filter(|op| names.contains(op.table.as_str())),
13194            );
13195        }
13196        for table in &tables {
13197            for index in &table.fts_indexes {
13198                let _ = self.rebuild_fts_projection(table, index);
13199                let _ = self.create_fts_triggers(table, index);
13200            }
13201        }
13202        self.exec("RELEASE syncular_overlay");
13203        self.overlay_dirty.set(false);
13204    }
13205
13206    /// §7.1 replay: every operation is applied over the current visible
13207    /// state. An operation that no longer applies (a server row now holds its
13208    /// unique value) stays pending and invisible until a later replay admits
13209    /// it or the server answers its push.
13210    fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
13211        #[cfg(feature = "bench-internals")]
13212        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
13213        for op in ops {
13214            #[cfg(feature = "bench-internals")]
13215            if let Some(phase) = &mut phase {
13216                phase.unit();
13217            }
13218            if op.upsert {
13219                if let (Some(table), Some(values)) = (
13220                    self.schema
13221                        .table(&op.table)
13222                        .filter(|table| table.indexes.iter().any(|index| index.unique)),
13223                    op.values.as_ref(),
13224                ) {
13225                    let mut intended: Map<String, Value> = self
13226                        .visible_row(table, &op.row_id)
13227                        .map(|(row, _)| {
13228                            table
13229                                .columns
13230                                .iter()
13231                                .zip(&row)
13232                                .map(|(column, value)| {
13233                                    (column.name.clone(), column_value_to_json(value))
13234                                })
13235                                .collect()
13236                        })
13237                        .unwrap_or_default();
13238                    intended.extend(values.clone());
13239                    if !self
13240                        .unique_conflicts(table, &intended, false)
13241                        .expect("overlay unique-key read failed")
13242                        .is_empty()
13243                    {
13244                        continue;
13245                    }
13246                }
13247            }
13248            let _ = self.apply_outbox_op(op);
13249        }
13250    }
13251
13252    fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
13253        let Some(table) = self.schema.table(&op.table) else {
13254            return Ok(());
13255        };
13256        let visible = visible_table(&table.name);
13257        if !op.upsert {
13258            let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
13259            self.conn
13260                .prepare_cached(&sql)
13261                .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
13262                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13263            return Ok(());
13264        }
13265        let Some(values) = op.values.as_ref() else {
13266            return Ok(());
13267        };
13268        // §7.1: the overlay applies the operation's PRESENT columns
13269        // over the current local row. A partial operation over an
13270        // absent local row leaves it absent — the server answers it
13271        // with sync.row_deleted or sync.row_missing (§6.2).
13272        let full = table
13273            .columns
13274            .iter()
13275            .all(|column| values.contains_key(&column.name));
13276        let local = self.visible_row(table, &op.row_id);
13277        if local.is_none() && !full {
13278            return Ok(());
13279        }
13280        let mut row: Row = Vec::with_capacity(table.columns.len());
13281        for (index, column) in table.columns.iter().enumerate() {
13282            let incoming = match values.get(&column.name) {
13283                Some(raw) => json_to_column_value(column, Some(raw)),
13284                None => Ok(local
13285                    .as_ref()
13286                    .and_then(|(values, _)| values.get(index).cloned())
13287                    .flatten()),
13288            };
13289            match incoming {
13290                Ok(value) => row.push(value),
13291                Err(_) => return Ok(()),
13292            }
13293        }
13294        let version = local.as_ref().map_or(-1, |(_, version)| *version);
13295        self.write_row(&visible, &table.name, &row, version)
13296            .map_err(|error| {
13297                if error.starts_with("UNIQUE constraint failed:") {
13298                    "sync.constraint_violation: local write violates a unique constraint".to_owned()
13299                } else {
13300                    error
13301                }
13302            })
13303    }
13304
13305    /// The visible (optimistic) row's values plus version, or `None` when the
13306    /// row is absent from the overlay.
13307    fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
13308        self.stored_row(table, row_id, false)
13309    }
13310
13311    fn stored_row(&self, table: &TableSchema, row_id: &str, base: bool) -> Option<(Row, i64)> {
13312        let sql = format!(
13313            "SELECT * FROM {} WHERE {}",
13314            if base {
13315                base_table(&table.name)
13316            } else {
13317                visible_table(&table.name)
13318            },
13319            row_id_predicate(table)
13320        );
13321        let mut stmt = self.conn.prepare_cached(&sql).ok()?;
13322        let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
13323        let row = rows.next().ok()??;
13324        let mut values = Vec::with_capacity(table.columns.len());
13325        for (index, column) in table.columns.iter().enumerate() {
13326            let raw = row.get_ref(index).ok()?;
13327            let json = sql_ref_to_json(column, raw);
13328            values.push(json_to_column_value(column, Some(&json)).ok()?);
13329        }
13330        let version: i64 = row.get(table.columns.len()).ok()?;
13331        Some((values, version))
13332    }
13333
13334    fn exec(&self, sql: &str) {
13335        let _ = self.conn.execute_batch(sql);
13336    }
13337
13338    // -- realtime (§8) ---------------------------------------------------------------
13339
13340    /// §8.8: the configured realtime policy.
13341    #[must_use]
13342    pub fn realtime_policy(&self) -> RealtimePolicy {
13343        self.realtime_policy
13344    }
13345
13346    /// §8.8: the explicit realtime availability state.
13347    #[must_use]
13348    pub fn realtime_state(&self) -> RealtimeState {
13349        self.realtime_state
13350    }
13351
13352    /// §8.8: set the realtime policy. Refused while the socket is connected:
13353    /// a policy change never silently re-routes an ownership state.
13354    pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
13355        if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
13356            return Err(
13357                "sync.invalid_request: disconnect realtime before changing the realtime policy"
13358                    .to_owned(),
13359            );
13360        }
13361        self.realtime_policy = policy;
13362        match policy {
13363            RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
13364            RealtimePolicy::Required | RealtimePolicy::Optional
13365                if self.realtime_state == RealtimeState::Disabled =>
13366            {
13367                self.set_realtime_state(RealtimeState::Disconnected, None);
13368            }
13369            RealtimePolicy::Required | RealtimePolicy::Optional => {}
13370        }
13371        Ok(())
13372    }
13373
13374    /// Record an availability transition, keeping the reason code bounded and
13375    /// code-like (§7.6) and clearing it where it cannot apply.
13376    fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
13377        self.realtime_state = state;
13378        self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
13379        if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
13380            self.realtime_reason_code = None;
13381            self.realtime_retry_delay_ms = None;
13382        }
13383    }
13384
13385    #[must_use]
13386    fn realtime_connected(&self) -> bool {
13387        self.realtime_state == RealtimeState::Connected
13388    }
13389
13390    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
13391        if self.realtime_policy == RealtimePolicy::Off {
13392            return Err(
13393                "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
13394            );
13395        }
13396        if self.realtime_connected() {
13397            return Ok(());
13398        }
13399        self.realtime_state = RealtimeState::Connecting;
13400        match transport.realtime_connect_for_client(&self.client_id) {
13401            Ok(()) => {
13402                self.set_realtime_state(RealtimeState::Connected, None);
13403                Ok(())
13404            }
13405            Err(error) => {
13406                self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
13407                Err(format!("{}: {}", error.code, error.message))
13408            }
13409        }
13410    }
13411
13412    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
13413        self.cancel_sync_round();
13414        // A deliberately disconnected or policy-disabled client has nothing
13415        // to release; a `lost` socket still does (the host owns the handle).
13416        if self.realtime_state == RealtimeState::Disconnected
13417            || self.realtime_state == RealtimeState::Disabled
13418        {
13419            return;
13420        }
13421        let _ = transport.realtime_close();
13422        self.set_realtime_state(
13423            if self.realtime_policy == RealtimePolicy::Off {
13424                RealtimeState::Disabled
13425            } else {
13426                RealtimeState::Disconnected
13427            },
13428            None,
13429        );
13430        self.presence.clear(); // §8.6.1: presence is per-connection
13431    }
13432
13433    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
13434    /// document for `scope_key`. Requires a live socket; the document is
13435    /// ephemeral (lost on disconnect). Authorization is the connection's
13436    /// registration (§8.6.3) — an unheld key is rejected loudly by the
13437    /// server with `presence.forbidden`.
13438    pub fn set_presence(
13439        &mut self,
13440        transport: &mut dyn Transport,
13441        scope_key: &str,
13442        doc: Option<&Value>,
13443    ) -> Result<(), String> {
13444        if !self.realtime_connected() {
13445            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
13446        }
13447        let text = encode_presence_publish(scope_key, doc);
13448        transport
13449            .realtime_send(&text)
13450            .map_err(|e| format!("{}: {}", e.code, e.message))
13451    }
13452
13453    /// §8.6: the peers currently present on a scope key (ephemeral).
13454    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
13455        self.presence
13456            .get(scope_key)
13457            .map(|peers| peers.values().cloned().collect())
13458            .unwrap_or_default()
13459    }
13460
13461    /// §8.6 apply an inbound presence fanout to the local map.
13462    fn apply_presence(
13463        &mut self,
13464        scope_key: String,
13465        kind: Option<PresenceKind>,
13466        actor_id: Option<String>,
13467        client_id: Option<String>,
13468        doc: Option<Value>,
13469        error: Option<String>,
13470    ) {
13471        // The publisher-directed error variant is out-of-band; nothing to
13472        // record in the peer map.
13473        if error.is_some() {
13474            return;
13475        }
13476        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
13477            return;
13478        };
13479        let peer_key = format!("{actor_id} {client_id}");
13480        match kind {
13481            PresenceKind::Leave => {
13482                if let Some(peers) = self.presence.get_mut(&scope_key) {
13483                    peers.remove(&peer_key);
13484                    if peers.is_empty() {
13485                        self.presence.remove(&scope_key);
13486                    }
13487                }
13488            }
13489            _ => {
13490                let doc = match doc {
13491                    Some(Value::Object(_)) => doc.unwrap(),
13492                    _ => return,
13493                };
13494                self.presence.entry(scope_key).or_default().insert(
13495                    peer_key,
13496                    PresencePeer {
13497                        actor_id,
13498                        client_id,
13499                        doc,
13500                    },
13501                );
13502            }
13503        }
13504    }
13505
13506    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
13507    pub fn on_realtime_text(&mut self, text: &str) {
13508        match parse_control(text) {
13509            Ok(ControlMessage::Hello { requires_sync, .. }) => {
13510                if requires_sync {
13511                    // §8.1: pull before trusting the socket for continuity.
13512                    self.set_sync_needed(true, true);
13513                }
13514            }
13515            Ok(ControlMessage::Presence {
13516                scope_key,
13517                kind,
13518                actor_id,
13519                client_id,
13520                doc,
13521                error,
13522                ..
13523            }) => {
13524                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
13525            }
13526            Ok(ControlMessage::Wake { .. }) => {
13527                // §8.3: any wake-up means "run a pull soon", never data.
13528                self.set_sync_needed(true, true);
13529            }
13530            _ => {}
13531        }
13532    }
13533
13534    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
13535    /// a pull response per section; an unapplied delta is a wake-up.
13536    /// Mailbox hosts send durable acknowledgements on their I/O executor.
13537    pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
13538        let mut transport = crate::round::DownloadResults::default();
13539        self.on_realtime_binary(&mut transport, bytes);
13540        transport.controls
13541    }
13542
13543    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
13544        if self.stopped {
13545            return;
13546        }
13547        #[cfg(feature = "bench-internals")]
13548        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
13549        let message = match decode_message(bytes) {
13550            Ok(m) if m.msg_kind == MsgKind::Response => m,
13551            _ => {
13552                self.set_sync_needed(true, true);
13553                return;
13554            }
13555        };
13556        #[cfg(feature = "bench-internals")]
13557        drop(decode_phase);
13558        let mut frames = message.frames.into_iter();
13559        let mut applied_cursor: Option<i64> = None;
13560        let mut any_covered = false;
13561        let mut dropped = false;
13562        while let Some(frame) = frames.next() {
13563            let Frame::SubStart {
13564                id,
13565                status,
13566                effective_scopes,
13567                ..
13568            } = frame
13569            else {
13570                continue;
13571            };
13572            let mut body = Vec::new();
13573            let mut next_cursor: Option<i64> = None;
13574            for inner in frames.by_ref() {
13575                match inner {
13576                    Frame::SubEnd {
13577                        next_cursor: nc, ..
13578                    } => {
13579                        next_cursor = Some(nc);
13580                        break;
13581                    }
13582                    Frame::Unknown { .. } => {}
13583                    other => body.push(other),
13584                }
13585            }
13586            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
13587                dropped = true;
13588                continue;
13589            };
13590            let sub = &self.subs[sub_index];
13591            // §8.2: only locally active, not mid-bootstrap subscriptions
13592            // apply; skipped sections are repaired by the next pull.
13593            if status != SubStatus::Active
13594                || sub.state != SubState::Active
13595                || sub.bootstrap_state.is_some()
13596                || !sub.synced_once
13597            {
13598                dropped = true;
13599                continue;
13600            }
13601            if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
13602                // Idempotent redelivery of an already-covered window.
13603                any_covered = true;
13604                continue;
13605            }
13606            let previous_effective = self.subs[sub_index].effective.clone();
13607            let previous_cursor = self.subs[sub_index].cursor;
13608            self.subs[sub_index].effective = Some(effective_scopes);
13609            let mut failed = false;
13610            for inner in body {
13611                match inner {
13612                    Frame::Commit {
13613                        tables, changes, ..
13614                    } => {
13615                        if self.apply_commit_frame(&tables, &changes).is_err() {
13616                            failed = true;
13617                            break;
13618                        }
13619                    }
13620                    _ => {
13621                        failed = true;
13622                        break;
13623                    }
13624                }
13625            }
13626            let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
13627                self.apply_sub_end(sub_index, cursor, None)
13628                    .ok()
13629                    .map(|()| cursor)
13630            });
13631            let Some(next_cursor) = committed else {
13632                self.subs[sub_index].effective = previous_effective;
13633                self.subs[sub_index].cursor = previous_cursor;
13634                dropped = true;
13635                continue;
13636            };
13637            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
13638        }
13639        if let Some(cursor) = applied_cursor {
13640            // §8.2 ack point: the highest applied SUB_END.nextCursor.
13641            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
13642            let _ = transport.realtime_send(&ack);
13643        } else if !any_covered || dropped {
13644            // §8.2: a delta not applied at all is treated as a wake-up.
13645            self.set_sync_needed(true, true);
13646        }
13647    }
13648
13649    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
13650    /// cursor across active, non-bootstrapping subscriptions that have
13651    /// synced at least once. No such subscription, no ack.
13652    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
13653        if !self.realtime_connected() {
13654            return;
13655        }
13656        let floor = self
13657            .subs
13658            .iter()
13659            .filter(|s| {
13660                s.state == SubState::Active
13661                    && s.bootstrap_state.is_none()
13662                    && s.synced_once
13663                    && s.cursor >= 0
13664            })
13665            .map(|s| s.cursor)
13666            .min();
13667        if let Some(cursor) = floor {
13668            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
13669            let _ = transport.realtime_send(&ack);
13670        }
13671    }
13672}
13673
13674#[cfg(test)]
13675mod previous_version_wiring_tests {
13676    use super::*;
13677    use serde_json::{json, Value};
13678
13679    fn previous_version_schema(version: i32) -> Value {
13680        json!({
13681            "version": version,
13682            "tables": [{
13683                "name": "tasks",
13684                "primaryKey": "id",
13685                "columns": [
13686                    { "name": "id", "type": "string", "nullable": false },
13687                    { "name": "note", "type": "string", "nullable": true }
13688                ],
13689                "scopes": []
13690            }]
13691        })
13692    }
13693
13694    fn previous_version_limits() -> crate::api::ClientLimits {
13695        crate::api::ClientLimits {
13696            previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
13697                enabled: true,
13698                ..Default::default()
13699            }),
13700            ..Default::default()
13701        }
13702    }
13703
13704    /// The configuration the live client carries, for a direct capture call.
13705    fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
13706        previous_version_limits()
13707            .previous_version_context
13708            .expect("configured")
13709    }
13710
13711    /// Capture the container the way a bump would, so a read test starts from a
13712    /// container this client's own state will then decide about.
13713    fn recapture_previous_version(client: &SyncClient) {
13714        let replica_path = client
13715            .previous_version_replica_path()
13716            .expect("file-backed replica");
13717        let outcome = crate::previous_version::capture_previous_version_from_replica(
13718            &client.conn,
13719            &replica_path,
13720            2,
13721            2,
13722            &previous_version_config(),
13723            1,
13724        )
13725        .expect("capture");
13726        assert!(matches!(
13727            outcome,
13728            crate::previous_version::CaptureOutcome::Captured(_)
13729        ));
13730    }
13731
13732    fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
13733        Subscription {
13734            id: "wiring-sub".to_owned(),
13735            table: "tasks".to_owned(),
13736            requested: Vec::new(),
13737            params: None,
13738            cursor,
13739            bootstrap_state: bootstrap_state.map(str::to_owned),
13740            state,
13741            reason_code: None,
13742            effective: None,
13743            synced_once: false,
13744        }
13745    }
13746
13747    #[test]
13748    fn previous_version_read_reasons_come_from_live_client_state() {
13749        use crate::previous_version::{
13750            PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
13751        };
13752        let path = std::env::temp_dir()
13753            .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
13754            .to_string_lossy()
13755            .into_owned();
13756        let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
13757        {
13758            let mut setup = SyncClient::open_path(
13759                "wiring".to_owned(),
13760                &previous_version_schema(1),
13761                previous_version_limits(),
13762                &path,
13763            )
13764            .expect("v1 install");
13765            setup
13766                .mutate(vec![Mutation::Upsert {
13767                    table: "tasks".to_owned(),
13768                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
13769                    base_version: None,
13770                }])
13771                .expect("seed a row");
13772        }
13773        let mut client = SyncClient::open_path(
13774            "wiring".to_owned(),
13775            &previous_version_schema(2),
13776            previous_version_limits(),
13777            &path,
13778        )
13779        .expect("v2 bump captures");
13780        assert!(std::path::Path::new(&container).exists());
13781        let spec = PreviousVersionReadSpec {
13782            table: "tasks".to_owned(),
13783            ..Default::default()
13784        };
13785
13786        // §7.3.5 lease error ⇒ lease-inactive, and the read discards.
13787        recapture_previous_version(&client);
13788        client.lease_state = Some(LeaseState {
13789            lease_id: None,
13790            expires_at_ms: None,
13791            error_code: Some("lease.expired".to_owned()),
13792        });
13793        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13794        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
13795        assert!(!snapshot.available);
13796        assert!(!std::path::Path::new(&container).exists());
13797
13798        // An expired lease uses the client clock (injected, no sleeps).
13799        recapture_previous_version(&client);
13800        client.set_now_ms(1_000);
13801        client.lease_state = Some(LeaseState {
13802            lease_id: Some("lease".to_owned()),
13803            expires_at_ms: Some(1_000),
13804            error_code: None,
13805        });
13806        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13807        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
13808        assert!(!std::path::Path::new(&container).exists());
13809        client.lease_state = None;
13810
13811        // A revoked subscription ⇒ scope-revoked.
13812        recapture_previous_version(&client);
13813        client.subs.push(subscription(SubState::Revoked, 0, None));
13814        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13815        assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
13816        assert!(!std::path::Path::new(&container).exists());
13817        client.subs.clear();
13818
13819        // Coverage completion: every ACTIVE subscription has a cursor and no
13820        // resume token — a still-resuming subscription is not completion.
13821        recapture_previous_version(&client);
13822        client
13823            .subs
13824            .push(subscription(SubState::Active, 0, Some("resume")));
13825        assert!(!client.previous_version_coverage_complete());
13826        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13827        assert!(snapshot.available);
13828        assert_eq!(snapshot.previous_version, Some(2));
13829        assert_eq!(snapshot.rows.len(), 1);
13830
13831        recapture_previous_version(&client);
13832        client.subs[0].bootstrap_state = None;
13833        assert!(client.previous_version_coverage_complete());
13834        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
13835        assert_eq!(
13836            snapshot.reason,
13837            Some(PreviousVersionReason::CoverageComplete)
13838        );
13839        assert!(!std::path::Path::new(&container).exists());
13840        client.subs.clear();
13841
13842        // With no active subscription at all, coverage is NOT complete.
13843        assert!(!client.previous_version_coverage_complete());
13844
13845        drop(client);
13846        let _ = std::fs::remove_file(&path);
13847    }
13848
13849    /// RFC 0005 D7 lifetime drops. The read surface already proves the reasons
13850    /// come from live state; this proves the OTHER caller —
13851    /// `previous_version_lifetime_check`, the once-per-sync-round trigger —
13852    /// actually removes the container for lease-inactivity, coverage
13853    /// completion and scope revocation, and leaves it alone while the
13854    /// replacement bootstrap is still resuming.
13855    #[test]
13856    fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
13857        let path = std::env::temp_dir()
13858            .join(format!(
13859                "syncular-prev-lifetime-{}.db",
13860                uuid::Uuid::new_v4()
13861            ))
13862            .to_string_lossy()
13863            .into_owned();
13864        let container = format!(
13865            "{path}{}",
13866            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
13867        );
13868        let exists = || std::path::Path::new(&container).exists();
13869        {
13870            let mut setup = SyncClient::open_path(
13871                "lifetime".to_owned(),
13872                &previous_version_schema(1),
13873                previous_version_limits(),
13874                &path,
13875            )
13876            .expect("v1 install");
13877            setup
13878                .mutate(vec![Mutation::Upsert {
13879                    table: "tasks".to_owned(),
13880                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
13881                    base_version: None,
13882                }])
13883                .expect("seed a row");
13884        }
13885        let mut client = SyncClient::open_path(
13886            "lifetime".to_owned(),
13887            &previous_version_schema(2),
13888            previous_version_limits(),
13889            &path,
13890        )
13891        .expect("v2 bump captures");
13892        // Pin the clock so the 24h TTL never fires in this test (the TTL has
13893        // its own coverage); the capture record's createdAtMs is 1.
13894        client.set_now_ms(1_000);
13895        assert!(exists());
13896
13897        // §7.3.5 lease error ⇒ the lifetime check drops the container.
13898        client.lease_state = Some(LeaseState {
13899            lease_id: None,
13900            expires_at_ms: None,
13901            error_code: Some("lease.expired".to_owned()),
13902        });
13903        client.previous_version_lifetime_check();
13904        assert!(!exists());
13905        client.lease_state = None;
13906
13907        // §7.4.5 coverage completion (every active sub has a cursor and no
13908        // resume token) ⇒ drop.
13909        recapture_previous_version(&client);
13910        client.subs.push(subscription(SubState::Active, 0, None));
13911        client.previous_version_lifetime_check();
13912        assert!(!exists());
13913        client.subs.clear();
13914
13915        // §3.3 scope revocation ⇒ drop.
13916        recapture_previous_version(&client);
13917        client.subs.push(subscription(SubState::Revoked, 0, None));
13918        client.previous_version_lifetime_check();
13919        assert!(!exists());
13920        client.subs.clear();
13921
13922        // Negative control: a still-resuming active subscription is NOT
13923        // coverage completion, so the container survives the same call.
13924        recapture_previous_version(&client);
13925        client
13926            .subs
13927            .push(subscription(SubState::Active, 0, Some("resume")));
13928        client.previous_version_lifetime_check();
13929        assert!(exists());
13930        client.subs.clear();
13931
13932        drop(client);
13933        let _ = std::fs::remove_file(&path);
13934        let _ = std::fs::remove_file(&container);
13935        let _ = std::fs::remove_file(format!("{container}-wal"));
13936        let _ = std::fs::remove_file(format!("{container}-shm"));
13937    }
13938}